From ec94449a63d59afea4cdb2f62bd742926b44b827 Mon Sep 17 00:00:00 2001 From: Alexandre Petitjean Date: Thu, 15 Jan 2015 22:25:34 +0100 Subject: [PATCH 001/314] Handle tags_changed in Core and send event to CoreListener --- mopidy/core/actor.py | 20 ++++++++++++++++++++ mopidy/core/listener.py | 11 +++++++++++ 2 files changed, 31 insertions(+) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 75c06f69..ccd1e4c5 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -102,6 +102,26 @@ class Core( # Forward event from mixer to frontends CoreListener.send('mute_changed', mute=mute) + def tags_changed(self, tags): + # Should return only one audio instance + audios = pykka.ActorRegistry.get_by_class(audio.Audio) + + if audios and len(audios) == 1: + audio_proxy = audios[0].proxy() + + # Gets metadata + future = audio_proxy.get_current_tags() + tags_data = future.get() + if not tags_data or not isinstance(tags_data, dict): + return + + # Convert to track and set playback + track = audio.utils.convert_tags_to_track(tags_data) + self.playback.current_track = track + + # Send event to frontends + CoreListener.send('track_metadata_changed', track_metadata=track) + class Backends(list): def __init__(self, backends): diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index 2c027e1b..c94037b2 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -163,3 +163,14 @@ class CoreListener(listener.Listener): :type time_position: int """ pass + + def track_metadata_changed(self, track_metadata): + """ + Called whenever current track's metadata changed + + *MAY* be implemented by actor. + + :param track_metadata: the track with metadata + :type track_metadata: :class:`mopidy.models.Track` + """ + pass From e4dd04cfb77e0cd930a84ad4f7d983a5248a4b90 Mon Sep 17 00:00:00 2001 From: Alexandre Petitjean Date: Fri, 16 Jan 2015 21:41:55 +0100 Subject: [PATCH 002/314] One step beyond --- mopidy/core/actor.py | 12 +++--------- mopidy/core/listener.py | 5 +---- mopidy/core/playback.py | 8 ++++++++ mopidy/mpd/actor.py | 3 +++ mopidy/mpd/protocol/current_playlist.py | 4 ++++ mopidy/mpd/translator.py | 22 ++++++++++++++++++++++ 6 files changed, 41 insertions(+), 13 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index ccd1e4c5..15a94665 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -109,18 +109,12 @@ class Core( if audios and len(audios) == 1: audio_proxy = audios[0].proxy() - # Gets metadata + # Request available metadata and put in playback future = audio_proxy.get_current_tags() - tags_data = future.get() - if not tags_data or not isinstance(tags_data, dict): - return - - # Convert to track and set playback - track = audio.utils.convert_tags_to_track(tags_data) - self.playback.current_track = track + self.playback.current_metadata = future.get() # Send event to frontends - CoreListener.send('track_metadata_changed', track_metadata=track) + CoreListener.send('current_metadata_changed') class Backends(list): diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index c94037b2..9d952473 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -164,13 +164,10 @@ class CoreListener(listener.Listener): """ pass - def track_metadata_changed(self, track_metadata): + def current_metadata_changed(self): """ Called whenever current track's metadata changed *MAY* be implemented by actor. - - :param track_metadata: the track with metadata - :type track_metadata: :class:`mopidy.models.Track` """ pass diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index ef3cc4b2..4b5f4b77 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -126,6 +126,14 @@ class PlaybackController(object): mute = property(get_mute, set_mute) """Mute state as a :class:`True` if muted, :class:`False` otherwise""" + def get_current_metadata(self): + return self.current_metadata + + current_metadata = None + """ + The currently playing metadata :class:`dict`, or :class:`None`. + """ + # Methods # TODO: remove this. diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index c8123c32..1f213812 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -71,3 +71,6 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): def mute_changed(self, mute): self.send_idle('output') + + def current_metadata_changed(self): + self.send_idle('playlist') diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index 33c090e3..09121df1 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -278,6 +278,10 @@ def plchanges(context, version): if int(version) < context.core.tracklist.version.get(): return translator.tracks_to_mpd_format( context.core.tracklist.tl_tracks.get()) + elif int(version) == context.core.tracklist.version.get(): + return translator.metadata_track_to_mpd_format( + context.core.playback.current_tl_track.get(), + context.core.playback.current_metadata.get()) @protocol.commands.add('plchangesposid', version=protocol.INT) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 23fb2874..95b1b263 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -86,6 +86,28 @@ def track_to_mpd_format(track, position=None): return result +def metadata_track_to_mpd_format(track, metadata): + # TODO: replace track data with metadata + result = [] + if track: + if isinstance(track, TlTrack): + (tlid, track) = track + else: + (tlid, track) = (None, track) + result = [ + ('file', track.uri or ''), + ('Time', track.length and (track.length // 1000) or 0), + ('Artist', artists_to_mpd_format(track.artists)), + ('Album', track.album and track.album.name or ''), + ] + if metadata and 'title' in metadata: + result.append(('Title', metadata['title'])) + else: + result.append(('Title', track.name or '')) + + return result + + def artists_to_mpd_format(artists): """ Format track artists for output to MPD client. From 7ee1935315ab0499e2c3f6ba8ca65cbb1794a27b Mon Sep 17 00:00:00 2001 From: Alexandre Petitjean Date: Fri, 16 Jan 2015 23:45:07 +0100 Subject: [PATCH 003/314] MPD gets metadata's updates from stream --- mopidy/core/actor.py | 4 ++- mopidy/core/playback.py | 9 +++--- mopidy/mpd/protocol/current_playlist.py | 4 +-- mopidy/mpd/translator.py | 37 ++++++++++++------------- 4 files changed, 28 insertions(+), 26 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 15a94665..4ad38cb6 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -7,6 +7,7 @@ import pykka from mopidy import audio, backend, mixer from mopidy.audio import PlaybackState +from mopidy.audio.utils import convert_tags_to_track from mopidy.core.history import HistoryController from mopidy.core.library import LibraryController from mopidy.core.listener import CoreListener @@ -111,7 +112,8 @@ class Core( # Request available metadata and put in playback future = audio_proxy.get_current_tags() - self.playback.current_metadata = future.get() + mtdata = future.get() + self.playback.current_md_track = convert_tags_to_track(mtdata) # Send event to frontends CoreListener.send('current_metadata_changed') diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 4b5f4b77..ad99e6ec 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -126,12 +126,13 @@ class PlaybackController(object): mute = property(get_mute, set_mute) """Mute state as a :class:`True` if muted, :class:`False` otherwise""" - def get_current_metadata(self): - return self.current_metadata + def get_current_metadata_track(self): + return self.current_md_track - current_metadata = None + current_md_track = None """ - The currently playing metadata :class:`dict`, or :class:`None`. + The currently playing metadata :class:`mopidy.models.Track`, + or :class:`None`. """ # Methods diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index 09121df1..d5464791 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -278,10 +278,10 @@ def plchanges(context, version): if int(version) < context.core.tracklist.version.get(): return translator.tracks_to_mpd_format( context.core.tracklist.tl_tracks.get()) - elif int(version) == context.core.tracklist.version.get(): + else: return translator.metadata_track_to_mpd_format( context.core.playback.current_tl_track.get(), - context.core.playback.current_metadata.get()) + context.core.playback.current_md_track.get()) @protocol.commands.add('plchangesposid', version=protocol.INT) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 95b1b263..0788b2d6 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -2,7 +2,7 @@ from __future__ import absolute_import, unicode_literals import re -from mopidy.models import TlTrack +from mopidy.models import TlTrack, Track # TODO: special handling of local:// uri scheme normalize_path_re = re.compile(r'[^/]+') @@ -87,25 +87,24 @@ def track_to_mpd_format(track, position=None): def metadata_track_to_mpd_format(track, metadata): - # TODO: replace track data with metadata - result = [] - if track: - if isinstance(track, TlTrack): - (tlid, track) = track - else: - (tlid, track) = (None, track) - result = [ - ('file', track.uri or ''), - ('Time', track.length and (track.length // 1000) or 0), - ('Artist', artists_to_mpd_format(track.artists)), - ('Album', track.album and track.album.name or ''), - ] - if metadata and 'title' in metadata: - result.append(('Title', metadata['title'])) - else: - result.append(('Title', track.name or '')) + """ + Create new Track with a mix of track and metadata + and convert it to mpd format + """ + # Sanity check + if track is None or metadata is None: + return None - return result + # + if isinstance(track, TlTrack): + (tlid, track) = track + + track_kwargs = {k: v for k, v in track.__dict__.items() if v} + for k, v in metadata.__dict__.items(): + if v: + track_kwargs[k] = v + result_track = Track(**track_kwargs) + return track_to_mpd_format(result_track) def artists_to_mpd_format(artists): From eeed2973f1236a25b598586c1634cbc225d359d9 Mon Sep 17 00:00:00 2001 From: Alexandre Petitjean Date: Sat, 17 Jan 2015 14:30:40 +0100 Subject: [PATCH 004/314] Fix metadata refresh with more than one pl in tracklist --- mopidy/core/actor.py | 32 ++++++++++++++++++------- mopidy/mpd/protocol/current_playlist.py | 23 ++++++++++++++---- mopidy/mpd/translator.py | 23 +----------------- 3 files changed, 43 insertions(+), 35 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 4ad38cb6..dbcfc9a1 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -14,6 +14,7 @@ from mopidy.core.listener import CoreListener from mopidy.core.playback import PlaybackController from mopidy.core.playlists import PlaylistsController from mopidy.core.tracklist import TracklistController +from mopidy.models import TlTrack, Track from mopidy.utils import versioning @@ -107,16 +108,31 @@ class Core( # Should return only one audio instance audios = pykka.ActorRegistry.get_by_class(audio.Audio) - if audios and len(audios) == 1: - audio_proxy = audios[0].proxy() + # Validity checks + if audios is None or len(audios) != 1: + return + if self.playback.current_tl_track is None: + return - # Request available metadata and put in playback - future = audio_proxy.get_current_tags() - mtdata = future.get() - self.playback.current_md_track = convert_tags_to_track(mtdata) + audio_proxy = audios[0].proxy() - # Send event to frontends - CoreListener.send('current_metadata_changed') + # Request available metadata and set a track + future = audio_proxy.get_current_tags() + mt_track = convert_tags_to_track(future.get()) + + # Merge current_tl_track with metadata in current_md_track + c_track = self.playback.current_tl_track.track + track_kwargs = {k: v for k, v in c_track.__dict__.items() if v} + for k, v in mt_track.__dict__.items(): + if v: + track_kwargs[k] = v + + self.playback.current_md_track = TlTrack(**{ + 'tlid': self.playback.current_tl_track.tlid, + 'track': Track(**track_kwargs)}) + + # Send event to frontends + CoreListener.send('current_metadata_changed') class Backends(list): diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index d5464791..bc89afcb 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -275,13 +275,26 @@ def plchanges(context, version): - Calls ``plchanges "-1"`` two times per second to get the entire playlist. """ # XXX Naive implementation that returns all tracks as changed - if int(version) < context.core.tracklist.version.get(): + tracklist_version = context.core.tracklist.version.get() + iversion = int(version) + if iversion < tracklist_version: return translator.tracks_to_mpd_format( context.core.tracklist.tl_tracks.get()) - else: - return translator.metadata_track_to_mpd_format( - context.core.playback.current_tl_track.get(), - context.core.playback.current_md_track.get()) + elif iversion == tracklist_version: + # If version are equals, it is just a metadata update + # So we replace the updated track in playlist + current_md_track = context.core.playback.current_md_track.get() + if current_md_track is None: + return None + + ntl_tracks = [] + tl_tracks = context.core.tracklist.tl_tracks.get() + for tl_track in tl_tracks: + if tl_track.tlid == current_md_track.tlid: + ntl_tracks.append(current_md_track) + else: + ntl_tracks.append(tl_track) + return translator.tracks_to_mpd_format(ntl_tracks) @protocol.commands.add('plchangesposid', version=protocol.INT) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 0788b2d6..23fb2874 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -2,7 +2,7 @@ from __future__ import absolute_import, unicode_literals import re -from mopidy.models import TlTrack, Track +from mopidy.models import TlTrack # TODO: special handling of local:// uri scheme normalize_path_re = re.compile(r'[^/]+') @@ -86,27 +86,6 @@ def track_to_mpd_format(track, position=None): return result -def metadata_track_to_mpd_format(track, metadata): - """ - Create new Track with a mix of track and metadata - and convert it to mpd format - """ - # Sanity check - if track is None or metadata is None: - return None - - # - if isinstance(track, TlTrack): - (tlid, track) = track - - track_kwargs = {k: v for k, v in track.__dict__.items() if v} - for k, v in metadata.__dict__.items(): - if v: - track_kwargs[k] = v - result_track = Track(**track_kwargs) - return track_to_mpd_format(result_track) - - def artists_to_mpd_format(artists): """ Format track artists for output to MPD client. From ef950a5e15c97eb1445a283afbc6760eb2ec7f73 Mon Sep 17 00:00:00 2001 From: Alexandre Petitjean Date: Tue, 20 Jan 2015 20:01:54 +0100 Subject: [PATCH 005/314] Adds audio in Core --- mopidy/commands.py | 6 +++--- mopidy/core/actor.py | 16 ++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/mopidy/commands.py b/mopidy/commands.py index fecabe98..d9b4ce0e 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -279,7 +279,7 @@ class RootCommand(Command): mixer = self.start_mixer(config, mixer_class) audio = self.start_audio(config, mixer) backends = self.start_backends(config, backend_classes, audio) - core = self.start_core(mixer, backends) + core = self.start_core(mixer, backends, audio) self.start_frontends(config, frontend_classes, core) loop.run() except (exceptions.BackendError, @@ -360,9 +360,9 @@ class RootCommand(Command): return backends - def start_core(self, mixer, backends): + def start_core(self, mixer, backends, audio): logger.info('Starting Mopidy core') - return Core.start(mixer=mixer, backends=backends).proxy() + return Core.start(mixer=mixer, backends=backends, audio=audio).proxy() def start_frontends(self, config, frontend_classes, core): logger.info( diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index dbcfc9a1..60de442a 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -42,7 +42,7 @@ class Core( """The tracklist controller. An instance of :class:`mopidy.core.TracklistController`.""" - def __init__(self, mixer=None, backends=None): + def __init__(self, mixer=None, backends=None, audio=None): super(Core, self).__init__() self.backends = Backends(backends) @@ -59,6 +59,8 @@ class Core( self.tracklist = TracklistController(core=self) + self.audio = audio + def get_uri_schemes(self): futures = [b.uri_schemes for b in self.backends] results = pykka.get_all(futures) @@ -105,20 +107,18 @@ class Core( CoreListener.send('mute_changed', mute=mute) def tags_changed(self, tags): - # Should return only one audio instance - audios = pykka.ActorRegistry.get_by_class(audio.Audio) - # Validity checks - if audios is None or len(audios) != 1: + if not self.audio: return if self.playback.current_tl_track is None: return - audio_proxy = audios[0].proxy() + tags = self.audio.get_current_tags().get() + if not tags: + return # Request available metadata and set a track - future = audio_proxy.get_current_tags() - mt_track = convert_tags_to_track(future.get()) + mt_track = convert_tags_to_track(tags) # Merge current_tl_track with metadata in current_md_track c_track = self.playback.current_tl_track.track From 64cab9ae95444fc96362b1775024647636a697d3 Mon Sep 17 00:00:00 2001 From: Alexandre Petitjean Date: Tue, 20 Jan 2015 21:50:02 +0100 Subject: [PATCH 006/314] Rename current_md_track to current_metadata_track --- mopidy/core/actor.py | 4 ++-- mopidy/core/playback.py | 4 ++-- mopidy/mpd/protocol/current_playlist.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 60de442a..ff60f190 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -120,14 +120,14 @@ class Core( # Request available metadata and set a track mt_track = convert_tags_to_track(tags) - # Merge current_tl_track with metadata in current_md_track + # Merge current_tl_track with metadata in current_metadata_track c_track = self.playback.current_tl_track.track track_kwargs = {k: v for k, v in c_track.__dict__.items() if v} for k, v in mt_track.__dict__.items(): if v: track_kwargs[k] = v - self.playback.current_md_track = TlTrack(**{ + self.playback.current_metadata_track = TlTrack(**{ 'tlid': self.playback.current_tl_track.tlid, 'track': Track(**track_kwargs)}) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index ad99e6ec..2bc2fbe6 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -127,9 +127,9 @@ class PlaybackController(object): """Mute state as a :class:`True` if muted, :class:`False` otherwise""" def get_current_metadata_track(self): - return self.current_md_track + return self.current_metadata_track - current_md_track = None + current_metadata_track = None """ The currently playing metadata :class:`mopidy.models.Track`, or :class:`None`. diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index bc89afcb..e083ea7c 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -283,7 +283,7 @@ def plchanges(context, version): elif iversion == tracklist_version: # If version are equals, it is just a metadata update # So we replace the updated track in playlist - current_md_track = context.core.playback.current_md_track.get() + current_md_track = context.core.playback.current_metadata_track.get() if current_md_track is None: return None From 735d1662dce784a783565682487e1fcda38e662b Mon Sep 17 00:00:00 2001 From: Alexandre Petitjean Date: Tue, 20 Jan 2015 22:05:14 +0100 Subject: [PATCH 007/314] Makes mpd 'currentsong' send song with metadata --- mopidy/mpd/protocol/status.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py index 9dae635e..eabb9317 100644 --- a/mopidy/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -34,7 +34,9 @@ def currentsong(context): Displays the song info of the current song (same song that is identified in status). """ - tl_track = context.core.playback.current_tl_track.get() + tl_track = context.core.playback.current_metadata_track.get() + if tl_track is None: + tl_track = context.core.playback.current_tl_track.get() if tl_track is not None: position = context.core.tracklist.index(tl_track).get() return translator.track_to_mpd_format(tl_track, position=position) From b46844fbe2078419c330030c20580a81ec36f324 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 21 Jan 2015 23:03:35 +0100 Subject: [PATCH 008/314] docs: Fix copy-paste error --- mopidy/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/models.py b/mopidy/models.py index 5508d4de..758b6c6d 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -153,7 +153,7 @@ class Ref(ImmutableObject): :param name: object name :type name: string :param type: object type - :type name: string + :type type: string """ #: The object URI. Read-only. From 5b614e95d69a27ed4a9d352a60aedf6904309a86 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 31 Jan 2015 22:06:23 +0100 Subject: [PATCH 009/314] Update to Mopidy.js 0.5.0 --- docs/api/js.rst | 7 +- docs/changelog.rst | 13 + docs/conf.py | 1 + mopidy/http/data/mopidy.js | 1808 ++++++++++++++++++-------------- mopidy/http/data/mopidy.min.js | 6 +- 5 files changed, 1050 insertions(+), 785 deletions(-) diff --git a/docs/api/js.rst b/docs/api/js.rst index 361c24fd..fffb40fa 100644 --- a/docs/api/js.rst +++ b/docs/api/js.rst @@ -289,9 +289,10 @@ unhandled errors. In general, unhandled errors will not go silently missing. The promise objects returned by Mopidy.js adheres to the `CommonJS Promises/A `_ standard. We use the -implementation known as `when.js `_. Please -refer to when.js' documentation or the standard for further details on how to -work with promise objects. +implementation known as `when.js `_, and +reexport it as ``Mopidy.when`` so you don't have to duplicate the dependency. +Please refer to when.js' documentation or the standard for further details on +how to work with promise objects. Cleaning up diff --git a/docs/changelog.rst b/docs/changelog.rst index 8fd3ad40..5be97bd9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -92,6 +92,19 @@ v0.20.0 (UNRELEASED) - Add basic tests for the stream library provider. +**Mopidy.js client library** + +This version has been released to npm as Mopidy.js v0.5.0. + +- Reexport When.js library as ``Mopidy.when``, to make it easily available to + users of Mopidy.js. (Fixes: :js:`1`) + +- Default to ``wss://`` as the WebSocket protocol if the page is hosted on + ``https://``. This has no effect if the ``webSocketUrl`` setting is + specified. (Pull request: :js:`2`) + +- Upgrade dependencies. + v0.19.6 (UNRELEASED) ==================== diff --git a/docs/conf.py b/docs/conf.py index 938ec87b..be748381 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -155,6 +155,7 @@ man_pages = [ extlinks = { 'issue': ('https://github.com/mopidy/mopidy/issues/%s', '#'), 'commit': ('https://github.com/mopidy/mopidy/commit/%s', 'commit '), + 'js': ('https://github.com/mopidy/mopidy.js/issues/%s', 'mopidy.js#'), 'mpris': ( 'https://github.com/mopidy/mopidy-mpris/issues/%s', 'mopidy-mpris#'), 'discuss': ('https://discuss.mopidy.com/t/%s', 'discuss.mopidy.com/t/'), diff --git a/mopidy/http/data/mopidy.js b/mopidy/http/data/mopidy.js index ce2f9763..7c95a56a 100644 --- a/mopidy/http/data/mopidy.js +++ b/mopidy/http/data/mopidy.js @@ -1,6 +1,6 @@ -/*! Mopidy.js v0.4.1 - built 2014-09-11 +/*! Mopidy.js v0.5.0 - built 2015-01-31 * http://www.mopidy.com/ - * Copyright (c) 2014 Stein Magnus Jodal and contributors + * Copyright (c) 2015 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ !function(e){if("object"==typeof exports)module.exports=e();else if("function"==typeof define&&define.amd)define(e);else{var f;"undefined"!=typeof window?f=window:"undefined"!=typeof global?f=global:"undefined"!=typeof self&&(f=self),f.Mopidy=e()}}(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);throw new Error("Cannot find module '"+o+"'")}var f=n[o]={exports:{}};t[o][0].call(f.exports,function(e){var n=t[o][1][e];return s(n?n:e)},f,f.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o>>0; - arrayForEach.call(promises, function(p) { - ++pending; - toPromise(p).then(resolve, handleReject); - }); + var pending = l; + var errors = []; - if(pending === 0) { - resolve(); + for (var h, x, i = 0; i < l; ++i) { + x = promises[i]; + if(x === void 0 && !(i in promises)) { + --pending; + continue; } - function handleReject(e) { - errors.push(e); - if(--pending === 0) { - reject(errors); - } + h = Promise._handler(x); + if(h.state() > 0) { + resolver.become(h); + Promise._visitRemaining(promises, i, h); + break; + } else { + h.visit(resolver, handleFulfill, handleReject); } - }); + } + + if(pending === 0) { + resolver.reject(new RangeError('any(): array must not be empty')); + } + + return p; + + function handleFulfill(x) { + /*jshint validthis:true*/ + errors = null; + this.resolve(x); // this === resolver + } + + function handleReject(e) { + /*jshint validthis:true*/ + if(this.resolved) { // this === resolver + return; + } + + errors.push(e); + if(--pending === 0) { + this.reject(errors); + } + } } /** @@ -516,116 +549,181 @@ define(function() { * @deprecated */ function some(promises, n) { - return new Promise(function(resolve, reject, notify) { - var nFulfill = 0; - var nReject; - var results = []; - var errors = []; + /*jshint maxcomplexity:7*/ + var p = Promise._defer(); + var resolver = p._handler; - arrayForEach.call(promises, function(p) { - ++nFulfill; - toPromise(p).then(handleResolve, handleReject, notify); - }); + var results = []; + var errors = []; - n = Math.max(n, 0); - nReject = (nFulfill - n + 1); - nFulfill = Math.min(n, nFulfill); + var l = promises.length>>>0; + var nFulfill = 0; + var nReject; + var x, i; // reused in both for() loops - if(nFulfill === 0) { - resolve(results); + // First pass: count actual array items + for(i=0; i nFulfill) { + resolver.reject(new RangeError('some(): array must contain at least ' + + n + ' item(s), but had ' + nFulfill)); + } else if(nFulfill === 0) { + resolver.resolve(results); + } + + // Second pass: observe each array item, make progress toward goals + for(i=0; i 0) { - --nFulfill; - results.push(x); + results.push(x); + if(--nFulfill === 0) { + errors = null; + this.resolve(results); + } + } - if(nFulfill === 0) { - resolve(results); - } - } + function reject(e) { + /*jshint validthis:true*/ + if(this.resolved) { // this === resolver + return; } - function handleReject(e) { - if(nReject > 0) { - --nReject; - errors.push(e); - - if(nReject === 0) { - reject(errors); - } - } + errors.push(e); + if(--nReject === 0) { + results = null; + this.reject(errors); } - }); + } } /** * Apply f to the value of each promise in a list of promises * and return a new list containing the results. * @param {array} promises - * @param {function} f - * @param {function} fallback + * @param {function(x:*, index:Number):*} f mapping function * @returns {Promise} */ - function map(promises, f, fallback) { - return all(arrayMap.call(promises, function(x) { - return toPromise(x).then(f, fallback); - })); + function map(promises, f) { + return Promise._traverse(f, promises); + } + + /** + * Filter the provided array of promises using the provided predicate. Input may + * contain promises and values + * @param {Array} promises array of promises and values + * @param {function(x:*, index:Number):boolean} predicate filtering predicate. + * Must return truthy (or promise for truthy) for items to retain. + * @returns {Promise} promise that will fulfill with an array containing all items + * for which predicate returned truthy. + */ + function filter(promises, predicate) { + var a = slice.call(promises); + return Promise._traverse(predicate, a).then(function(keep) { + return filterSync(a, keep); + }); + } + + function filterSync(promises, keep) { + // Safe because we know all promises have fulfilled if we've made it this far + var l = keep.length; + var filtered = new Array(l); + for(var i=0, j=0; i 2 - ? arrayReduce.call(promises, reducer, arguments[2]) - : arrayReduce.call(promises, reducer); - - function reducer(result, x, i) { - return toPromise(result).then(function(r) { - return toPromise(x).then(function(x) { - return f(r, x, i); - }); - }); + function settleOne(p) { + var h = Promise._handler(p); + if(h.state() === 0) { + return toPromise(p).then(state.fulfilled, state.rejected); } + + h._unreport(); + return state.inspect(h); } - function reduceRight(promises, f) { - return arguments.length > 2 - ? arrayReduceRight.call(promises, reducer, arguments[2]) - : arrayReduceRight.call(promises, reducer); + /** + * Traditional reduce function, similar to `Array.prototype.reduce()`, but + * input may contain promises and/or values, and reduceFunc + * may return either a value or a promise, *and* initialValue may + * be a promise for the starting value. + * @param {Array|Promise} promises array or promise for an array of anything, + * may contain a mix of promises and values. + * @param {function(accumulated:*, x:*, index:Number):*} f reduce function + * @returns {Promise} that will resolve to the final reduced value + */ + function reduce(promises, f /*, initialValue */) { + return arguments.length > 2 ? ar.call(promises, liftCombine(f), arguments[2]) + : ar.call(promises, liftCombine(f)); + } - function reducer(result, x, i) { - return toPromise(result).then(function(r) { - return toPromise(x).then(function(x) { - return f(r, x, i); - }); - }); - } + /** + * Traditional reduce function, similar to `Array.prototype.reduceRight()`, but + * input may contain promises and/or values, and reduceFunc + * may return either a value or a promise, *and* initialValue may + * be a promise for the starting value. + * @param {Array|Promise} promises array or promise for an array of anything, + * may contain a mix of promises and values. + * @param {function(accumulated:*, x:*, index:Number):*} f reduce function + * @returns {Promise} that will resolve to the final reduced value + */ + function reduceRight(promises, f /*, initialValue */) { + return arguments.length > 2 ? arr.call(promises, liftCombine(f), arguments[2]) + : arr.call(promises, liftCombine(f)); + } + + function liftCombine(f) { + return function(z, x, i) { + return applyFold(f, void 0, [z,x,i]); + }; } }; - }); -}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); -},{}],9:[function(_dereq_,module,exports){ +},{"../apply":7,"../state":20}],9:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ @@ -635,6 +733,7 @@ define(function() { return function flow(Promise) { + var resolve = Promise.resolve; var reject = Promise.reject; var origCatch = Promise.prototype['catch']; @@ -648,10 +747,7 @@ define(function() { * @returns {undefined} */ Promise.prototype.done = function(onResult, onError) { - var h = this._handler; - h.when({ resolve: this._maybeFatal, notify: noop, context: this, - receiver: h.receiver, fulfilled: onResult, rejected: onError, - progress: void 0 }); + this._handler.visit(this._handler.receiver, onResult, onError); }; /** @@ -663,15 +759,15 @@ define(function() { * @returns {*} */ Promise.prototype['catch'] = Promise.prototype.otherwise = function(onRejected) { - if (arguments.length === 1) { + if (arguments.length < 2) { return origCatch.call(this, onRejected); - } else { - if(typeof onRejected !== 'function') { - return this.ensure(rejectInvalidPredicate); - } - - return origCatch.call(this, createCatchFilter(arguments[1], onRejected)); } + + if(typeof onRejected !== 'function') { + return this.ensure(rejectInvalidPredicate); + } + + return origCatch.call(this, createCatchFilter(arguments[1], onRejected)); }; /** @@ -701,14 +797,29 @@ define(function() { */ Promise.prototype['finally'] = Promise.prototype.ensure = function(handler) { if(typeof handler !== 'function') { - // Optimization: result will not change, return same promise return this; } - handler = isolate(handler, this); - return this.then(handler, handler); + return this.then(function(x) { + return runSideEffect(handler, this, identity, x); + }, function(e) { + return runSideEffect(handler, this, reject, e); + }); }; + function runSideEffect (handler, thisArg, propagate, value) { + var result = handler.call(thisArg); + return maybeThenable(result) + ? propagateValue(result, propagate, value) + : propagate(value); + } + + function propagateValue (result, propagate, x) { + return resolve(result).then(function () { + return propagate(x); + }); + } + /** * Recover from a failure by returning a defaultValue. If defaultValue * is a promise, it's fulfillment value will be used. If defaultValue is @@ -763,15 +874,13 @@ define(function() { || (predicate != null && predicate.prototype instanceof Error); } - // prevent argument passing to f and ignore return value - function isolate(f, x) { - return function() { - f.call(this); - return x; - }; + function maybeThenable(x) { + return (typeof x === 'object' || typeof x === 'function') && x !== null; } - function noop() {} + function identity(x) { + return x; + } }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); @@ -787,9 +896,15 @@ define(function() { return function fold(Promise) { - Promise.prototype.fold = function(fn, arg) { + Promise.prototype.fold = function(f, z) { var promise = this._beget(); - this._handler.fold(promise._handler, fn, arg); + + this._handler.fold(function(z, x, to) { + Promise._handler(z).fold(function(x, z, to) { + to.resolve(f.call(this, z, x)); + }, x, this, to); + }, z, promise._handler.receiver, promise._handler); + return promise; }; @@ -805,21 +920,23 @@ define(function() { /** @author John Hann */ (function(define) { 'use strict'; -define(function() { +define(function(_dereq_) { - return function inspect(Promise) { + var inspect = _dereq_('../state').inspect; + + return function inspection(Promise) { Promise.prototype.inspect = function() { - return this._handler.inspect(); + return inspect(Promise._handler(this)); }; return Promise; }; }); -}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); -},{}],12:[function(_dereq_,module,exports){ +},{"../state":20}],12:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ @@ -837,6 +954,7 @@ define(function() { return Promise; /** + * @deprecated Use github.com/cujojs/most streams and most.iterate * Generate a (potentially infinite) stream of promised values: * x, f(x), f(f(x)), etc. until condition(x) returns true * @param {function} f function to generate a new x from the previous x @@ -854,6 +972,7 @@ define(function() { } /** + * @deprecated Use github.com/cujojs/most streams and most.unfold * Generate a (potentially infinite) stream of promised values * by applying handler(generator(seed)) iteratively until * condition(seed) returns true. @@ -895,6 +1014,7 @@ define(function() { return function progress(Promise) { /** + * @deprecated * Register a progress handler for this promise * @param {function} onProgress * @returns {Promise} @@ -917,9 +1037,15 @@ define(function() { (function(define) { 'use strict'; define(function(_dereq_) { - var timer = _dereq_('../timer'); + var env = _dereq_('../env'); var TimeoutError = _dereq_('../TimeoutError'); + function setTimeout(f, ms, x, y) { + return env.setTimer(function() { + f(x, y, ms); + }, ms); + } + return function timed(Promise) { /** * Return a new promise whose fulfillment value is revealed only @@ -929,57 +1055,61 @@ define(function(_dereq_) { */ Promise.prototype.delay = function(ms) { var p = this._beget(); - var h = p._handler; - - this._handler.map(function delay(x) { - timer.set(function() { h.resolve(x); }, ms); - }, h); - + this._handler.fold(handleDelay, ms, void 0, p._handler); return p; }; + function handleDelay(ms, x, h) { + setTimeout(resolveDelay, ms, x, h); + } + + function resolveDelay(x, h) { + h.resolve(x); + } + /** * Return a new promise that rejects after ms milliseconds unless * this promise fulfills earlier, in which case the returned promise * fulfills with the same value. * @param {number} ms milliseconds * @param {Error|*=} reason optional rejection reason to use, defaults - * to an Error if not provided + * to a TimeoutError if not provided * @returns {Promise} */ Promise.prototype.timeout = function(ms, reason) { - var hasReason = arguments.length > 1; var p = this._beget(); var h = p._handler; - var t = timer.set(onTimeout, ms); + var t = setTimeout(onTimeout, ms, reason, p._handler); - this._handler.chain(h, + this._handler.visit(h, function onFulfill(x) { - timer.clear(t); - this.resolve(x); // this = p._handler + env.clearTimer(t); + this.resolve(x); // this = h }, function onReject(x) { - timer.clear(t); - this.reject(x); // this = p._handler + env.clearTimer(t); + this.reject(x); // this = h }, h.notify); return p; - - function onTimeout() { - h.reject(hasReason - ? reason : new TimeoutError('timed out after ' + ms + 'ms')); - } }; + function onTimeout(reason, h, ms) { + var e = typeof reason === 'undefined' + ? new TimeoutError('timed out after ' + ms + 'ms') + : reason; + h.reject(e); + } + return Promise; }; }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); -},{"../TimeoutError":6,"../timer":19}],15:[function(_dereq_,module,exports){ +},{"../TimeoutError":6,"../env":17}],15:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ @@ -987,20 +1117,27 @@ define(function(_dereq_) { (function(define) { 'use strict'; define(function(_dereq_) { - var timer = _dereq_('../timer'); + var setTimer = _dereq_('../env').setTimer; + var format = _dereq_('../format'); return function unhandledRejection(Promise) { + var logError = noop; var logInfo = noop; + var localConsole; if(typeof console !== 'undefined') { - logError = typeof console.error !== 'undefined' - ? function (e) { console.error(e); } - : function (e) { console.log(e); }; + // Alias console to prevent things like uglify's drop_console option from + // removing console.log/error. Unhandled rejections fall into the same + // category as uncaught exceptions, and build tools shouldn't silence them. + localConsole = console; + logError = typeof localConsole.error !== 'undefined' + ? function (e) { localConsole.error(e); } + : function (e) { localConsole.log(e); }; - logInfo = typeof console.info !== 'undefined' - ? function (e) { console.info(e); } - : function (e) { console.log(e); }; + logInfo = typeof localConsole.info !== 'undefined' + ? function (e) { localConsole.info(e); } + : function (e) { localConsole.log(e); }; } Promise.onPotentiallyUnhandledRejection = function(rejection) { @@ -1017,12 +1154,12 @@ define(function(_dereq_) { var tasks = []; var reported = []; - var running = false; + var running = null; function report(r) { if(!r.handled) { reported.push(r); - logError('Potentially unhandled rejection [' + r.id + '] ' + formatError(r.value)); + logError('Potentially unhandled rejection [' + r.id + '] ' + format.formatError(r.value)); } } @@ -1030,20 +1167,19 @@ define(function(_dereq_) { var i = reported.indexOf(r); if(i >= 0) { reported.splice(i, 1); - logInfo('Handled previous rejection [' + r.id + '] ' + formatObject(r.value)); + logInfo('Handled previous rejection [' + r.id + '] ' + format.formatObject(r.value)); } } function enqueue(f, x) { tasks.push(f, x); - if(!running) { - running = true; - running = timer.set(flush, 0); + if(running === null) { + running = setTimer(flush, 0); } } function flush() { - running = false; + running = null; while(tasks.length > 0) { tasks.shift()(tasks.shift()); } @@ -1052,28 +1188,6 @@ define(function(_dereq_) { return Promise; }; - function formatError(e) { - var s = typeof e === 'object' && e.stack ? e.stack : formatObject(e); - return e instanceof Error ? s : s + ' (WARNING: non-Error used)'; - } - - function formatObject(o) { - var s = String(o); - if(s === '[object Object]' && typeof JSON !== 'undefined') { - s = tryStringify(o, s); - } - return s; - } - - function tryStringify(e, defaultValue) { - try { - return JSON.stringify(e); - } catch(e) { - // Ignore. Cannot JSON.stringify e, stick with String(e) - return defaultValue; - } - } - function throwit(e) { throw e; } @@ -1083,7 +1197,7 @@ define(function(_dereq_) { }); }(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); -},{"../timer":19}],16:[function(_dereq_,module,exports){ +},{"../env":17,"../format":18}],16:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ @@ -1094,21 +1208,27 @@ define(function() { return function addWith(Promise) { /** * Returns a promise whose handlers will be called with `this` set to - * the supplied `thisArg`. Subsequent promises derived from the - * returned promise will also have their handlers called with `thisArg`. - * Calling `with` with undefined or no arguments will return a promise - * whose handlers will again be called in the usual Promises/A+ way (no `this`) - * thus safely undoing any previous `with` in the promise chain. + * the supplied receiver. Subsequent promises derived from the + * returned promise will also have their handlers called with receiver + * as `this`. Calling `with` with undefined or no arguments will return + * a promise whose handlers will again be called in the usual Promises/A+ + * way (no `this`) thus safely undoing any previous `with` in the + * promise chain. * * WARNING: Promises returned from `with`/`withThis` are NOT Promises/A+ * compliant, specifically violating 2.2.5 (http://promisesaplus.com/#point-41) * - * @param {object} thisArg `this` value for all handlers attached to + * @param {object} receiver `this` value for all handlers attached to * the returned promise. * @returns {Promise} */ - Promise.prototype['with'] = Promise.prototype.withThis - = Promise.prototype._bindContext; + Promise.prototype['with'] = Promise.prototype.withThis = function(receiver) { + var p = this._beget(); + var child = p._handler; + child.receiver = receiver; + this._handler.chain(child, receiver); + return p; + }; return Promise; }; @@ -1118,6 +1238,142 @@ define(function() { },{}],17:[function(_dereq_,module,exports){ +(function (process){ +/** @license MIT License (c) copyright 2010-2014 original author or authors */ +/** @author Brian Cavalier */ +/** @author John Hann */ + +/*global process,document,setTimeout,clearTimeout,MutationObserver,WebKitMutationObserver*/ +(function(define) { 'use strict'; +define(function(_dereq_) { + /*jshint maxcomplexity:6*/ + + // Sniff "best" async scheduling option + // Prefer process.nextTick or MutationObserver, then check for + // setTimeout, and finally vertx, since its the only env that doesn't + // have setTimeout + + var MutationObs; + var capturedSetTimeout = typeof setTimeout !== 'undefined' && setTimeout; + + // Default env + var setTimer = function(f, ms) { return setTimeout(f, ms); }; + var clearTimer = function(t) { return clearTimeout(t); }; + var asap = function (f) { return capturedSetTimeout(f, 0); }; + + // Detect specific env + if (isNode()) { // Node + asap = function (f) { return process.nextTick(f); }; + + } else if (MutationObs = hasMutationObserver()) { // Modern browser + asap = initMutationObserver(MutationObs); + + } else if (!capturedSetTimeout) { // vert.x + var vertxRequire = _dereq_; + var vertx = vertxRequire('vertx'); + setTimer = function (f, ms) { return vertx.setTimer(ms, f); }; + clearTimer = vertx.cancelTimer; + asap = vertx.runOnLoop || vertx.runOnContext; + } + + return { + setTimer: setTimer, + clearTimer: clearTimer, + asap: asap + }; + + function isNode () { + return typeof process !== 'undefined' && process !== null && + typeof process.nextTick === 'function'; + } + + function hasMutationObserver () { + return (typeof MutationObserver === 'function' && MutationObserver) || + (typeof WebKitMutationObserver === 'function' && WebKitMutationObserver); + } + + function initMutationObserver(MutationObserver) { + var scheduled; + var node = document.createTextNode(''); + var o = new MutationObserver(run); + o.observe(node, { characterData: true }); + + function run() { + var f = scheduled; + scheduled = void 0; + f(); + } + + var i = 0; + return function (f) { + scheduled = f; + node.data = (i ^= 1); + }; + } +}); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); + +}).call(this,_dereq_("FWaASH")) +},{"FWaASH":3}],18:[function(_dereq_,module,exports){ +/** @license MIT License (c) copyright 2010-2014 original author or authors */ +/** @author Brian Cavalier */ +/** @author John Hann */ + +(function(define) { 'use strict'; +define(function() { + + return { + formatError: formatError, + formatObject: formatObject, + tryStringify: tryStringify + }; + + /** + * Format an error into a string. If e is an Error and has a stack property, + * it's returned. Otherwise, e is formatted using formatObject, with a + * warning added about e not being a proper Error. + * @param {*} e + * @returns {String} formatted string, suitable for output to developers + */ + function formatError(e) { + var s = typeof e === 'object' && e !== null && e.stack ? e.stack : formatObject(e); + return e instanceof Error ? s : s + ' (WARNING: non-Error used)'; + } + + /** + * Format an object, detecting "plain" objects and running them through + * JSON.stringify if possible. + * @param {Object} o + * @returns {string} + */ + function formatObject(o) { + var s = String(o); + if(s === '[object Object]' && typeof JSON !== 'undefined') { + s = tryStringify(o, s); + } + return s; + } + + /** + * Try to return the result of JSON.stringify(x). If that fails, return + * defaultValue + * @param {*} x + * @param {*} defaultValue + * @returns {String|*} JSON.stringify(x) or defaultValue + */ + function tryStringify(x, defaultValue) { + try { + return JSON.stringify(x); + } catch(e) { + return defaultValue; + } + } + +}); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); + +},{}],19:[function(_dereq_,module,exports){ +(function (process){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @author Brian Cavalier */ /** @author John Hann */ @@ -1128,6 +1384,7 @@ define(function() { return function makePromise(environment) { var tasks = environment.scheduler; + var emitRejection = initEmitRejection(); var objectCreate = Object.create || function(proto) { @@ -1149,10 +1406,10 @@ define(function() { /** * Run the supplied resolver * @param resolver - * @returns {makePromise.DeferredHandler} + * @returns {Pending} */ function init(resolver) { - var handler = new DeferredHandler(); + var handler = new Pending(); try { resolver(promiseResolve, promiseReject, promiseNotify); @@ -1180,6 +1437,7 @@ define(function() { } /** + * @deprecated * Issue a progress event, notifying all progress listeners * @param {*} x progress event payload to pass to all listeners */ @@ -1195,6 +1453,7 @@ define(function() { Promise.never = never; Promise._defer = defer; + Promise._handler = getHandler; /** * Returns a trusted promise. If x is already a trusted promise, it is @@ -1204,7 +1463,7 @@ define(function() { */ function resolve(x) { return isPromise(x) ? x - : new Promise(Handler, new AsyncHandler(getHandler(x))); + : new Promise(Handler, new Async(getHandler(x))); } /** @@ -1213,7 +1472,7 @@ define(function() { * @returns {Promise} rejected promise */ function reject(x) { - return new Promise(Handler, new AsyncHandler(new RejectedHandler(x))); + return new Promise(Handler, new Async(new Rejected(x))); } /** @@ -1230,7 +1489,7 @@ define(function() { * @returns {Promise} */ function defer() { - return new Promise(Handler, new DeferredHandler()); + return new Promise(Handler, new Pending()); } // Transformation and flow control @@ -1242,29 +1501,23 @@ define(function() { * this promise's fulfillment. * @param {function=} onFulfilled fulfillment handler * @param {function=} onRejected rejection handler - * @deprecated @param {function=} onProgress progress handler + * @param {function=} onProgress @deprecated progress handler * @return {Promise} new promise */ - Promise.prototype.then = function(onFulfilled, onRejected) { + Promise.prototype.then = function(onFulfilled, onRejected, onProgress) { var parent = this._handler; + var state = parent.join().state(); - if (typeof onFulfilled !== 'function' && parent.join().state() > 0) { + if ((typeof onFulfilled !== 'function' && state > 0) || + (typeof onRejected !== 'function' && state < 0)) { // Short circuit: value will not change, simply share handler - return new Promise(Handler, parent); + return new this.constructor(Handler, parent); } var p = this._beget(); var child = p._handler; - parent.when({ - resolve: child.resolve, - notify: child.notify, - context: child, - receiver: parent.receiver, - fulfilled: onFulfilled, - rejected: onRejected, - progress: arguments.length > 2 ? arguments[2] : void 0 - }); + parent.chain(child, parent.receiver, onFulfilled, onRejected, onProgress); return p; }; @@ -1279,49 +1532,25 @@ define(function() { return this.then(void 0, onRejected); }; - /** - * Private function to bind a thisArg for this promise's handlers - * @private - * @param {object} thisArg `this` value for all handlers attached to - * the returned promise. - * @returns {Promise} - */ - Promise.prototype._bindContext = function(thisArg) { - return new Promise(Handler, new BoundHandler(this._handler, thisArg)); - }; - /** * Creates a new, pending promise of the same type as this promise * @private * @returns {Promise} */ Promise.prototype._beget = function() { - var parent = this._handler; - var child = new DeferredHandler(parent.receiver, parent.join().context); - return new this.constructor(Handler, child); + return begetFrom(this._handler, this.constructor); }; - /** - * Check if x is a rejected promise, and if so, delegate to handler._fatal - * @private - * @param {*} x - */ - Promise.prototype._maybeFatal = function(x) { - if(!maybeThenable(x)) { - return; - } - - var handler = getHandler(x); - var context = this._handler.context; - handler.catchError(function() { - this._fatal(context); - }, handler); - }; + function begetFrom(parent, Promise) { + var child = new Pending(parent.receiver, parent.join().context); + return new Promise(Handler, child); + } // Array combinators Promise.all = all; Promise.race = race; + Promise._traverse = traverse; /** * Return a promise that will fulfill when all promises in the @@ -1331,13 +1560,28 @@ define(function() { * @returns {Promise} promise for array of fulfillment values */ function all(promises) { - /*jshint maxcomplexity:8*/ - var resolver = new DeferredHandler(); + return traverseWith(snd, null, promises); + } + + /** + * Array> -> Promise> + * @private + * @param {function} f function to apply to each promise's value + * @param {Array} promises array of promises + * @returns {Promise} promise for transformed values + */ + function traverse(f, promises) { + return traverseWith(tryCatch2, f, promises); + } + + function traverseWith(tryMap, f, promises) { + var handler = typeof f === 'function' ? mapAt : settleAt; + + var resolver = new Pending(); var pending = promises.length >>> 0; var results = new Array(pending); - var i, h, x, s; - for (i = 0; i < promises.length; ++i) { + for (var i = 0, x; i < promises.length && !resolver.resolved; ++i) { x = promises[i]; if (x === void 0 && !(i in promises)) { @@ -1345,40 +1589,64 @@ define(function() { continue; } - if (maybeThenable(x)) { - h = isPromise(x) - ? x._handler.join() - : getHandlerUntrusted(x); - - s = h.state(); - if (s === 0) { - resolveOne(resolver, results, h, i); - } else if (s > 0) { - results[i] = h.value; - --pending; - } else { - resolver.become(h); - break; - } - - } else { - results[i] = x; - --pending; - } + traverseAt(promises, handler, i, x, resolver); } if(pending === 0) { - resolver.become(new FulfilledHandler(results)); + resolver.become(new Fulfilled(results)); } return new Promise(Handler, resolver); - function resolveOne(resolver, results, handler, i) { - handler.map(function(x) { - results[i] = x; - if(--pending === 0) { - this.become(new FulfilledHandler(results)); - } - }, resolver); + + function mapAt(i, x, resolver) { + if(!resolver.resolved) { + traverseAt(promises, settleAt, i, tryMap(f, x, i), resolver); + } + } + + function settleAt(i, x, resolver) { + results[i] = x; + if(--pending === 0) { + resolver.become(new Fulfilled(results)); + } + } + } + + function traverseAt(promises, handler, i, x, resolver) { + if (maybeThenable(x)) { + var h = getHandlerMaybeThenable(x); + var s = h.state(); + + if (s === 0) { + h.fold(handler, i, void 0, resolver); + } else if (s > 0) { + handler(i, h.value, resolver); + } else { + resolver.become(h); + visitRemaining(promises, i+1, h); + } + } else { + handler(i, x, resolver); + } + } + + Promise._visitRemaining = visitRemaining; + function visitRemaining(promises, start, handler) { + for(var i=start; i 0) { - q.shift().run(); - } - - this._running = false; - - q = this._afterQueue; - while(q.length > 0) { - q.shift()(q.shift(), q.shift()); - } - }; - - return Scheduler; - -}); -}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); - -},{"./Queue":5}],19:[function(_dereq_,module,exports){ -/** @license MIT License (c) copyright 2010-2014 original author or authors */ -/** @author Brian Cavalier */ -/** @author John Hann */ - -(function(define) { 'use strict'; -define(function(_dereq_) { - /*global setTimeout,clearTimeout*/ - var cjsRequire, vertx, setTimer, clearTimer; - - cjsRequire = _dereq_; - - try { - vertx = cjsRequire('vertx'); - setTimer = function (f, ms) { return vertx.setTimer(ms, f); }; - clearTimer = vertx.cancelTimer; - } catch (e) { - setTimer = function(f, ms) { return setTimeout(f, ms); }; - clearTimer = function(t) { return clearTimeout(t); }; - } +define(function() { return { - set: setTimer, - clear: clearTimer + pending: toPendingState, + fulfilled: toFulfilledState, + rejected: toRejectedState, + inspect: inspect }; -}); -}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(_dereq_); })); + function toPendingState() { + return { state: 'pending' }; + } -},{}],20:[function(_dereq_,module,exports){ + function toRejectedState(e) { + return { state: 'rejected', reason: e }; + } + + function toFulfilledState(x) { + return { state: 'fulfilled', value: x }; + } + + function inspect(handler) { + var state = handler.state(); + return state === 0 ? toPendingState() + : state > 0 ? toFulfilledState(handler.value) + : toRejectedState(handler.value); + } + +}); +}(typeof define === 'function' && define.amd ? define : function(factory) { module.exports = factory(); })); + +},{}],21:[function(_dereq_,module,exports){ /** @license MIT License (c) copyright 2010-2014 original author or authors */ /** @@ -2074,7 +2348,7 @@ define(function(_dereq_) { * when is part of the cujoJS family of libraries (http://cujojs.com/) * @author Brian Cavalier * @author John Hann - * @version 3.2.3 + * @version 3.7.2 */ (function(define) { 'use strict'; define(function (_dereq_) { @@ -2096,7 +2370,7 @@ define(function (_dereq_) { return feature(Promise); }, _dereq_('./lib/Promise')); - var slice = Array.prototype.slice; + var apply = _dereq_('./lib/apply')(Promise); // Public API @@ -2108,8 +2382,8 @@ define(function (_dereq_) { when['try'] = attempt; // call a function and return a promise when.attempt = attempt; // alias for when.try - when.iterate = Promise.iterate; // Generate a stream of promises - when.unfold = Promise.unfold; // Generate a stream of promises + when.iterate = Promise.iterate; // DEPRECATED (use cujojs/most streams) Generate a stream of promises + when.unfold = Promise.unfold; // DEPRECATED (use cujojs/most streams) Generate a stream of promises when.join = join; // Join 2 or more promises @@ -2118,10 +2392,12 @@ define(function (_dereq_) { when.any = lift(Promise.any); // One-winner race when.some = lift(Promise.some); // Multi-winner race + when.race = lift(Promise.race); // First-to-settle race when.map = map; // Array.map() for promises - when.reduce = reduce; // Array.reduce() for promises - when.reduceRight = reduceRight; // Array.reduceRight() for promises + when.filter = filter; // Array.filter() for promises + when.reduce = lift(Promise.reduce); // Array.reduce() for promises + when.reduceRight = lift(Promise.reduceRight); // Array.reduceRight() for promises when.isPromiseLike = isPromiseLike; // Is something promise-like, aka thenable @@ -2141,21 +2417,19 @@ define(function (_dereq_) { * will be invoked immediately. * @param {function?} onRejected callback to be called when x is * rejected. - * @deprecated @param {function?} onProgress callback to be called when progress updates - * are issued for x. + * @param {function?} onProgress callback to be called when progress updates + * are issued for x. @deprecated * @returns {Promise} a new promise that will fulfill with the return * value of callback or errback or the completion value of promiseOrValue if * callback and/or errback is not supplied. */ - function when(x, onFulfilled, onRejected) { + function when(x, onFulfilled, onRejected, onProgress) { var p = Promise.resolve(x); - if(arguments.length < 2) { + if (arguments.length < 2) { return p; } - return arguments.length > 3 - ? p.then(onFulfilled, onRejected, arguments[3]) - : p.then(onFulfilled, onRejected); + return p.then(onFulfilled, onRejected, onProgress); } /** @@ -2175,7 +2449,10 @@ define(function (_dereq_) { */ function lift(f) { return function() { - return _apply(f, this, slice.call(arguments)); + for(var i=0, l=arguments.length, a=new Array(l); i0)for(d=0;e>d;++d)c[d](a,b);else setTimeout(function(){throw b.message=a+" listener threw error: "+b.message,b},0)}function b(a){if("function"!=typeof a)throw new TypeError("Listener is not function");return a}function c(a){return a.supervisors||(a.supervisors=[]),a.supervisors}function d(a,b){return a.listeners||(a.listeners={}),b&&!a.listeners[b]&&(a.listeners[b]=[]),b?a.listeners[b]:a.listeners}function e(a){return a.errbacks||(a.errbacks=[]),a.errbacks}function f(f){function h(b,c,d){try{c.listener.apply(c.thisp||f,d)}catch(g){a(b,g,e(f))}}return f=f||{},f.on=function(a,e,f){return"function"==typeof a?c(this).push({listener:a,thisp:e}):void d(this,a).push({listener:b(e),thisp:f})},f.off=function(a,b){var f,g,h,i;if(!a){f=c(this),f.splice(0,f.length),g=d(this);for(h in g)g.hasOwnProperty(h)&&(f=d(this,h),f.splice(0,f.length));return f=e(this),void f.splice(0,f.length)}if("function"==typeof a?(f=c(this),b=a):f=d(this,a),!b)return void f.splice(0,f.length);for(h=0,i=f.length;i>h;++h)if(f[h].listener===b)return void f.splice(h,1)},f.once=function(a,b,c){var d=function(){f.off(a,d),b.apply(this,arguments)};f.on(a,d,c)},f.bind=function(a,b){var c,d,e;if(b)for(d=0,e=b.length;e>d;++d){if("function"!=typeof a[b[d]])throw new Error("No such method "+b[d]);this.on(b[d],a[b[d]],a)}else for(c in a)"function"==typeof a[c]&&this.on(c,a[c],a);return a},f.emit=function(a){var b,e,f=c(this),i=g.call(arguments);for(b=0,e=f.length;e>b;++b)h(a,f[b],i);for(f=d(this,a).slice(),i=g.call(arguments,1),b=0,e=f.length;e>b;++b)h(a,f[b],i)},f.errback=function(a){this.errbacks||(this.errbacks=[]),this.errbacks.push(b(a))},f}var g=Array.prototype.slice;return{createEventEmitter:f,aggregate:function(a){var b=f();return a.forEach(function(a){a.on(function(a,c){b.emit(a,c)})}),b}}})},{}],3:[function(a,b){function c(){}var d=b.exports={};d.nextTick=function(){var a="undefined"!=typeof window&&window.setImmediate,b="undefined"!=typeof window&&window.postMessage&&window.addEventListener;if(a)return function(a){return window.setImmediate(a)};if(b){var c=[];return window.addEventListener("message",function(a){var b=a.source;if((b===window||null===b)&&"process-tick"===a.data&&(a.stopPropagation(),c.length>0)){var d=c.shift();d()}},!0),function(a){c.push(a),window.postMessage("process-tick","*")}}return function(a){setTimeout(a,0)}}(),d.title="browser",d.browser=!0,d.env={},d.argv=[],d.on=c,d.addListener=c,d.once=c,d.off=c,d.removeListener=c,d.removeAllListeners=c,d.emit=c,d.binding=function(){throw new Error("process.binding is not supported")},d.cwd=function(){return"/"},d.chdir=function(){throw new Error("process.chdir is not supported")}},{}],4:[function(b,c){!function(a){"use strict";a(function(a){var b=a("./makePromise"),c=a("./scheduler"),d=a("./async");return b({scheduler:new c(d)})})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"./async":7,"./makePromise":17,"./scheduler":18}],5:[function(b,c){!function(a){"use strict";a(function(){function a(a){this.head=this.tail=this.length=0,this.buffer=new Array(1<f;++f)e[f]=d[f];else{for(a=d.length,b=this.tail;a>c;++f,++c)e[f]=d[c];for(c=0;b>c;++f,++c)e[f]=d[c]}this.buffer=e,this.head=0,this.tail=this.length},a})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],6:[function(b,c){!function(a){"use strict";a(function(){function a(b){Error.call(this),this.message=b,this.name=a.name,"function"==typeof Error.captureStackTrace&&Error.captureStackTrace(this,a)}return a.prototype=Object.create(Error.prototype),a.prototype.constructor=a,a})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],7:[function(b,c){(function(d){!function(a){"use strict";a(function(a){var b,c;return b="undefined"!=typeof d&&null!==d&&"function"==typeof d.nextTick?function(a){d.nextTick(a)}:(c="function"==typeof MutationObserver&&MutationObserver||"function"==typeof WebKitMutationObserver&&WebKitMutationObserver)?function(a,b){function c(){var a=d;d=void 0,a()}var d,e=a.createElement("div"),f=new b(c);return f.observe(e,{attributes:!0}),function(a){d=a,e.setAttribute("class","x")}}(document,c):function(a){try{return a("vertx").runOnLoop||a("vertx").runOnContext}catch(b){}var c=setTimeout;return function(a){c(a,0)}}(a)})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})}).call(this,b("FWaASH"))},{FWaASH:3}],8:[function(b,c){!function(a){"use strict";a(function(){return function(a){function b(b){return new a(function(a,c){function d(a){f.push(a),0===--e&&c(f)}var e=0,f=[];k.call(b,function(b){++e,l(b).then(a,d)}),0===e&&a()})}function c(b,c){return new a(function(a,d,e){function f(b){i>0&&(--i,j.push(b),0===i&&a(j))}function g(a){h>0&&(--h,m.push(a),0===h&&d(m))}var h,i=0,j=[],m=[];return k.call(b,function(a){++i,l(a).then(f,g,e)}),c=Math.max(c,0),h=i-c+1,i=Math.min(c,i),0===i?void a(j):void 0})}function d(a,b,c){return m(h.call(a,function(a){return l(a).then(b,c)}))}function e(a){return m(h.call(a,function(a){function b(){return a.inspect()}return a=l(a),a.then(b,b)}))}function f(a,b){function c(a,c,d){return l(a).then(function(a){return l(c).then(function(c){return b(a,c,d)})})}return arguments.length>2?i.call(a,c,arguments[2]):i.call(a,c)}function g(a,b){function c(a,c,d){return l(a).then(function(a){return l(c).then(function(c){return b(a,c,d)})})}return arguments.length>2?j.call(a,c,arguments[2]):j.call(a,c)}var h=Array.prototype.map,i=Array.prototype.reduce,j=Array.prototype.reduceRight,k=Array.prototype.forEach,l=a.resolve,m=a.all;return a.any=b,a.some=c,a.settle=e,a.map=d,a.reduce=f,a.reduceRight=g,a.prototype.spread=function(a){return this.then(m).then(function(b){return a.apply(void 0,b)})},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],9:[function(b,c){!function(a){"use strict";a(function(){function a(){throw new TypeError("catch predicate must be a function")}function b(a,b){return c(b)?a instanceof b:b(a)}function c(a){return a===Error||null!=a&&a.prototype instanceof Error}function d(a,b){return function(){return a.call(this),b}}function e(){}return function(c){function f(a,c){return function(d){return b(d,c)?a.call(this,d):g(d)}}var g=c.reject,h=c.prototype["catch"];return c.prototype.done=function(a,b){var c=this._handler;c.when({resolve:this._maybeFatal,notify:e,context:this,receiver:c.receiver,fulfilled:a,rejected:b,progress:void 0})},c.prototype["catch"]=c.prototype.otherwise=function(b){return 1===arguments.length?h.call(this,b):"function"!=typeof b?this.ensure(a):h.call(this,f(arguments[1],b))},c.prototype["finally"]=c.prototype.ensure=function(a){return"function"!=typeof a?this:(a=d(a,this),this.then(a,a))},c.prototype["else"]=c.prototype.orElse=function(a){return this.then(void 0,function(){return a})},c.prototype["yield"]=function(a){return this.then(function(){return a})},c.prototype.tap=function(a){return this.then(a)["yield"](this)},c}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],10:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype.fold=function(a,b){var c=this._beget();return this._handler.fold(c._handler,a,b),c},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],11:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype.inspect=function(){return this._handler.inspect()},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],12:[function(b,c){!function(a){"use strict";a(function(){return function(a){function b(a,b,d,e){return c(function(b){return[b,a(b)]},b,d,e)}function c(a,b,e,f){function g(f,g){return d(e(f)).then(function(){return c(a,b,e,g)})}return d(f).then(function(c){return d(b(c)).then(function(b){return b?c:d(a(c)).spread(g)})})}var d=a.resolve;return a.iterate=b,a.unfold=c,a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],13:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype.progress=function(a){return this.then(void 0,void 0,a)},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],14:[function(b,c){!function(a){"use strict";a(function(a){var b=a("../timer"),c=a("../TimeoutError");return function(a){return a.prototype.delay=function(a){var c=this._beget(),d=c._handler;return this._handler.map(function(c){b.set(function(){d.resolve(c)},a)},d),c},a.prototype.timeout=function(a,d){function e(){h.reject(f?d:new c("timed out after "+a+"ms"))}var f=arguments.length>1,g=this._beget(),h=g._handler,i=b.set(e,a);return this._handler.chain(h,function(a){b.clear(i),this.resolve(a)},function(a){b.clear(i),this.reject(a)},h.notify),g},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../TimeoutError":6,"../timer":19}],15:[function(b,c){!function(a){"use strict";a(function(a){function b(a){var b="object"==typeof a&&a.stack?a.stack:c(a);return a instanceof Error?b:b+" (WARNING: non-Error used)"}function c(a){var b=String(a);return"[object Object]"===b&&"undefined"!=typeof JSON&&(b=d(a,b)),b}function d(a,b){try{return JSON.stringify(a)}catch(a){return b}}function e(a){throw a}function f(){}var g=a("../timer");return function(a){function d(a){a.handled||(n.push(a),k("Potentially unhandled rejection ["+a.id+"] "+b(a.value)))}function h(a){var b=n.indexOf(a);b>=0&&(n.splice(b,1),l("Handled previous rejection ["+a.id+"] "+c(a.value)))}function i(a,b){m.push(a,b),o||(o=!0,o=g.set(j,0))}function j(){for(o=!1;m.length>0;)m.shift()(m.shift())}var k=f,l=f;"undefined"!=typeof console&&(k="undefined"!=typeof console.error?function(a){console.error(a)}:function(a){console.log(a)},l="undefined"!=typeof console.info?function(a){console.info(a)}:function(a){console.log(a)}),a.onPotentiallyUnhandledRejection=function(a){i(d,a)},a.onPotentiallyUnhandledRejectionHandled=function(a){i(h,a)},a.onFatalRejection=function(a){i(e,a.value)};var m=[],n=[],o=!1;return a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../timer":19}],16:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype["with"]=a.prototype.withThis=a.prototype._bindContext,a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],17:[function(b,c){!function(a){"use strict";a(function(){return function(a){function b(a,b){this._handler=a===m?b:c(a)}function c(a){function b(a){e.resolve(a)}function c(a){e.reject(a)}function d(a){e.notify(a)}var e=new n;try{a(b,c,d)}catch(f){c(f)}return e}function d(a){return k(a)?a:new b(m,new p(j(a)))}function e(a){return new b(m,new p(new t(a)))}function f(){return M}function g(){return new b(m,new n)}function h(a){function c(a,b,c,d){c.map(function(a){b[d]=a,0===--i&&this.become(new s(b))},a)}var d,e,f,g,h=new n,i=a.length>>>0,j=new Array(i);for(d=0;d0)){h.become(e);break}j[d]=e.value,--i}else j[d]=f,--i;else--i;return 0===i&&h.become(new s(j)),new b(m,h)}function i(a){if(Object(a)===a&&0===a.length)return f();var c,d,e=new n;for(c=0;c0)return new b(m,d);var e=this._beget(),f=e._handler;return d.when({resolve:f.resolve,notify:f.notify,context:f,receiver:d.receiver,fulfilled:a,rejected:c,progress:arguments.length>2?arguments[2]:void 0}),e},b.prototype["catch"]=function(a){return this.then(void 0,a)},b.prototype._bindContext=function(a){return new b(m,new q(this._handler,a))},b.prototype._beget=function(){var a=this._handler,b=new n(a.receiver,a.join().context);return new this.constructor(m,b)},b.prototype._maybeFatal=function(a){if(C(a)){var b=j(a),c=this._handler.context;b.catchError(function(){this._fatal(c)},b)}},b.all=h,b.race=i,m.prototype.when=m.prototype.resolve=m.prototype.reject=m.prototype.notify=m.prototype._fatal=m.prototype._unreport=m.prototype._report=H,m.prototype.inspect=x,m.prototype._state=0,m.prototype.state=function(){return this._state},m.prototype.join=function(){for(var a=this;void 0!==a.handler;)a=a.handler;return a},m.prototype.chain=function(a,b,c,d){this.when({resolve:H,notify:H,context:void 0,receiver:a,fulfilled:b,rejected:c,progress:d})},m.prototype.map=function(a,b){this.chain(b,a,b.reject,b.notify)},m.prototype.catchError=function(a,b){this.chain(b,b.resolve,a,b.notify)},m.prototype.fold=function(a,b,c){this.join().map(function(a){j(c).map(function(c){this.resolve(E(b,c,a,this.receiver))},this)},a)},G(m,n),n.prototype._state=0,n.prototype.inspect=function(){return this.resolved?this.join().inspect():x()},n.prototype.resolve=function(a){this.resolved||this.become(j(a))},n.prototype.reject=function(a){this.resolved||this.become(new t(a))},n.prototype.join=function(){if(this.resolved){for(var a=this;void 0!==a.handler;)if(a=a.handler,a===this)return this.handler=new w;return a}return this},n.prototype.run=function(){var a=this.consumers,b=this.join();this.consumers=void 0;for(var c=0;c0;)a.shift().run();for(this._running=!1,a=this._afterQueue;a.length>0;)a.shift()(a.shift(),a.shift())},b})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"./Queue":5}],19:[function(b,c){!function(a){"use strict";a(function(a){var b,c,d,e;b=a;try{c=b("vertx"),d=function(a,b){return c.setTimer(b,a)},e=c.cancelTimer}catch(f){d=function(a,b){return setTimeout(a,b)},e=function(a){return clearTimeout(a)}}return{set:d,clear:e}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{}],20:[function(b,c){!function(a){"use strict";a(function(a){function b(a,b,c){var d=z.resolve(a);return arguments.length<2?d:arguments.length>3?d.then(b,c,arguments[3]):d.then(b,c)}function c(a){return new z(a)}function d(a){return function(){return f(a,this,A.call(arguments))}}function e(a){return f(a,this,A.call(arguments,1))}function f(a,b,c){return z.all(c).then(function(c){return a.apply(b,c)})}function g(){return new h}function h(){function a(a){d._handler.resolve(a)}function b(a){d._handler.reject(a)}function c(a){d._handler.notify(a)}var d=z._defer();this.promise=d,this.resolve=a,this.reject=b,this.notify=c,this.resolver={resolve:a,reject:b,notify:c}}function i(a){return a&&"function"==typeof a.then}function j(){return z.all(arguments)}function k(a){return b(a,z.all)}function l(a){return b(a,z.settle)}function m(a,c){return b(a,function(a){return z.map(a,c)})}function n(a){var c=A.call(arguments,1);return b(a,function(a){return c.unshift(a),z.reduce.apply(z,c)})}function o(a){var c=A.call(arguments,1);return b(a,function(a){return c.unshift(a),z.reduceRight.apply(z,c)})}var p=a("./lib/decorators/timed"),q=a("./lib/decorators/array"),r=a("./lib/decorators/flow"),s=a("./lib/decorators/fold"),t=a("./lib/decorators/inspect"),u=a("./lib/decorators/iterate"),v=a("./lib/decorators/progress"),w=a("./lib/decorators/with"),x=a("./lib/decorators/unhandledRejection"),y=a("./lib/TimeoutError"),z=[q,r,s,u,v,t,w,p,x].reduce(function(a,b){return b(a)},a("./lib/Promise")),A=Array.prototype.slice;return b.promise=c,b.resolve=z.resolve,b.reject=z.reject,b.lift=d,b["try"]=e,b.attempt=e,b.iterate=z.iterate,b.unfold=z.unfold,b.join=j,b.all=k,b.settle=l,b.any=d(z.any),b.some=d(z.some),b.map=m,b.reduce=n,b.reduceRight=o,b.isPromiseLike=i,b.Promise=z,b.defer=g,b.TimeoutError=y,b})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"./lib/Promise":4,"./lib/TimeoutError":6,"./lib/decorators/array":8,"./lib/decorators/flow":9,"./lib/decorators/fold":10,"./lib/decorators/inspect":11,"./lib/decorators/iterate":12,"./lib/decorators/progress":13,"./lib/decorators/timed":14,"./lib/decorators/unhandledRejection":15,"./lib/decorators/with":16}],21:[function(a,b){function c(a){return this instanceof c?(this._console=this._getConsole(a||{}),this._settings=this._configure(a||{}),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,d.createEventEmitter(this),this._delegateEvents(),void(this._settings.autoConnect&&this.connect())):new c(a)}var d=a("bane"),e=a("../lib/websocket/"),f=a("when");c.ConnectionError=function(a){this.name="ConnectionError",this.message=a},c.ConnectionError.prototype=new Error,c.ConnectionError.prototype.constructor=c.ConnectionError,c.ServerError=function(a){this.name="ServerError",this.message=a},c.ServerError.prototype=new Error,c.ServerError.prototype.constructor=c.ServerError,c.WebSocket=e.Client,c.prototype._getConsole=function(a){if("undefined"!=typeof a.console)return a.console;var b="undefined"!=typeof console&&console||{};return b.log=b.log||function(){},b.warn=b.warn||function(){},b.error=b.error||function(){},b},c.prototype._configure=function(a){var b="undefined"!=typeof document&&document.location.host||"localhost";return a.webSocketUrl=a.webSocketUrl||"ws://"+b+"/mopidy/ws",a.autoConnect!==!1&&(a.autoConnect=!0),a.backoffDelayMin=a.backoffDelayMin||1e3,a.backoffDelayMax=a.backoffDelayMax||64e3,"undefined"==typeof a.callingConvention&&this._console.warn("Mopidy.js is using the default calling convention. The default will change in the future. You should explicitly specify which calling convention you use."),a.callingConvention=a.callingConvention||"by-position-only",a},c.prototype._delegateEvents=function(){this.off("websocket:close"),this.off("websocket:error"),this.off("websocket:incomingMessage"),this.off("websocket:open"),this.off("state:offline"),this.on("websocket:close",this._cleanup),this.on("websocket:error",this._handleWebSocketError),this.on("websocket:incomingMessage",this._handleMessage),this.on("websocket:open",this._resetBackoffDelay),this.on("websocket:open",this._getApiSpec),this.on("state:offline",this._reconnect)},c.prototype.connect=function(){if(this._webSocket){if(this._webSocket.readyState===c.WebSocket.OPEN)return;this._webSocket.close()}this._webSocket=this._settings.webSocket||new c.WebSocket(this._settings.webSocketUrl),this._webSocket.onclose=function(a){this.emit("websocket:close",a)}.bind(this),this._webSocket.onerror=function(a){this.emit("websocket:error",a)}.bind(this),this._webSocket.onopen=function(){this.emit("websocket:open")}.bind(this),this._webSocket.onmessage=function(a){this.emit("websocket:incomingMessage",a)}.bind(this)},c.prototype._cleanup=function(a){Object.keys(this._pendingRequests).forEach(function(b){var d=this._pendingRequests[b];delete this._pendingRequests[b];var e=new c.ConnectionError("WebSocket closed");e.closeEvent=a,d.reject(e)}.bind(this)),this.emit("state:offline")},c.prototype._reconnect=function(){this.emit("reconnectionPending",{timeToAttempt:this._backoffDelay}),setTimeout(function(){this.emit("reconnecting"),this.connect()}.bind(this),this._backoffDelay),this._backoffDelay=2*this._backoffDelay,this._backoffDelay>this._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},c.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},c.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},c.prototype._handleWebSocketError=function(a){this._console.warn("WebSocket error:",a.stack||a)},c.prototype._send=function(a){switch(this._webSocket.readyState){case c.WebSocket.CONNECTING:return f.reject(new c.ConnectionError("WebSocket is still connecting"));case c.WebSocket.CLOSING:return f.reject(new c.ConnectionError("WebSocket is closing"));case c.WebSocket.CLOSED:return f.reject(new c.ConnectionError("WebSocket is closed"));default:var b=f.defer();return a.jsonrpc="2.0",a.id=this._nextRequestId(),this._pendingRequests[a.id]=b.resolver,this._webSocket.send(JSON.stringify(a)),this.emit("websocket:outgoingMessage",a),b.promise}},c.prototype._nextRequestId=function(){var a=-1;return function(){return a+=1}}(),c.prototype._handleMessage=function(a){try{var b=JSON.parse(a.data);b.hasOwnProperty("id")?this._handleResponse(b):b.hasOwnProperty("event")?this._handleEvent(b):this._console.warn("Unknown message type received. Message was: "+a.data)}catch(c){if(!(c instanceof SyntaxError))throw c;this._console.warn("WebSocket message parsing failed. Message was: "+a.data)}},c.prototype._handleResponse=function(a){if(!this._pendingRequests.hasOwnProperty(a.id))return void this._console.warn("Unexpected response received. Message was:",a);var b,d=this._pendingRequests[a.id];delete this._pendingRequests[a.id],a.hasOwnProperty("result")?d.resolve(a.result):a.hasOwnProperty("error")?(b=new c.ServerError(a.error.message),b.code=a.error.code,b.data=a.error.data,d.reject(b),this._console.warn("Server returned error:",a.error)):(b=new Error("Response without 'result' or 'error' received"),b.data={response:a},d.reject(b),this._console.warn("Response without 'result' or 'error' received. Message was:",a))},c.prototype._handleEvent=function(a){var b=a.event,c=a;delete c.event,this.emit("event:"+this._snakeToCamel(b),c)},c.prototype._getApiSpec=function(){return this._send({method:"core.describe"}).then(this._createApi.bind(this)).catch(this._handleWebSocketError)},c.prototype._createApi=function(a){var b="by-position-or-by-name"===this._settings.callingConvention,c=function(a){return function(){var c={method:a};return 0===arguments.length?this._send(c):b?arguments.length>1?f.reject(new Error("Expected zero arguments, a single array, or a single object.")):Array.isArray(arguments[0])||arguments[0]===Object(arguments[0])?(c.params=arguments[0],this._send(c)):f.reject(new TypeError("Expected an array or an object.")):(c.params=Array.prototype.slice.call(arguments),this._send(c))}.bind(this)}.bind(this),d=function(a){var b=a.split(".");return b.length>=1&&"core"===b[0]&&(b=b.slice(1)),b},e=function(a){var b=this;return a.forEach(function(a){a=this._snakeToCamel(a),b[a]=b[a]||{},b=b[a]}.bind(this)),b}.bind(this),g=function(b){var f=d(b),g=this._snakeToCamel(f.slice(-1)[0]),h=e(f.slice(0,-1));h[g]=c(b),h[g].description=a[b].description,h[g].params=a[b].params}.bind(this);Object.keys(a).forEach(g),this.emit("state:online")},c.prototype._snakeToCamel=function(a){return a.replace(/(_[a-z])/g,function(a){return a.toUpperCase().replace("_","")})},b.exports=c},{"../lib/websocket/":1,bane:2,when:20}]},{},[21])(21)}); \ No newline at end of file +!function(a){if("object"==typeof exports)module.exports=a();else if("function"==typeof define&&define.amd)define(a);else{var b;"undefined"!=typeof window?b=window:"undefined"!=typeof global?b=global:"undefined"!=typeof self&&(b=self),b.Mopidy=a()}}(function(){var a;return function b(a,c,d){function e(g,h){if(!c[g]){if(!a[g]){var i="function"==typeof require&&require;if(!h&&i)return i(g,!0);if(f)return f(g,!0);throw new Error("Cannot find module '"+g+"'")}var j=c[g]={exports:{}};a[g][0].call(j.exports,function(b){var c=a[g][1][b];return e(c?c:b)},j,j.exports,b,a,c,d)}return c[g].exports}for(var f="function"==typeof require&&require,g=0;g0)for(d=0;e>d;++d)c[d](a,b);else setTimeout(function(){throw b.message=a+" listener threw error: "+b.message,b},0)}function b(a){if("function"!=typeof a)throw new TypeError("Listener is not function");return a}function c(a){return a.supervisors||(a.supervisors=[]),a.supervisors}function d(a,b){return a.listeners||(a.listeners={}),b&&!a.listeners[b]&&(a.listeners[b]=[]),b?a.listeners[b]:a.listeners}function e(a){return a.errbacks||(a.errbacks=[]),a.errbacks}function f(f){function h(b,c,d){try{c.listener.apply(c.thisp||f,d)}catch(g){a(b,g,e(f))}}return f=f||{},f.on=function(a,e,f){return"function"==typeof a?c(this).push({listener:a,thisp:e}):void d(this,a).push({listener:b(e),thisp:f})},f.off=function(a,b){var f,g,h,i;if(!a){f=c(this),f.splice(0,f.length),g=d(this);for(h in g)g.hasOwnProperty(h)&&(f=d(this,h),f.splice(0,f.length));return f=e(this),void f.splice(0,f.length)}if("function"==typeof a?(f=c(this),b=a):f=d(this,a),!b)return void f.splice(0,f.length);for(h=0,i=f.length;i>h;++h)if(f[h].listener===b)return void f.splice(h,1)},f.once=function(a,b,c){var d=function(){f.off(a,d),b.apply(this,arguments)};f.on(a,d,c)},f.bind=function(a,b){var c,d,e;if(b)for(d=0,e=b.length;e>d;++d){if("function"!=typeof a[b[d]])throw new Error("No such method "+b[d]);this.on(b[d],a[b[d]],a)}else for(c in a)"function"==typeof a[c]&&this.on(c,a[c],a);return a},f.emit=function(a){var b,e,f=c(this),i=g.call(arguments);for(b=0,e=f.length;e>b;++b)h(a,f[b],i);for(f=d(this,a).slice(),i=g.call(arguments,1),b=0,e=f.length;e>b;++b)h(a,f[b],i)},f.errback=function(a){this.errbacks||(this.errbacks=[]),this.errbacks.push(b(a))},f}var g=Array.prototype.slice;return{createEventEmitter:f,aggregate:function(a){var b=f();return a.forEach(function(a){a.on(function(a,c){b.emit(a,c)})}),b}}})},{}],3:[function(a,b){function c(){}var d=b.exports={};d.nextTick=function(){var a="undefined"!=typeof window&&window.setImmediate,b="undefined"!=typeof window&&window.postMessage&&window.addEventListener;if(a)return function(a){return window.setImmediate(a)};if(b){var c=[];return window.addEventListener("message",function(a){var b=a.source;if((b===window||null===b)&&"process-tick"===a.data&&(a.stopPropagation(),c.length>0)){var d=c.shift();d()}},!0),function(a){c.push(a),window.postMessage("process-tick","*")}}return function(a){setTimeout(a,0)}}(),d.title="browser",d.browser=!0,d.env={},d.argv=[],d.on=c,d.addListener=c,d.once=c,d.off=c,d.removeListener=c,d.removeAllListeners=c,d.emit=c,d.binding=function(){throw new Error("process.binding is not supported")},d.cwd=function(){return"/"},d.chdir=function(){throw new Error("process.chdir is not supported")}},{}],4:[function(b,c){!function(a){"use strict";a(function(a){var b=a("./makePromise"),c=a("./Scheduler"),d=a("./env").asap;return b({scheduler:new c(d)})})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"./Scheduler":5,"./env":17,"./makePromise":19}],5:[function(b,c){!function(a){"use strict";a(function(){function a(a){this._async=a,this._running=!1,this._queue=this,this._queueLen=0,this._afterQueue={},this._afterQueueLen=0;var b=this;this.drain=function(){b._drain()}}return a.prototype.enqueue=function(a){this._queue[this._queueLen++]=a,this.run()},a.prototype.afterQueue=function(a){this._afterQueue[this._afterQueueLen++]=a,this.run()},a.prototype.run=function(){this._running||(this._running=!0,this._async(this.drain))},a.prototype._drain=function(){for(var a=0;a>>0,j=i,k=[],l=0;i>l;++l)if(f=b[l],void 0!==f||l in b){if(e=a._handler(f),e.state()>0){h.become(e),a._visitRemaining(b,l,e);break}e.visit(h,c,d)}else--j;return 0===j&&h.reject(new RangeError("any(): array must not be empty")),g}function e(b,c){function d(a){this.resolved||(k.push(a),0===--n&&(l=null,this.resolve(k)))}function e(a){this.resolved||(l.push(a),0===--f&&(k=null,this.reject(l)))}var f,g,h,i=a._defer(),j=i._handler,k=[],l=[],m=b.length>>>0,n=0;for(h=0;m>h;++h)g=b[h],(void 0!==g||h in b)&&++n;for(c=Math.max(c,0),f=n-c+1,n=Math.min(c,n),c>n?j.reject(new RangeError("some(): array must contain at least "+c+" item(s), but had "+n)):0===n&&j.resolve(k),h=0;m>h;++h)g=b[h],(void 0!==g||h in b)&&a._handler(g).visit(j,d,e,j.notify);return i}function f(b,c){return a._traverse(c,b)}function g(b,c){var d=s.call(b);return a._traverse(c,d).then(function(a){return h(d,a)})}function h(b,c){for(var d=c.length,e=new Array(d),f=0,g=0;d>f;++f)c[f]&&(e[g++]=a._handler(b[f]).value);return e.length=g,e}function i(a){return p(a.map(j))}function j(c){var d=a._handler(c);return 0===d.state()?o(c).then(b.fulfilled,b.rejected):(d._unreport(),b.inspect(d))}function k(a,b){return arguments.length>2?q.call(a,m(b),arguments[2]):q.call(a,m(b))}function l(a,b){return arguments.length>2?r.call(a,m(b),arguments[2]):r.call(a,m(b))}function m(a){return function(b,c,d){return n(a,void 0,[b,c,d])}}var n=c(a),o=a.resolve,p=a.all,q=Array.prototype.reduce,r=Array.prototype.reduceRight,s=Array.prototype.slice;return a.any=d,a.some=e,a.settle=i,a.map=f,a.filter=g,a.reduce=k,a.reduceRight=l,a.prototype.spread=function(a){return this.then(p).then(function(b){return a.apply(this,b)})},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../apply":7,"../state":20}],9:[function(b,c){!function(a){"use strict";a(function(){function a(){throw new TypeError("catch predicate must be a function")}function b(a,b){return c(b)?a instanceof b:b(a)}function c(a){return a===Error||null!=a&&a.prototype instanceof Error}function d(a){return("object"==typeof a||"function"==typeof a)&&null!==a}function e(a){return a}return function(c){function f(a,c){return function(d){return b(d,c)?a.call(this,d):j(d)}}function g(a,b,c,e){var f=a.call(b);return d(f)?h(f,c,e):c(e)}function h(a,b,c){return i(a).then(function(){return b(c)})}var i=c.resolve,j=c.reject,k=c.prototype["catch"];return c.prototype.done=function(a,b){this._handler.visit(this._handler.receiver,a,b)},c.prototype["catch"]=c.prototype.otherwise=function(b){return arguments.length<2?k.call(this,b):"function"!=typeof b?this.ensure(a):k.call(this,f(arguments[1],b))},c.prototype["finally"]=c.prototype.ensure=function(a){return"function"!=typeof a?this:this.then(function(b){return g(a,this,e,b)},function(b){return g(a,this,j,b)})},c.prototype["else"]=c.prototype.orElse=function(a){return this.then(void 0,function(){return a})},c.prototype["yield"]=function(a){return this.then(function(){return a})},c.prototype.tap=function(a){return this.then(a)["yield"](this)},c}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],10:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype.fold=function(b,c){var d=this._beget();return this._handler.fold(function(c,d,e){a._handler(c).fold(function(a,c,d){d.resolve(b.call(this,c,a))},d,this,e)},c,d._handler.receiver,d._handler),d},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],11:[function(b,c){!function(a){"use strict";a(function(a){var b=a("../state").inspect;return function(a){return a.prototype.inspect=function(){return b(a._handler(this))},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../state":20}],12:[function(b,c){!function(a){"use strict";a(function(){return function(a){function b(a,b,d,e){return c(function(b){return[b,a(b)]},b,d,e)}function c(a,b,e,f){function g(f,g){return d(e(f)).then(function(){return c(a,b,e,g)})}return d(f).then(function(c){return d(b(c)).then(function(b){return b?c:d(a(c)).spread(g)})})}var d=a.resolve;return a.iterate=b,a.unfold=c,a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],13:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype.progress=function(a){return this.then(void 0,void 0,a)},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],14:[function(b,c){!function(a){"use strict";a(function(a){function b(a,b,d,e){return c.setTimer(function(){a(d,e,b)},b)}var c=a("../env"),d=a("../TimeoutError");return function(a){function e(a,c,d){b(f,a,c,d)}function f(a,b){b.resolve(a)}function g(a,b,c){var e="undefined"==typeof a?new d("timed out after "+c+"ms"):a;b.reject(e)}return a.prototype.delay=function(a){var b=this._beget();return this._handler.fold(e,a,void 0,b._handler),b},a.prototype.timeout=function(a,d){var e=this._beget(),f=e._handler,h=b(g,a,d,e._handler);return this._handler.visit(f,function(a){c.clearTimer(h),this.resolve(a)},function(a){c.clearTimer(h),this.reject(a)},f.notify),e},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../TimeoutError":6,"../env":17}],15:[function(b,c){!function(a){"use strict";a(function(a){function b(a){throw a}function c(){}var d=a("../env").setTimer,e=a("../format");return function(a){function f(a){a.handled||(n.push(a),k("Potentially unhandled rejection ["+a.id+"] "+e.formatError(a.value)))}function g(a){var b=n.indexOf(a);b>=0&&(n.splice(b,1),l("Handled previous rejection ["+a.id+"] "+e.formatObject(a.value)))}function h(a,b){m.push(a,b),null===o&&(o=d(i,0))}function i(){for(o=null;m.length>0;)m.shift()(m.shift())}var j,k=c,l=c;"undefined"!=typeof console&&(j=console,k="undefined"!=typeof j.error?function(a){j.error(a)}:function(a){j.log(a)},l="undefined"!=typeof j.info?function(a){j.info(a)}:function(a){j.log(a)}),a.onPotentiallyUnhandledRejection=function(a){h(f,a)},a.onPotentiallyUnhandledRejectionHandled=function(a){h(g,a)},a.onFatalRejection=function(a){h(b,a.value)};var m=[],n=[],o=null;return a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"../env":17,"../format":18}],16:[function(b,c){!function(a){"use strict";a(function(){return function(a){return a.prototype["with"]=a.prototype.withThis=function(a){var b=this._beget(),c=b._handler;return c.receiver=a,this._handler.chain(c,a),b},a}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],17:[function(b,c){(function(d){!function(a){"use strict";a(function(a){function b(){return"undefined"!=typeof d&&null!==d&&"function"==typeof d.nextTick}function c(){return"function"==typeof MutationObserver&&MutationObserver||"function"==typeof WebKitMutationObserver&&WebKitMutationObserver}function e(a){function b(){var a=c;c=void 0,a()}var c,d=document.createTextNode(""),e=new a(b);e.observe(d,{characterData:!0});var f=0;return function(a){c=a,d.data=f^=1}}var f,g="undefined"!=typeof setTimeout&&setTimeout,h=function(a,b){return setTimeout(a,b)},i=function(a){return clearTimeout(a)},j=function(a){return g(a,0)};if(b())j=function(a){return d.nextTick(a)};else if(f=c())j=e(f);else if(!g){var k=a,l=k("vertx");h=function(a,b){return l.setTimer(b,a)},i=l.cancelTimer,j=l.runOnLoop||l.runOnContext}return{setTimer:h,clearTimer:i,asap:j}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})}).call(this,b("FWaASH"))},{FWaASH:3}],18:[function(b,c){!function(a){"use strict";a(function(){function a(a){var c="object"==typeof a&&null!==a&&a.stack?a.stack:b(a);return a instanceof Error?c:c+" (WARNING: non-Error used)"}function b(a){var b=String(a);return"[object Object]"===b&&"undefined"!=typeof JSON&&(b=c(a,b)),b}function c(a,b){try{return JSON.stringify(a)}catch(c){return b}}return{formatError:a,formatObject:b,tryStringify:c}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],19:[function(b,c){(function(b){!function(a){"use strict";a(function(){return function(a){function c(a,b){this._handler=a===u?b:d(a)}function d(a){function b(a){e.resolve(a)}function c(a){e.reject(a)}function d(a){e.notify(a)}var e=new w;try{a(b,c,d)}catch(f){c(f)}return e}function e(a){return J(a)?a:new c(u,new x(r(a)))}function f(a){return new c(u,new x(new A(a)))}function g(){return ab}function h(){return new c(u,new w)}function i(a,b){var c=new w(a.receiver,a.join().context);return new b(u,c)}function j(a){return l(T,null,a)}function k(a,b){return l(O,a,b)}function l(a,b,d){function e(c,e,g){g.resolved||m(d,f,c,a(b,e,c),g)}function f(a,b,c){k[a]=b,0===--j&&c.become(new z(k))}for(var g,h="function"==typeof b?e:f,i=new w,j=d.length>>>0,k=new Array(j),l=0;l0?b(c,f.value,e):(e.become(f),n(a,c+1,f))}else b(c,d,e)}function n(a,b,c){for(var d=b;dc&&a._unreport()}}function p(a){return"object"!=typeof a||null===a?f(new TypeError("non-iterable passed to race()")):0===a.length?g():1===a.length?e(a[0]):q(a)}function q(a){var b,d,e,f=new w;for(b=0;b0||"function"!=typeof b&&0>e)return new this.constructor(u,d);var f=this._beget(),g=f._handler;return d.chain(g,d.receiver,a,b,c),f},c.prototype["catch"]=function(a){return this.then(void 0,a)},c.prototype._beget=function(){return i(this._handler,this.constructor)},c.all=j,c.race=p,c._traverse=k,c._visitRemaining=n,u.prototype.when=u.prototype.become=u.prototype.notify=u.prototype.fail=u.prototype._unreport=u.prototype._report=U,u.prototype._state=0,u.prototype.state=function(){return this._state},u.prototype.join=function(){for(var a=this;void 0!==a.handler;)a=a.handler;return a},u.prototype.chain=function(a,b,c,d,e){this.when({resolver:a,receiver:b,fulfilled:c,rejected:d,progress:e})},u.prototype.visit=function(a,b,c,d){this.chain(Z,a,b,c,d)},u.prototype.fold=function(a,b,c,d){this.when(new I(a,b,c,d))},S(u,v),v.prototype.become=function(a){a.fail()};var Z=new v;S(u,w),w.prototype._state=0,w.prototype.resolve=function(a){this.become(r(a))},w.prototype.reject=function(a){this.resolved||this.become(new A(a))},w.prototype.join=function(){if(!this.resolved)return this;for(var a=this;void 0!==a.handler;)if(a=a.handler,a===this)return this.handler=D();return a},w.prototype.run=function(){var a=this.consumers,b=this.handler;this.handler=this.handler.join(),this.consumers=void 0;for(var c=0;c0?c(d.value):b(d.value)}return{pending:a,fulfilled:c,rejected:b,inspect:d}})}("function"==typeof a&&a.amd?a:function(a){c.exports=a()})},{}],21:[function(b,c){!function(a){"use strict";a(function(a){function b(a,b,c,d){var e=x.resolve(a);return arguments.length<2?e:e.then(b,c,d)}function c(a){return new x(a)}function d(a){return function(){for(var b=0,c=arguments.length,d=new Array(c);c>b;++b)d[b]=arguments[b];return y(a,this,d)}}function e(a){for(var b=0,c=arguments.length-1,d=new Array(c);c>b;++b)d[b]=arguments[b+1];return y(a,this,d)}function f(){return new g}function g(){function a(a){d._handler.resolve(a)}function b(a){d._handler.reject(a)}function c(a){d._handler.notify(a)}var d=x._defer();this.promise=d,this.resolve=a,this.reject=b,this.notify=c,this.resolver={resolve:a,reject:b,notify:c}}function h(a){return a&&"function"==typeof a.then}function i(){return x.all(arguments)}function j(a){return b(a,x.all)}function k(a){return b(a,x.settle)}function l(a,c){return b(a,function(a){return x.map(a,c)})}function m(a,c){return b(a,function(a){return x.filter(a,c)})}var n=a("./lib/decorators/timed"),o=a("./lib/decorators/array"),p=a("./lib/decorators/flow"),q=a("./lib/decorators/fold"),r=a("./lib/decorators/inspect"),s=a("./lib/decorators/iterate"),t=a("./lib/decorators/progress"),u=a("./lib/decorators/with"),v=a("./lib/decorators/unhandledRejection"),w=a("./lib/TimeoutError"),x=[o,p,q,s,t,r,u,n,v].reduce(function(a,b){return b(a)},a("./lib/Promise")),y=a("./lib/apply")(x);return b.promise=c,b.resolve=x.resolve,b.reject=x.reject,b.lift=d,b["try"]=e,b.attempt=e,b.iterate=x.iterate,b.unfold=x.unfold,b.join=i,b.all=j,b.settle=k,b.any=d(x.any),b.some=d(x.some),b.race=d(x.race),b.map=l,b.filter=m,b.reduce=d(x.reduce),b.reduceRight=d(x.reduceRight),b.isPromiseLike=h,b.Promise=x,b.defer=f,b.TimeoutError=w,b})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{"./lib/Promise":4,"./lib/TimeoutError":6,"./lib/apply":7,"./lib/decorators/array":8,"./lib/decorators/flow":9,"./lib/decorators/fold":10,"./lib/decorators/inspect":11,"./lib/decorators/iterate":12,"./lib/decorators/progress":13,"./lib/decorators/timed":14,"./lib/decorators/unhandledRejection":15,"./lib/decorators/with":16}],22:[function(a,b){function c(a){return this instanceof c?(this._console=this._getConsole(a||{}),this._settings=this._configure(a||{}),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,d.createEventEmitter(this),this._delegateEvents(),void(this._settings.autoConnect&&this.connect())):new c(a)}var d=a("bane"),e=a("../lib/websocket/"),f=a("when");c.ConnectionError=function(a){this.name="ConnectionError",this.message=a},c.ConnectionError.prototype=Object.create(Error.prototype),c.ConnectionError.prototype.constructor=c.ConnectionError,c.ServerError=function(a){this.name="ServerError",this.message=a},c.ServerError.prototype=Object.create(Error.prototype),c.ServerError.prototype.constructor=c.ServerError,c.WebSocket=e.Client,c.when=f,c.prototype._getConsole=function(a){if("undefined"!=typeof a.console)return a.console;var b="undefined"!=typeof console&&console||{};return b.log=b.log||function(){},b.warn=b.warn||function(){},b.error=b.error||function(){},b},c.prototype._configure=function(a){var b="undefined"!=typeof document&&"https:"===document.location.protocol?"wss://":"ws://",c="undefined"!=typeof document&&document.location.host||"localhost";return a.webSocketUrl=a.webSocketUrl||b+c+"/mopidy/ws",a.autoConnect!==!1&&(a.autoConnect=!0),a.backoffDelayMin=a.backoffDelayMin||1e3,a.backoffDelayMax=a.backoffDelayMax||64e3,"undefined"==typeof a.callingConvention&&this._console.warn("Mopidy.js is using the default calling convention. The default will change in the future. You should explicitly specify which calling convention you use."),a.callingConvention=a.callingConvention||"by-position-only",a},c.prototype._delegateEvents=function(){this.off("websocket:close"),this.off("websocket:error"),this.off("websocket:incomingMessage"),this.off("websocket:open"),this.off("state:offline"),this.on("websocket:close",this._cleanup),this.on("websocket:error",this._handleWebSocketError),this.on("websocket:incomingMessage",this._handleMessage),this.on("websocket:open",this._resetBackoffDelay),this.on("websocket:open",this._getApiSpec),this.on("state:offline",this._reconnect)},c.prototype.connect=function(){if(this._webSocket){if(this._webSocket.readyState===c.WebSocket.OPEN)return;this._webSocket.close()}this._webSocket=this._settings.webSocket||new c.WebSocket(this._settings.webSocketUrl),this._webSocket.onclose=function(a){this.emit("websocket:close",a)}.bind(this),this._webSocket.onerror=function(a){this.emit("websocket:error",a)}.bind(this),this._webSocket.onopen=function(){this.emit("websocket:open")}.bind(this),this._webSocket.onmessage=function(a){this.emit("websocket:incomingMessage",a)}.bind(this)},c.prototype._cleanup=function(a){Object.keys(this._pendingRequests).forEach(function(b){var d=this._pendingRequests[b];delete this._pendingRequests[b];var e=new c.ConnectionError("WebSocket closed");e.closeEvent=a,d.reject(e)}.bind(this)),this.emit("state:offline")},c.prototype._reconnect=function(){this.emit("reconnectionPending",{timeToAttempt:this._backoffDelay}),setTimeout(function(){this.emit("reconnecting"),this.connect()}.bind(this),this._backoffDelay),this._backoffDelay=2*this._backoffDelay,this._backoffDelay>this._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},c.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},c.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},c.prototype._handleWebSocketError=function(a){this._console.warn("WebSocket error:",a.stack||a)},c.prototype._send=function(a){switch(this._webSocket.readyState){case c.WebSocket.CONNECTING:return f.reject(new c.ConnectionError("WebSocket is still connecting"));case c.WebSocket.CLOSING:return f.reject(new c.ConnectionError("WebSocket is closing"));case c.WebSocket.CLOSED:return f.reject(new c.ConnectionError("WebSocket is closed"));default:var b=f.defer();return a.jsonrpc="2.0",a.id=this._nextRequestId(),this._pendingRequests[a.id]=b.resolver,this._webSocket.send(JSON.stringify(a)),this.emit("websocket:outgoingMessage",a),b.promise}},c.prototype._nextRequestId=function(){var a=-1;return function(){return a+=1}}(),c.prototype._handleMessage=function(a){try{var b=JSON.parse(a.data);b.hasOwnProperty("id")?this._handleResponse(b):b.hasOwnProperty("event")?this._handleEvent(b):this._console.warn("Unknown message type received. Message was: "+a.data)}catch(c){if(!(c instanceof SyntaxError))throw c;this._console.warn("WebSocket message parsing failed. Message was: "+a.data)}},c.prototype._handleResponse=function(a){if(!this._pendingRequests.hasOwnProperty(a.id))return void this._console.warn("Unexpected response received. Message was:",a);var b,d=this._pendingRequests[a.id];delete this._pendingRequests[a.id],a.hasOwnProperty("result")?d.resolve(a.result):a.hasOwnProperty("error")?(b=new c.ServerError(a.error.message),b.code=a.error.code,b.data=a.error.data,d.reject(b),this._console.warn("Server returned error:",a.error)):(b=new Error("Response without 'result' or 'error' received"),b.data={response:a},d.reject(b),this._console.warn("Response without 'result' or 'error' received. Message was:",a))},c.prototype._handleEvent=function(a){var b=a.event,c=a;delete c.event,this.emit("event:"+this._snakeToCamel(b),c)},c.prototype._getApiSpec=function(){return this._send({method:"core.describe"}).then(this._createApi.bind(this))["catch"](this._handleWebSocketError)},c.prototype._createApi=function(a){var b="by-position-or-by-name"===this._settings.callingConvention,c=function(a){return function(){var c={method:a};return 0===arguments.length?this._send(c):b?arguments.length>1?f.reject(new Error("Expected zero arguments, a single array, or a single object.")):Array.isArray(arguments[0])||arguments[0]===Object(arguments[0])?(c.params=arguments[0],this._send(c)):f.reject(new TypeError("Expected an array or an object.")):(c.params=Array.prototype.slice.call(arguments),this._send(c))}.bind(this)}.bind(this),d=function(a){var b=a.split(".");return b.length>=1&&"core"===b[0]&&(b=b.slice(1)),b},e=function(a){var b=this;return a.forEach(function(a){a=this._snakeToCamel(a),b[a]=b[a]||{},b=b[a]}.bind(this)),b}.bind(this),g=function(b){var f=d(b),g=this._snakeToCamel(f.slice(-1)[0]),h=e(f.slice(0,-1));h[g]=c(b),h[g].description=a[b].description,h[g].params=a[b].params}.bind(this);Object.keys(a).forEach(g),this.emit("state:online")},c.prototype._snakeToCamel=function(a){return a.replace(/(_[a-z])/g,function(a){return a.toUpperCase().replace("_","")})},b.exports=c},{"../lib/websocket/":1,bane:2,when:21}]},{},[22])(22)}); \ No newline at end of file From bec7d0d4acba123333c51bdf92c4e191a1879035 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Jan 2015 12:45:20 +0100 Subject: [PATCH 010/314] docs: New year (cherry picked from commit edcad494dabf4dc9ba3b86d2e50ac538ac60a0dd) --- docs/authors.rst | 2 +- docs/conf.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/authors.rst b/docs/authors.rst index 7c00e2ac..1a0f21ed 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -4,7 +4,7 @@ Authors ******* -Mopidy is copyright 2009-2014 Stein Magnus Jodal and contributors. Mopidy is +Mopidy is copyright 2009-2015 Stein Magnus Jodal and contributors. Mopidy is licensed under the `Apache License, Version 2.0 `_. diff --git a/docs/conf.py b/docs/conf.py index f7475293..f9cdd613 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -96,7 +96,7 @@ source_suffix = '.rst' master_doc = 'index' project = 'Mopidy' -copyright = '2009-2014, Stein Magnus Jodal and contributors' +copyright = '2009-2015, Stein Magnus Jodal and contributors' from mopidy.utils.versioning import get_version release = get_version() From 071d0e0639357a104c7c3fa9c3ffa79fa7e00036 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Jan 2015 12:47:37 +0100 Subject: [PATCH 011/314] docs: Docs site has HTTPS now (cherry picked from commit 78b39390e31d1e677fc630c8afea175991a2c1bc) --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index a70eef7e..1da79a6e 100644 --- a/README.rst +++ b/README.rst @@ -49,8 +49,8 @@ Spotify. To get started with Mopidy, check out `the installation docs `_. -- `Documentation `_ -- `Discuss `_ +- `Documentation `_ +- `Discussion forum `_ - `Source code `_ - `Issue tracker `_ - `Development branch tarball `_ From 7fd0603e160fa236f4de6b23df14d8164b854603 Mon Sep 17 00:00:00 2001 From: Dirk Groenen Date: Sun, 4 Jan 2015 03:30:42 +0100 Subject: [PATCH 012/314] docs: Update Mopidy-Mopify entry --- docs/ext/mopify.jpg | Bin 0 -> 106257 bytes docs/ext/mopify.png | Bin 193583 -> 0 bytes docs/ext/web.rst | 11 ++++++----- 3 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 docs/ext/mopify.jpg delete mode 100644 docs/ext/mopify.png diff --git a/docs/ext/mopify.jpg b/docs/ext/mopify.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1ac060b73d8d2237dfeee7c39e8445addfa1f995 GIT binary patch literal 106257 zcmce-Wpo_9vM4&nm@#JNIA&%iF*AG26f-kp5;HT89W&FIIc8>NW@dJt{q243-Dlmi z-n#G4tM1iORY{{NsY)t!YyQsvT?e2^iAjn9ARr(B5^opa?-D>500jy8e!l_q+YJK; z0|N~WgYe-4EF2O75)vW;A|f&h1{yL7Itn5p8a5g_=0_|nEF@GMTM_>ahl$RGbd!{1H-Ivm7rC{id0bO0ne1Qa^N-);aA0003E^(O8AhBpae zAs|1%!N0Ky-Z*dE|KvkJz8CoLcM*UD1p$CWgF<`ziJSkbPI#NJl`>`QZ*GC9O@PN7 z?k_ilhLpmlff*mXTAka$n(fRPcwtgkrY6p^WcFfr;3M8R_|l%Ji-Miligdft!GfW^`Ap*n@Sk=nWfIosl^atc`yIoK8V44AGSr|tucv}c)!fU|5+{dx zV$=NB%doeM|C^!!WkBG9DHZ;G+LXYn$3XT&9EAnX<277o6ePbs0!B||V{ZSO`f zH;cv@SnKdbIkIAAfG#ooL{m?Vs-8%9XM!oQ$ub;!ezCF1hWlW+uEfh4st)I~^S7nM zm&8Y)!J|X-l{I=h?~q{bBt_05k%pNKwGnc--qlZKEhrBeSh0 ztT(kAZPmER=Eh+m=PuE91J)h^A$UNi7>Wq4S?{@+b;ulQR-V>iG{U zxDU5S@}(JXSWb?XU!C|DUFe|lsOVTZnx&O|iGD)fEQ4|bY;H6eG$&^6xmxXQ4y%a_ z=R-3thP+Wc{sPqKnxwGF*jJ^uCNSH1r&M<9+vFOLcWa&Lq*_&bb+BF<9+OC{m*RY^ z7y7=PZ3x@A#IQa%FL1vwncl3wsqqePs%$>YQCC?Zf9GkpWw=7VDV^|OK5S{#0wQt- zPVfZDT=5yPCTx10T@$Oydl|&l%xGFV4BmzF!ub_lUnwWaKgk6!ip{S7_#s7?8=99-Ffp1f3)!L9T?}Mv(>ia<=$M~G11kJt`?+DNtk;X z)b&cGt#S*2{RLhtPRCVBw!+S(gaCmnycZe>cXj19Y`JyKS zuWQ1-#Fq2Kp5E_Eyw&JAEgn8PBK|oWont(U^m=i~JATfI?As9w4Lk~SVF86P@gbMB zFZqJX8jH=fG>&bmh1VMs4b5!mcpmF;;No@?fktG!lm!8%wAU2g+VXqT6-GnJ! z)%^?B#RyZ%GCYkQ2HLSX-J?xsG?~=~Pf9xG#oaxkvIZ^CjkwC@QkwJCb%sErtt< zO}XU|QJ$&eV$aChiA||8J|4vRyJirBmU_HZa-2dS^X#xAx6i6Y*`uV3H^`gpVY;}MKt~Sir>zc$ z5hZ;uf(WQZJF`yh4i~MwtItbvWhmYU?vFcVC7E(X+a7ixZtxT(6OrNIY0e`*M`XBB z<8`@{{-LeH;jYw-ZhcIji-%z?11;9!D_1?@LYM32QH|q4uYjD#nmD70e(j#S7LU#= zuV7zs#mXK}$Rbwjg|*e=UDUBaNbS|h7#~a8fYf28V)z_rgG8hHO`*mFZ-nk#qj_}aqg)vU!+xAR(%`C%dewL~y&&qe2fmq)K; z2P3eFc#1A?#dP?z+)1gmoi6JV-q}k;U4KkAEJszlu}kBie9y3uCAwfAG{kEhG)^it4KkcZ1{-XhqC(z)YAC%>GWc+VVRk-{s7K}%flK0;a9tW-CpYKpO}uU1^vdiiPQIuB>^vS}~r zK>k?H6}o<0tq0cS-(5)V)w5lw>WnkKE1^Jfe?zzKql z6ecYbvKOY@4juB_4)^k!W=rXYpAVKCQE6lr&3Wl^YI3NO+_m!c5quk1mM0mBr#6Ye zY_-x1S73eQ6hpSgDvjMNpSITUpR>^eM%IZQTeiTfRrw0wO@m_rA#nNPuC&cn%z!5# z+m5>|HuZpE^D_B>Op7v{0AF2%%1q26Aj&+OP4$TW1e5T^x@IWvTHl|1_anPpFWM#5 zu^#{cWnlM@mHxjzAV}ZXP>%Wp|1Jel*?`n7Lx9{}JiEt&aqw^I|C~Taz>)_RVpbwX z1a1E2{CAnRRxvmLP{Jy-7`cA`bG?64G2T;bE{Lk%;{%12+wT~1z@PWz{{~=t1N|(Z z{N9Ul;y@;#6Z&u0uw%xKTVXg0Tk=v4#e+*{FUOgU07%J@!0YtMtT8dt_rgw7_WqSA}#xfo-@^?nhtfDaqTe-dv*W7S!H{OQa5G(- z+on$UnqxiQ#__C(Jn(q?WHkua&1mPBBQB?&(65gBAmhSkj1#u14mXZN;D~`s=8C$J@jF@YvkponL45oi1 zoA1l=Iyn% z<013pP78YW=A_OS9(MK6O1wIA-CM04YgakmI(ZEu^?Ifg_0J?kip+4>C=w;elo1#G zz-Jff>7;qkQ#WFCN6)dtYvAATOERgzX0{c|0`0ushX~Qfv_5dA22>(azUl85W@yu$3d%b;u;*PJ4RJqadhk0fl;>0PXpj9q;t#Z3$o}54day#f^n$|t za4&*EX|IRzky=v8ZC8AXY+{5hQ((t))U{qTQ?v*;n&}mjV!&oTy4Zbv8t(C0DcnPx z$?73|B<(R2DpRT#=ia=+!)tYg?alV{mit9x_mJ3=U-wg(fcf{_8z2V^i`ZwZ zQDfgrims@MP?+%^5#=LoM&5SxrkHu44&CLJOBa5T%GUbY=L8`Dg!?kFpzx^pF*D|x1Y=#YWYqimE&fLu3XE?8rL-1r?o#+ zVhPQkc6e1giwztArJLr35nKu~Cd*lS^r|JT(GX`cneBYY7{l><6Aa?maN^H@cw`X& z!B%*kxvZ9Q0sG`<=hb;@4bKYBxg0uW@VSRP`E|Ls8&YH9gSwoNtD}F$c}WLQjCg$g zNQtO@d>b`0NPvW>yFLUVK{*tyE9W5i5Pklj|9qjxqz4n81H5OW+~(|_J+O$EeC(-~ zcOpdm{idqcaUpU#ZSeA8Wk>gGuf|4eGlQqxgTy0$Yg*vOUUkp9Oq~CPI%TL`KdK>d zhsdHazw-GtJ)m&FgmWaXxcv2d;y{n{u~+LE`E;ANn;3xy zVs+9FP44Tm%7dGX4VF!Jk)g%sCxynFf7vY0{(TFk$N;_L&upQ{sCwZS7%j&EuHq9Z8|GJudOi9Fi((X+OhznNnJ(m zvu@?p!sa;@N~&};wUmJx)W1B9ne!*B7w@~&Z?j$yNd-tRlr6Hx{Age`f?;r!Eiza# zj%QoT$5(!8Ax<}GtRkJiLJ}oh>1@+NfUI(<;M#Ea#j!?shOm|SLF?bf&^0wqBg;SS z8bdKGg0}tmo!8%o*P+dN{fzMNdOtqx)I-5(^agaOR^~I)w&#f|hvU63JcuK;U{}MI zymedfvGew*i#tu@5XGNznks&Hw2L!W-#!-B!*s3wZZ8owp2YszsNGP9LvU( zIFftZIMQ860RJOyyJ$iQP8oY5uluQnIZL^!ZYge8k-f82_w8SRaJZlk;kBE`*BIKJ zQkAO|UBXP6AccrNUDKfou2wQz^lI$S!^R~`JdbQFI;~bUwP!hak?FtoWPvTEt=2eZ zJ1clYvU-aK#oCQmhno~@p40rdZY}Sk{bLf9Ki~o|e~Ng$6JR?xP-wT#fV~=;-<^`5 zW=ea>O|tE^#1|JrJ;r8s6IzrWZmxoo_Sa!b_glSMJdr)yeX$6%cc#V~nwnbVZLhhz z_7=uu2eOo*$iD^>prGap7m96Eq*b*_Jv4_Emb!m0E4eK<@kSnL5iPPf>^+)Dc0-7h zXH3z=(VS#2y(xjxfQc*^D#hB`cbWO`v+cWhZvfl59*Mkl=g6#zA(va^wqkbwD^1K= zC(6?Ga#)kr>+zv$=vr7oPO*h?bu5XBh20)suX(Eic)W}Hfv|LX;1{X>K&;y%FR#BY z{sR*qdo5Loi4b-!$ZC`NLY7yTYdGJNXV=34H8uE0vVsIWeDFMI7zaU9Ea?%m3~J0Y z?9aOlw=DbX|FMiygg73-W5?!p9Z6a_k!xvUko1%g7!5u=e!rmkgj6x)&TIEmLk=yM z!5i;$Kyd2NffGu)@F$JIvs?pe|TY08Vl!^ zdOfF7ffg^FCKSWc-zH59_|JyK{>V{NuD@4?fMZR@$QAK_i&Fsrwx6)zXsq`fL4>R# zb@V$CLf*e+*4PS<*#tLHUjjhzt+bXBhEy7Ef%I)Y_j@bw4)faoO8>_v+_W(-hS;C~ z&4beacb*gHe-rc$XPH|+kxy==_i04ONZo(eM z876TSIBAs}Jf&h$t{ly&LB$*q`+`vokqf1cf%FG@8?ZW#8(0bX6#xwdJGuBx^QZn> zd$7i4ko*Df4MXb3LR7wI-hcoxnOi>L?3<$tQw{y%@uiraJPtp{keiFlO9HU7F;r-2%+hMRWNxKm;85!l9o-8A2OP16*W|zo z`IAdH>k>ju&F5bIN!Qxz?#fq^p`4X7zqa~>&5nUXcba@KY0Pji;jmlKYV!Q+n;?+j zM{kP{nJCnpJl}qi`eBgDS4N5X{Z}WxExv>#gLfP5d@4i0DoH`_RY#d_1BQw1exUiY z&ywW+iJtZOnd?wSpDV7bC>`t-P02y2FtDd;)?GtRDWcq7UV14$ICy)1woXWv0gn}J z`A@cky2k_f+m-dMeB$F0lRRZ_L+_Q7)(aC)JLkgByAmJkq#CD+-{(8LRj6s~H5d*? zox%XX=ct0Y-|p9!VD^v-Y0A^21D%DDInHJ8>qv(Ew1?NG8V`2ZKX2;1gB7Fxx}f~{ z+(R=~pZ5VPt~JF#-kl!8$dTJf3BPdAY~<#xH_>e-?o$r2CBc-9rlo4x>Fkt|O7+3; zq@ymU)pkv=oNlJr7z;Y)z&OYXf1swGotZ0nRsK`P@!r6PvGyb%`D2>9J@dpw-3&EH zNgX?BA_!vZbj?=z121^pQM-pm$)6#s>LHWra`}M%#_QXMpiG0U`MLr7HgD90{dRYs zuuS0)Ia|&zeRnPwygaT&S1*47O{?MmX&(H40oyM~u1v~EO)I7-4XyB2MQ(==VL@5D z!n3dXQxlPWw@vvf?nBdUzZ4TGb-0hqCg9r>gu@w$P_IJGX1p@#u=3G+BFXP%SU z*OEPI&&ICZS_as+N3Swc{i4*ZFA?!2I}p|C`bO6LYSO+&B)*scCAaGU!m#vT%0g*(>I^yc%6U z;wtAX`?QaYm}d%l5W2L*&oolf{2J!A-k0S7AEdMEb`*T1GOE=ynAl&kk9jxv*$Mo~ z`Z@xU`WEi8U=yGWr#+_e;obmLka)C@=gBetm8@PSw?@0*>}xqRt@Rby&AgV#_n^(D zYya}YNT@kHQc8TfmgOFnh9nQG_QtiaOxIXJ^zrFmz_;!sTYgiT@q;9p;t2Q4l!qvc zsP>#8UfQF9Hxuj4`D7voi5r77p#M)i5?tbVt#sdfkYQS(ezfB&X>9`XYSpA0$lbk6o zi+2qMu_8uOvt(+_p)1#am(NISroqXurexm9t)llSj4}OU8Kg$kfO~bV!*P{t(AK!n z&Uxw5)#NQdIO=lsVHD|%gi1uzpg@c=&`Y!v{NGbzNx7twd{@L<2k zliVbrgOafdxv39u6%MWsSD7<;PFwIZoky9~UqkL(8!rPIceIJ>U>hA1zg2N+7L4S!`3qo~C1~ucP%h`It-QTE^QKCC`&qSR(jLLBIRL+FY%i8fc zp1PsHj_>1iNqZoUZx0s{b8yPinC`BLmFP#_CLo`h36vW3xTnb6H=-*mLUnBVIICOh zj7bwHVOR<);FEFJCbqTGNy5WqX1U&K;)~oo+a~;Ckb$$eaOY?f8SH7A*e+8w%>U@# z+wwxE5z&|g+L@@k*7elTdKuLoeZ}MqOZ|LTQDh_+*vT>!Ie}xY?i}#2Zd>vcH zDR0sqbU=5XDE*^VBP7IX?e9`LHGvK)esyjd}|I=jw}Cidnx(Yf4? zt`K0e6-~$=S{nvgYi+H3?)QYdHbV{;9wbUf5P2MnYiuGADWUJ84m4p88SV)nd)s!6 zWvDePjpZR4*7Oco=4;AOcr96u4n7=ulIWU;glgF`)g?vgJe6lW5N)c4*;>=9-o=Dp zFy`?SrLLO89dq=UD#VJ#;Y+(w+d%I=7=`3D5V@{=7QG8Q(cvtc%a47S7#8Muw6#s* zX(QYEj$-O=?Xm5^F;RO&md@6vO}te4ft-KU7*~S7RVhRW3?L5SBSj9q zb48Q0DvAS`nV?cN5Fq{l`a5Nkaf3cMLCd4P#x;1mjJd^s&3FPQ~x*Em2lRuPWINJ`3@6rF|RAV41v zo?8emLnij?+h<4}q1cR&e?}cSpaW}gh)+0JJzp4YxYJyH831iS!mx=c6UrJxP+$%K zKzm`r2SsBPwfF%5Xm~m5AZjXdVOV4Z8utJt2m)ku|9(97%IS^xn08_>_?)TzF{qa9gskbdtO3;lobr;%kN{vnJ5mfz_}_&?(G-!p=i zemD;OusIgRgm=h8NusJXB70_p^rbyr#XpGIsgbZ`?j~@ESUT{fuwrAm%XZDP2TVD1 zB@WWP8F(Tu78O;#{8W-7ThN}OR5)znPCb$fif@`gZ(8q?0h>^bI49>Vhsf}L=KX$w z6p$lsZ>mTeiky$D#r1GRqr)uNW>r1YbAWEOUJ>y#(HFaHu_AjSPRpC2%-B45f3bRE z$d3i)153`MmN%PzARK@S59x;o{U2!iq2W$Rn=}rDkL2d8)3r|?0Du^9Ce(?+i!m|h zJ7HyX0XJ5d#FX*o5`}rGNeYFQ>`ryJ@ERY6BQk0Fa9hf^#n359{u9hRdk$abDY~8` zk&*04jT6F}X)IN|WTV7h{OeUJkNpGd?+!E^=5KFtQpU1|iDd~yr&Ir!`|DIEl#cYQ z=Mzv!@|0G{ZXYqwhfRU<3fKEX$icrT*^`m}M1RNQVlocy5dB5VT9(n9NL!Y%P=I2u z^Zg`l`UcN00yh!x39I@ExB*urni!KcC`vwwcSf2nT3O23pk9=VG&>|qX03%Jh7nG| zpoLWeVsO$Au!V{h~`U^8&NtrohgF>V{oUp}>vgyH=m z2zxb$%+UtG3IrHVtzitC6_Pv=A{F3?SBL3-n zv2v+%*8|Ju+06dC9ABBRmnU$AgF%~*YHmyV5k>y`l+XYY(LJ_BsU%U0(dU%Zf$kh? z37dEE5^sZ;9RY7=57^P(jwV1r!a%^nLjLQ70u%%!Gz>9DiB(sy-+oy~0(r@foINpnh*mBd;h{pG?;GjT za#<|TDZl#UZeyqGcI%;0+I*q;jQ#LH(gbnDIf(0X(Ih6lQ1q`QnmAHw%d!|91Oa{6 z2j}pgmSR}i{eoUV7Tol(3!-be@%HiAcALX)7bbyM2Oed@b>d{_L${^3ajL1No-x_45!!{ zBct*oeQ`VWqUqQ_V#{LjVx!EsivITesq0^l;DwF=(4;fsRTn25Klpxp^4*Fy-|hiY zV@sk%unN%8nS@rEphdO1##%H&6-V;u3yOdDbRIq&N`3;j`;xB}%jtq-VMF`3;|jC! z@)_Ky1KH-!Yg;V`P7bYuaj`ex#=#BwJaDEQr_qH)WNNUdb7pn@OH8xF@rWaD2`6*w>y?% z_>mr_l|uQW$%J;I^Wj7!OA_y-;*M&WlldP5XK1PV1tW!AJHxalSp3w^)ZbFC;to%S zog{t~g;2_xN$^^l+)RYb2$q&Pxk0@Ulonq@#`|z?qT#j>_~1XD>%pH|M2v4sB6;Ki z^~osH``Mdf2_qwcZ=JjC!1Y!4_Hy- z)aycNqR45ZGPA!RWw`~T!Mb~%vIbiWNQ67;?oRbd=0tvsBvw0iRR2L)xTrYLNI0?6 z%C2naw=2*}p|HS@V9vlhEv+$gNl|i#^#f!* z(W+NOhXeP-)GD+t6*P}nr(#E~Zy;4V8eceq*!ml*kHL#hzvrkjviH$#qXfx0!flgf zx1wV|6yi;jTyyj-x$~^xUb0&mq|j~$%q6hI(rYQ8{VDknLx$zDcK#={Q-qQf=OdIo zHFAEenLrSs4cRws@iwjH-h;$fIhQ$>Zt|t<2*TZFQy!tQkR;bj(Ss3A;t|87*DDUD z`%;}rE%{BDu(?4*Rb-jfj$g*26so{Gg)-|2SBEi}CjWbO? zJzqAEEaoHNM)}mumMz@jSrlUp=wdBm`X{kYIzgIldI@&z1l|nKBMK3Adx*nnyx_Xl zo6QsT3~4gM<=H%c8jBbgCXdj5gT-Q3v}bF3Okjfw7Eap z^Y6>;4}BTQ&xZ1rh+G{d!+qQ>mYv#4U3i)=qmaPhIu>%kwi#m@zZ_%kecb>^!{Q`c z-1X2Qa5bUaR>b>q^r|XQyr~UwVlsrGBQxt5_{Z@q4c2IZnBJ`^%b3AGF92<}qB^U| zRiBX}ol{4;%)B&Lf^E4&px0EJDEM18BTwP6+e8oS{${#?shUoCyw6qb_gnuyYkR>g z@G1}vyB_Oxu44Mpy*9?CIlnN}6oDD-SOGzja@9A8IIX_!`J^39Zf)lR#kE?W^^jNU zwx23d?0Co-wYsU=J9+!LRDN`vzjWs>AVnzzgO<{2g;Uj=#S|;@^tE=n1Lk5I{z~CE z@`C9gR0uACrQa%w=m@33fh0-;If7ZB5y{l9?rRY8+PQ>Z-mg#Cs>Fb5`B1XKQ$LKZ z>;|>9(yxTh$Y9_ChL_MUYL~(Ia~*wE2xV*@W&R3GWEb_(7*lEmvI;}qgmFmL%BO}{ zC)Fa-(pdzHA=FshCNFyjE0d83Lu>(6nEt2JgLxJ@J$f~CEuxbb@e$OQ0EL874`=9; z=WU0lZ%sk^=9W&PBrcR?pF`H#$Uhdh-!7K-L|TnBIW5nXHn1d!mjL>J5xExF(uqhc zw|lw*eB2H$0VgD3$9$H%e2tg)eqTdtuE;4}3Hlq0=%#r`h7I5mAkOw{&g?!QQkduE zUsQPE&)a>FRhMQm0ahcz0sEYEAR+>q%9`V7RS+89kc*9zWIm*;Ja2Ms3L*4A(kZJJ5OmnNcOFnt%M5w9_7u zX|5U~k46-551G7+9Xp zd^Yso_2h03G$oa-v+Xxrs&PNm?*3fC6K(&N5u*K~7E$6d7LeJg1u@w`Te1 zUmU{ZrHJ^5WdR004n{}rlB(Jt2WwWHe}JdElj~4R*<~)<~B5;D^N@SeVjMY_tA002B%FJw3nP24h zPjJ22OOZ0N+BIf14tZMfSJ7@7}NyYkd|xj(%2;m%ZbN>UFrOpKGd-~z`mDiec4psjSEeb1l2Gi zk)J*4drX7Trw!XT4F0!aYc#hF?^RronHXlk2A}xvP9DX38{FGOLY64~>-A;bg7Zke z)Ke^&2~xW{dbF^h2@#=#K&*(3Zl88J3d06vERk1KKmY4Lg1GnU*y7h^g|O!}Ize{X z2!#zgND@BSqReUmF^OODpH|CP2Ia6T=V;){BDQua+Cjh zK2NGA|0|Z;1&<=__j4a+5U7()Bm!C%im10Ms)f44QJY%;%8I5M8#6cq4{IGEWR0v{ z?JpqcXpJ}Nzg=_aE0V8qKII~;RR>J2?wKDGn)AlgbGwltkh56gWjq|sl{l7WUk71wDu4i2_yrgBc3^(AN$$$pmhc$3Y|TrM`pD+|rl zt|q4=D{22fpm_rzrqQH5>8rel$>#$fr*>YQhg+^D5A49%%{}Ju!pXQ7r6H^UR0k zsLXqMHenFePbm+2jm)8vNT_9y0G?~g8F$lrtv5Vp-g2HR{|j-J`xj@&dzw%D5>&Bo zY2uNVUp>9Tk&%DUa>FINSJY%FAUmaTXp=go>@@qvyqX^23U?4_WVO?WZz?Vul~q-5 zB%Yij14Fk{TUw4n6dc! zTZ1UKL)=NRjXC|6{X2=%lYqa<)SAJF!7bqoS-`0b-{at7qWNbqn}YVZ`I8O{Ym~~o z#(pI$L28+qy-^;bpGB{amSc`*O8wIQc#`(Fj&L)pEgULL7mMuCk&|I&YqKWPFmZ zV%*e_-l^koBZtUeOa$o*?cuuQaerjE-pX>r+>7Ooo%C^f|Lgp7?HH3l4%lzyEOD(Q zv314SR8sO+sj5-vj-djydnm~d|6P4oc%bmai&1+PAc!y zggLN9nXH?Rv=;H8F?Qzoba{XqH8*&x!qzB-@fj7ROe?X36F z$?n|1ufklG+4}JjlrM8Q_l*^iF$Z$j9_F5qKfPm4HuC60V*84>YpIbgtf1^EL*N6d zLnO&w>5N1y+JmgnvM(zUSSO(Swn)8@fwEoNMd5ze&JlRuc=LA8>B^WSgcDD0E5b#Y z7&-m|G#|7vo@gFr9~``v z^DK$M?Cn-mevMB~Y0FfTIFNV@sM@?LAYC$e0wLOotN!gI02;bQ@HR!g1_emW1Q(W%C2a(7Q8J&4BGyqdEgGQxW z%$XU9;v=%9rv~bMN~vH+w8F$uU^bNFt0POgY66zn49=8jNPmp4&eb zzWv-iBMV_*_ZRTyse89A0nber?8hN+wy{F<2z5JaMMj?vw9H?%qi%X$8D;+hu&&+n zs!%`)?m?nJ} z)DG)O3q0*U@rGBw(^w+Kz3N`HOGiVDmlsryZKyA@oss*MoLI`gnJVM`;;qIO?|@s$ z`U}A12$}~5H9Ck9$_qYcK-*Lk@PZJz0Bw9TNVd5o>aNm-=(=R7sOA+$kCD@W%Zgqlw)SDKay zsryyDnl?d*LDN#O!?AaT!?!g5*zJzi^aAGxbnu@|jTd1$N(pmv3nSaQhkvH7(?H5T zKK>*Q4TI&cY0Z~QHxAABTJDVaVD|-kv-_|EjW5r>8OE4PK*d-*To42iyBv%7)S~v56k^69!2pabn>v?jT$( z2U`5-EUrufX;&l``BHFgiB_wBtCzsDl5?fw?${XL||kMS8~2)R}Aa7~UqYZ=cLPE}hnwCt0wz z-1?_V8xc5^$^7mp`t2C$jT}>9Z&bajIeh0GRKCfYR_Ytj{$@$Fl;=`rSp5|up1|A# z85W>wBCv-+j=@p85?x^aO7a)LmDut+7d>aLz!Cqc3P-0?JHc3*`Pb}uGUw5}c~XGz z=)YWf`|QF3Zd@8YRvd#nY%!LaUdF4DG6G&E*8hNoT;mWf0cDy4OrIu87_&L&rJR@> z`)wuG*x4u%c-mcL%&y|v%hnx`#u2N58zIhLf^YS0Fa)38?blv!`;i{qPd%eu?ygzpMGgDUIhAy?M*IbQ;NVh1*Ou_VBwr?#c-6Lit-WlZ zx|M$q7^edrG*js)BW{nydXu)}VLwFevW3ovxy>yE&-PSH6RG}vSFhfj8fBaUj0GQ}08&saSB37-VgTsZW27=rnB z1o`~+k+(a-aw8q+vb*iIH8sLDk=f625*!ta24AAIMi-B%pLH&P^<|#2$F^-0b=`U+ z=#$==)4E>&O;X;^or|+vD5Zg5H>)VojoJ*)5F?f_A&r{8jhmPSoFL5JDKpxaSiAE6 z23_<8bW8G5{Nt7i=RDU2*6L+EjZ^5-J6NFS4?4Uos)(JErsxT^6+a)U!)?$P+D9H>@9HD@>UuIbAqpjAS?G?I;9 zf|XD8mo6%^t)&LbV6>dSiiYUoj(~W+dP-VRob2(VnfAxgY!6hW;whDyWK%wv_18^O zH7{be)Yit&d44yQurk7%D$9o-FEz&2<70~ld|V>FFQw<}t4`&5@(`nnuiy4`#5bq6 zB6w}~DHmm%7ncNnmeQ?=H>HD}iqYFC?~JZ+9dsv*l0;b=QtfCi%cL9wvRtZ$#wL~Q zl%*$R>QTljbMDkLw4<-?a)61Zc0aF!mQ9xH?*9VH*Gk?zN`n#q;d9FFmReE!v&)O# zOGLHR!j}hz$MzS3w|!G(D9#fQ+w<)fBj3v;;NM@^6YGwqG4*nL(8u?bBj)J8-PjKO z^s-Sr=60xzqclzJIiS>dMkVLj_m&_784C(*Jjdi3<o+Y6J97b<9#`mrtwtN=J{_ z8kSGl_##H@_Ou+!T4og4${J^vk8GUld8c@nK-T}z8C7}R$SU&01+ldwLZBQR!e^M)i0l;YJU>PZTT|$zW}fo z3)g5_oO8F{FE!q3?+YGn9u4ipYF4C>DOt2B;{=)kQS`j{?=ht1=(*_7k}$T>CMA?O z9yiPU6+U;DqOZEz7OyqD_;+2Ec@@y8UG;+SFG(-dFWR*vtMj+y0;SB4+SB*(7Bilt zT{OngC^8o_#J{a#Zi8AV7l|#~k0i4ffnpPp)e$(trD5%kDlW6Bv^UW=#Ob#P3g*5x zJp~gstwHiR*q!R^@@~%zQJbrmOmmU47mAvNEp^&!1ze*Qzg)Xtz(*%$jq0ro#&N2- zI=SMOeb|kyU4%tHECfG4f0TQ^`3u`Q=dyh@a)aAlcaaIBl% zX_;()rml75OB>z<*vsD6c8&KD&9|Pey{5{6j5Dt{xm zwbc6i=L-100GHw!V7(Wx*oQg0$lbo=aY=WZPW#3R;ZlzBb4)jpp_ti4J1v}8X;2LR z=JDR4+qUEVr!-~X+_ybb{?mGP)>gBLk$sWK>{(2}^r0(U&KiU6M#sk;BZQE_F4gAp zdhYPF13H4=id~a&GF0}Djjghp7kL@t|T4i}n}q)aozI*C#HP>DR~>O@(q ztS*YQk3BrveO+E`cn@T!LP$le%1s-Wtxs!Y1NhsmR_HTsupcp^V)KsOp$& zCY4s~mn7<(r|~6=fIVFj8?sas(nX4ERvl3@Qs!W58$RRWD;s@cYg$IuC|ZDCz6Py9z(5tW-1aGfmopCei#ts-IU8LXA+kSbM`gw7 zPb;Golr|P9Y%rq57MHOhQOJ|=*}~Jp*VdundSSDWzW_$30nkB*uj+Du1dF~;13Ery zvx#8Xmd@banOBS|R_q+gEEeHPJG5SZOT)~I4dCM>R8{xe2TZpY`Y@6{6*?{`$?Z>Q zsEti31_+xK(62)y%1*p~$f!mGi?Yq;!PxmOTEXn{(Q75~RpGJl1<^<#qT^ojkW;nR zd?kRUW}Qr9Pzj8eR@v7$$UTmfZ93K%*TNCo7v~3qCQ89l-yYF+eNm6NG7r!4j`-Hq zqos5jo#O}0qn;7j_B76xxX)#KWr5)>j^1GsBGzB1R#e;TEKmc;F+W92=uhfzlj?nv zL;4F?-7B)95cRpheU5wuD_`I9S7`kOIN%?!iXNwg!uY#1COCw`B5$Y(@trH67;y3s zvM9-35#M#E3`OY8UZ<;7l|u3xMHa4Rw8X-=u&91zUm4l@-bx+Dp)$> z&-mB(R+7s>8gA3#y3!^F9j8=y#K{DUX;-c_!nEhm_Fsxpq|erh=~UShJf6vHd|7Tp z{~|bF6cEnFWR9+*MKtUL!J`C7E)GrJWgM~C^jRo;?Vj6qw~0I>+=RW9g=CnH4y5et z{mLJ+jZ^`jD+6yw?+_*Pl9@2cF{jDeDPST$O`++zBB{)Z#aM()g;lD~`Fnb9oQ7?w z%E$sPDohf?B|CWOh&D3C_@B-QNXu%#fTBQYZIP&;=7A1&E2A?KIA50}k6u9>`sr)D zS4g*FDaFhklM^U1ToiDe8W8j7100N1)toxL!&7_}x~>{L>wI(k#$>Vi`B5_5E9+lA z2ln3hxvso{s&zQod@EF8zgPuI2|{Aw#!=p9yqC?9keGPZqPR?8xUS=$2ogXAPyoI+}<{}a}{F4fR z-IIoGAA_uhp^}>a8PJygb$9v|bjeSXkhi_1*0obR#{5B%c$^;E$OLRjZw`G=HQ!TV zr7=-Zz55opm8HxSCmETe$mOTCi4&~HN)%U+DQArs2KNdY;NR7=pUpdJFixW$bUlm7 z_v4hGV%80i=xBKSx>)%OP|CNfKIIR)+1!0Q=GRUrZVi$SZTJgVD#5%RQGD?&Ar;pU ziBrHmz2!zzECyVmU}BGBbvmXfqnD?Av>Gj2$yM`UR2%sVXq7R%?VnQlKwZM_ALHUE zdlz!b{QvOvmSJrzZ}cdqF0>SPD1_ir+@S@61Pz4XP=Y(bi=I+6K!9My3n91^w*tip z6eu3tDPEws^>Tjyd!PI1-Y4&#nd}cUd%t_nWZpF^Yii>|XaN%as?>`dH2nYjAzimB zK$FZry3eAalTy^{ndTG!(MLZHsYI30z;lnMzlXN&^fzzBQx%E zOK;B%KWII(TDJaTtU%%upqX)H5NNSFDK+p@1wAtHl!oV{W> z>qz&`KA#bY-yqC}KVd{&O1j{h>WPVB#CQ=Fx zN)oOSoznq*(~*i}M}y7H>XAcMUj?b-XoD^G%5NOBlw)h(apvaCE`K)Any5nlCRWXwQ)H3W#k88bFzG&EGB>&u-aj>SU%jE@vPh)VrrZr(( zN`uT^@mJnutTfyB2g=Z*%n-F*(TsI{)|WCPB-F!w3KZ@4 zl>F;Wir%7I0s1Dc-0R6upxBUoP5Jh3(pURi@DtIyX`v4H59*FKju4~rahdOxJo#w7M&W;_P;~pkw0h01xX>b4TFlQkL3De&tq+^^?SyN zxLtfCex}U-bCy~GzG4(#MD=KQB2#*m>LJA{XPGGdWR1j0`?v(@w)yBAh2p7g#pB*@ z+YToH>T{q8o#N?o7TI`=m(sVRR}#_4lkwRw;8gOVJwuJU!W7W|i2;q2{r6?HBayS^JMGSJk_RcOQRllgC!S4{BX0{X$}SsK>K@ny&PVS)EL_9w z)k~@m4T$X3Q@ojv=yF*W*kxAUbW2TLi$q|Y=j&FU7RA^L%aS59f4_F>Npv>-KCwk- zy9O6sqxRJgy}o^01$b94$LhiAHH)O+6uR%=cYk}Y{=i#S4+nog$4pw)tucle={fz9 zHn&$FIHpGzvL-2CE%sMb_(_ z(k%kZm0-+`OCbA?&yU*tH=RB5Jw(?_AnrMhKNph=QA3GCVJpI<>&K#jVZY?q2B3=) zq!P3d{$PHk1dRQ}Zy^0N)q% z?_X324#uMwS#)a8^=;wgJ_$UcEpl381^vBX_ZCaVg?nk@9k9Pfm zUc73%e*dr?cw0EH#(w$-f>uJi)@HySq>WxY(MAww zB%bM1of2K0k~NwVX~!pBz{P0A$7mx6Fj8>_X4JngwK^#a$p{F+!tmhDY$qPl8FzuY zew{D}m3wyF8@v)jMmfNYE;BpaH1}Gw)RvgRQPTfu^{(On48!m0USQz_w5eG)F9-gr z*|7P4EbtlQs(bde)GSx$LQ4MvxU?(rxG#J^TviVxtWj*Ah$mqe%Nh&uIOB@US%DZmb^OV7GpXuvQAL5WqCI{ zb~LPqG2~C0n1cm$X`mL+j2E7EMpC-NzCQ}%SU>LkT%3K<%;I{-K|dfRrP)>5xR|ID z$6wno;fp5&t=z8p+tRsZ2a~!sg>t+f1qVuc4%X`=89V>hcoS7ES**f!iI2fs!3AZl zGzx6&TthEjSY#)Da$R+ccVK4&mIi+%?(^RhQ_+@1G8Z@c8p6Cy*QU*{S=`e3nO^dI zUrR|U7Sk>?Ivh~)c=yHZ{DLDBQJLMWXSKNWc{gz1hv`fB=o*VRS1PcjnrLZZqhH<$ zmD&oPfboge7kE6|*TK(jMaK<9MpCFv3+__*U(-i_f9ecuaH)>FD6;nVEbaJe*HoZC z(WTHyY?A5tz*`#pEgnIcd|rZV7)CwBW7QC@Vb>i3%26{OhEs?8ortvtyPaO{89h65pOJ909 z9Lk$E=+_bg{?Vy4dP@J7fL3<(*R{XJ%0IXMqCztK{HW+u;dtZC0op2sm{8?`Ok($Z zUJr$y-soSsihln-33(0ZG=ISEcaM-^7SpM=+c`8R1@BTIejUZgNjm0Jg2Z=sE8FkS zfrcU)XCsmLsEBxeBhtXnC7BJMOCuZ7NsF?$5HMa&kg+@5rA7_bWxV%a6*fpwiSM>K z*^IgVn^k~HVuY9di$cfLA_Ctn4Gpx(JvDy8B5T8sC-38g?ypBSuEo-__Kic@6pcXL+6aRx(r0-rc3qanyHGfD35Y*B}d?aTP zl6xO7|8GwF0lac#`S5@A);lV!>^~Qe%Hi+DD0o&Y;oaZBQspKoq?PF;NR}zLGIkFp z?d)-kubYk%DTLEFB{)wK^B`%K;;SQ86__T{))=L6jrG${l7e`J9Ip%eCbPd&gaxh} z4kgYghk62Z5Z(LE>Rk3ZNy(v!82D)3A&v9pGwl&di(Le9KZJZ=)#0;=aR@?MXg|db z@?oJaVzQZeX^pwPOY9HxN((By#kJd9TLe-@80U{%Dl z=7T%W$1+;NOaZ2t|p_oD(N85s!|ie<(}0A;MjlLB%f&JUWoZx z3_b(BlxbnrtI&)@!N!e9{t~d~CN%1qR|r@fVG6TFcN&}h*YTf5NJ1+};wwH1O$^N5 zM9tG16CU}Z_!})Dur!599dCX*Q1SXMI7Qo#-0poYf`)~|N_%Y{``izupX6Y;Sp?H% zvLi;SvJCWtxT=wQ;eD+>mA&L2h!HcZdH`}V@M#ru`88rT6Pl5_smInD@*Y><#~kVG zz5<|s*sUP}q84rDHQWtWagX|1=JeEsLDvh2FRCOTZf07{)Kq!evqkv_M>decU}%)6 zi-G6=P#v(K`cd`}Z#YdZga$qT<>s{gb8RA06&hO4hA(Kg@>>u&hIFxK5M0nS|&6H>g< zAGvjjWV~T}olc-7)Mq>h*Ezt_v%5FIb)>Pqu3`v0mM>v<^kgusoB@u_VM^%JEfkXL zNt{^1AI0oy`z49S9ziFS?bmioHN<;!zGnNr z=I(e+mJx_lt(cvc)w4IH*cABb&u%w_R#c^-xWcpnlG=+4#1pziZ?5i5afy}hI`un?$rtug)H)8v{$3siZa-9s=L5dZuUC>LS z6M1qu>N)!y3#ZiV>~0>?szNT{Be5$_d-!Q$PNky0uF@JilgJ0e2l#i>daMow)-izw zu5+Uo;X)hpyV`E37)mGP6}bdDIn)+RI{pzxf^Jd}vs)bgTA&Gci;Aq~+tqMlNoeGB z&Q(hUD8T~O!A7=n-Ux+sU`nKAe*Ce2MrPB;F{JsL2!U=sn{6&G?uqIet zD4m_KHppA#$WI_y+z_N+%cTIed45*xf&OTsu4`|E%xFW9hj_9%G62=#R@g|cU6uF~ zdsRI*VL!5S+YUP)U-F4uMco!*8gi>xQpH5AVXBk!sZoy+F&>3lEAUFQaNwajg3QHgJGiB1 zE;PLi1c~f&9z=UJK3ex?h35GDdL)NZNeHc>AN{Evo%{*Kg3aWZ>mDoGV4u{uAYGBF zkJwh}&VAKdQmI`AqOX-Wq1smUzzvSaj;rtx&M@ir8)ZK2H(-WZc?S@SQk8fED9!!I zSk|9gYRE>^iZ$^H4*hyIG0UYcW*}f-o8PzGq+}>tS^Q2pF&1z#}2--bAa;+t9OwX6qv5>+Sq{_>_qJd=2`8K99e-n-3($&Xvg zK)jJ3$IbM}>J*SZFejcrBOf;ozzMT?h2Ik@RpjJd3D#K4i7V{7YTEU1nlqL#8A4W` zSvJRI5qE7@d0@mhhMPI*yDi5`BC#(0Cz20qADyj&GWi*;!c1Z(wUK9H<)8V_jm!F4$x40@z##l zGAehoiTD2`<#uKY9tFGqKL&m&c^#8#4gX;f^(Jo2C{pCENfaur=}F@V(G=!|Sr3N{32OHGPxbQ|)F}N6(Wf=58OhViZAx27bWD9h{PfiMx2m=BkhJ?lCp_gp0{t^(QI<@~2a70H8 z-mSeNJ$d`?SYbF+;A+xef*&SVamVG*7ApgT7p-bjof;aTfRsL_Dp6sIycnF*mkMWf zqV&bg#vWeq%5hWxIGy5#S^W+7u%OyVr*R&9N$7z}B?7Oj zPC{{#vY%86Xvt5_m|hGRt0-6+0O$6;bn)o14b3FelCHy}>Vi*2IDLaa8O}qgA9*RD zElP3_@79?(EMz7lU|+(`WAYOQ6TZjxuIqb8m)E-LI5aA1YH7qt^1nJY5y|TTPPpFe z0|D^d@1llLGCg4~%GTUeZM~mCxf&|U+5k!(WNZmgrlViHeombgTQT;R0FJdC;?wv` zaGOv%Gdt*V{g)s|ySj)g2Py!YL(LWW_U{;-J6@CBC|6WnJoAfjM}1qk9`$Z!8(diF zs|ocSx~@RrQcrJ4^8BaBtQu8CHKwW?d3m{Ly*Yub1@TCxe=xND>vt}fM?^SA{P@;6*W37Y zQX#2kBR8xiMqN3-{-CZU)aH^o#M>$bJ~NLQ07fJnw}cWQlqk_8t)*l5v{U}p>n-04 zu_9-WT#qF-&BljMcad??AAUC8y&hy|)Il!y){`PxPlc1ZI z4LHSUQ(zX8_{>6GZ`Z}d_SJWW!}s;3{X?aDD}vYb7YYJR(ayKDDTlR}KuNd?Tj8$U z3S7C$@1+f;v$kv?Wknvhqb-VoF|-zwWJE)-4wW+<4nxL3V^}21c&xEZ&Rn z+s0N&bQYq@lWlZ?>++drjABwILrtd=JNTUni}DuK|B5ZL@Z+1PPP{hKn-e;`Mp_vk z@3!78NiSdM`7(#Y>d@s<pZiS_xj48%c@Z1@h- zjsnpt30+wSI>UJiYd(Cn@!o2swSN81wdgvNaV*!7V+EIV-vXwR(2g69DsLtax)Ym~ z9g-KT7!W>VFL}ocV+i$}EAV7Wsxw}#@5b)PV>IzaG#(Q(tVQokFa<<#aE2jlE|ED2 z$KI7p>_x_WQW*kntr+()!^!algvP_W3dl-vHK`i;^&EpLG;<|8{tb-7@~bmR85&%X z8e-*_C@-WHFW|tfu3HAEzA^%?hy5Zp${Q6!es1Q6cF!d_kpHh~%tb_{p(w%(GU8ib zN$!(?NStdyUV^GJWQVTMK0j)EYB(=jVQ<{=D`Z9RfnPznjUAAkUeQ`)hG&IlbQkI4@wr)H|+#z>M`=FRsnrt(4=1RJMg7H~<-HAn#VDPIxgd0gf93SAWc zhY{0@%I$dGZcXqn`c@vIAMzm32L*&c2TH+~TbQ9{)oq@wymPeRn5Vz3>^c@r7T^Eo}4g< zAsxt0m*#aug++K~s@~*<_6btFPB%8K4-H9l{y;~^=p`pj6Uk^b-DAUeQOPNsq?%rs zTsy5d#(XXmY>DrpVOrINb4vj`tz zHx-P=d%`Pn#5(xfoVwX3^lRazuS?hZy&M$9GURHNR_h~uY;@)2WNh3Sk_C;txk|Vc zHaWCiozKKncquRiKzA_}!|Y?AMvHw5hpmNT*35MaM<5A^4Ejl9O{MO*0h=S2Vb7`= zoP0=5k05i(q4BaU+VO84MrIR}4c!aV-N>(z84Jdmz{7o$0B&}Q`bm1LZ&z_JgrEteDH)(a26#{VUd zYRF{oOCg+9@ikrZPyFRP#i7&lln6|;sncS2LecHQk~`4Ig*U_?ucubk+?5T_89!Zc z>1b*TB78GsDMOriA`k5$HN=yd-@=K>%?_FH1*ajK17tP8ZjGu5yFbtP$VNJ+AfuoO zJ)&AxBOvKze@>s<~Si@mJn#oaD< z|7d;6rV6m9mJPa!y82dn!(I68kXsX!(`8op`+&7v$7`%T=)unW!`i;d`~@EU4C%}f zlO|8@2)*4tDB=$@9n{`|(i&MaVXy&oVc_5aLv-e*GrtinLyk%G?jnLC{tnaCXrxx= zF^6k>a@DS!R({z7&ctp}*&OYo5prqGl<5mzW-({i}_Ubhv3l* zMa1Px{xoENx0t#Ehp~%J%=NFOc!wijG~Z$(%qk2$)vg`Qc^P@2UAwZ+0nP84Fw-9I ziX{&z?YreM>NWS7T+K>Wu*S)(nX0I+Y8^*9v*QtB&Lo~^cn^T4G6@7*8g5D$lAwp= zPtt_I5P7AMlDMNQz_sObMmu)TIx(NscM0Tk(?2RB1!&IT5}FB#8wLXX~d*=Y4Kh^5K~(;<++l z%D}F?bSt*#1a^d(_vgI4D)~7FZRORZ+{Rv)DAmbOt!+tL#R6?ay}1UYV6?6!>e$Mw zrWOg-ieBeg$Xn)w=1%VXCHQ3MV~nNr$1bMOUK|}j#|wCP`kg1TPw>&Q&5c45TAgBN zN)WD>9{|jJW)DGg1!Hq%oUVr1k*22VNGU11ow*eXcL+!r6=DkhOCZ?6A;Dky%w!r^ zHdO9B-mAC3Tq0VdIvyXL=)x+l6V*Jv!bS}~e033!a4YbzzyEl;hugIu}G+zw|aNTCnZoCZ=gh6kIrJ=+>~~xW9P?P&n)0P{nepHpaV;qEkDFF3EgWEvyHZRyb*q?pRUa2V^D3|g z;kj(U{h^498o**19gh}JRK`&QfJ3}Lwlgh$GrmK>c6r6Gp6@+sW}MMH&7NVr)NHH8 z-;M^=l38YJf=}|;>ZQ9 zu?g zq0#X<58waGq62qr{~r#0?>`*+`f0HRds4wFR#KIce7!YqNP5qJlSR;WA?Zr%~IH(`S)#-gdVAF)mC~)P z-QTlXoBM>qz$)47=O^_oTc4H@b^ezX@PPM<3HWql0p7Y-^EBOp+%Dsx&H2WGV{}$j zbD=zHW&>5)TD0{6ViY6eVCRxj*dRLP>rzI%xR@j`e!#-0SQ1@KmIj48PIX|D=4qhu5Sy?l1JNxLItW(C zYxTejbKsRL#m|On5E2A(fct71d333Yifv|bG>|&chP1IpnYz&S01=TpdVoj@OJ=+t zg`G8{8(*S){NcnEQC3{9f0P|Wc+1klRVfOUtVD~hhK0 zb$n%B9eE`{1jn1qR{PR84Mi#io~Dv5xp{u!7^XxYF&kwqewvr}e!$HO4l$rpL#{XS zNHN54A6(xO=FG(qk<}DVa|?uc0o3<>0!-#FdFzrF3|!h_HfCM$;J_PxP(v|Ck5GS& zpd!{#G=PRGmVRo;HKsZl_M?egXK%8WN~_yS#ktzoz*D@`AyE!8SIRxd+im8S zn!y5R0&7~}Zn0`(vxc+WH`;Uhqb%ix4&&jvYP$LHD~CloY-s&rA)d@wF1W8Z6NhDA zMzmzm+WNFqni(s#{^{!*A#1-bx#g%mS($*B-)Oh`23e3h4@Ik-K`h>d@flT+bn384(+DV zvm9L)q_lsdQQ8@=K}*<;=AY?Z0q{P4bOu1c=Sddh0Z_#2)UAe4rzn)yC&Y=|3IQY9=?WCZ~bj1R| zWdP5J6b&JV_?S@PnsQIsF+?->jAZ7=x303T&k;ABjjXB+5mWmEY>ovVv^*fM zi#~LVp-SpIn5$A($TFO#zYw!~BisICTST6ALM;QzKgvX}NSM_I>rp%7iPY3?rhp}} z24b{Fz1=|7je-rE;}go=tP_>sNRd;8ltZ9ZZM$<}Ct3h0&*)-Ss!6`DzS=T8CSNm8 zZ4=B7jTm64S-g|8YmgF*5hg{u23OM^YynQo;{ycvl&j1$&S|1~U8Q7{k|k1Cj7Aj6 zx6%9>9y{(Z<*Vns&~wJfgbh};STI69aY3l%2csvc3$PnNgTx-F%V?#IIUX|rm$JNO z=X$ZLYWuy{DyoeMW0f(|fw{B-p)7kmie+gXm z1%p!Al3=Tzk##;kE``xhFE&npnp$Cy_FNV;t1tGDjpMVxBnwz?7n)JbKEk)HAd;YT z0@<3#AKo6bh`M0ODi(Fhw=(N$nj7ToJAKo-I;N&?Vd$t&>RZ}%n(ISWQ2e9$Qq5&d z;n3T(^&!PP)2erlfIcv0E_FG1KN>y3)&?9kiuEpmgl>A>bt&*UgUE%oP-W(%w^{rA z4eHl!5C%-^2+K?!WOFh}w!d3%XDeCLQbuUlD*!NaRT{lc5T{&WQmq0dMCPP;BYh|9 z%iUTLZkm9;dUD0;re2X=)L9krUNdmp%eEDS%4S)eQ;{{7=iR8 z4lUmpMqc9JwJ@c>1ga1-1fc{o#`>d5LE&T&EcdKx%r|ObP|YuLtm>>9uH`hJS#V?< z#O8FJS$~(ah9B7l0R>c)f3g=I$dg(4JtK~-lqI5;q##SVy=l88oetox9lhK(RUCy4 z9m{C^f1C1<;PUN#4+q6OhgjTPFE9Iknh||p+R9DWZybnhkb4B6DpW1+Pv7YO zlEiG9#>?Jze3m|^E;B_5AxW00zjvT5wp#8|oln(W!ayZG)I~J*{YL8Pd)+Ouo#n=$ zk1`VW=al;@PF2Kt5XC-3>$6e#!sX8i2_e-bt$1v1D#vf-#E5l-wNz%8Y}e`m=g=&J zpjzuw#X4Z2Q8dQy$ofjc$EL+&Z5H#Fz-DXgOkj1SSM$Zd_0t30+((`4r<6EErGLif z2{Zi?5U;NcI@fe9Z%kZ@yJje?jrjI~C-H>!!bYyv6qiB{OBhm3i%izM+n*@k{s6k1}$R#DpcOY4)STM0*wl%q4v^#ZiJ#g7qmn%m5$5!T3-=rYa0bH z5+9}h=f0F+DH^2Ep3Ob<*6rX9;?)lMmVAu^8Yto{BwRSQD<<6ZroG+~H7mOtzlx2r z^rvl!XwL!pBRP00?(Xs6Xgc2vJu+-u@^kpkXM}Bb)Q-Xp@tnq<#-47vihO}zQ?NwJ z>m-^1;a~A_ePw;`NWx{O8Nm4c>sZc87aZ-fes6MZh+7((=dKIa*V&ZwWTLw?M}X^pOHC{k{c(? zSD$MYVc4o9${4hpN_RpO2-+f>k!%C>FFtC+PZ%e2Q1D9JJ#Iw}NlXjBRr(o_uJuRX zk;NImWn$c}QUtc@Ki3Mng4(!5;4$%bjyTCTS0{Nw3paeQ1?w}>plQK@^B5WEj;u?HjCNtsT%H#nqqQEt}cdn8=WPO)(|b50Gp6%Q=%-%46{K!%7`h z9orMt4^-OPp6ApJNB+l6!b}6J<%~f^d~>Q-fUfk)<<^JDAU4NcBgU{Q8HVsr^Uphf zZ`;^6kU5mDmJ0=@UT5bAv2ERX)8^>^d;wZsaPi>`WK`tbpg;5%?(}i)A;t@T4ifIA zy`1J&&rJ3*z}r}TUd9Xu!t_oRTw6!xQZnE(st;6tc?rtdxcOW@K!cvd;x zd*{&jr0)faN)GVAR0zcXvup}O)5GUNzZGL}ZxcP@!g61Y+qHx~_u#GiwdEI85OHg? z6*`&U`eXx~_yKq(NRwzz1;j0%rKt@_+|ryfX)+2o`bF;h1XUlJztD(WGAU5}++^*Q zTlQ+e9-n!5esO!szov{Ut^^_CK6_vL$91q26do4%`fg=YduU7gbh3&keCQJJ*mSVrwIjWrnSPd)dpsQ=r z(Waf<`My+@Ju~P0t0>jK1no%Tq#{yOW|Lo3eo$r1tEd#LAG&K*nqh73V6Xr;onRcF zxGpVo{ZgRg1T-D2lT?^z)jZF_rWE2^$#$+UB{XD3Pb8;aStcLr-F(47S}K32B_^30QFBWAaLPl@6OS=ldt zRso%Ex+cHHwc&BB8`HxPW{pYQT6jaLfKBac7lC~N)rc=Fnm6ggWC*iKmblqiTTE8)Hied$BSLW7#Vk6Qe z|8RP0b~k5ez6+*~6lJSS0L^m;wX?d%uWuqt^wv}taY#ON%PNkrW?Q*q98;-)v9@Em z1BB!GF1UVl?Irkf_O|c)J!h*+^ zmSYKHUK)^;SsD^4#-o&Y-b7bUmzPE$BcJi+e@^DiQo)JdkXA`VGson)8 zjE~NK`M(M~#VPqwPT|gE&}ew3Tl50+o2w@(EgOozpk6aTimY8Xt)*US^?jZcm75mg zT?KR<$n$KA;lHkUg8M$AkX-h2u{6;(BY;@Zc6;8BXpBQFm68zC{+EE~1IZSL9ZYqE zUYo>W`py-;ma_35Dt-mOI`>Bj^c){?>o&4BD1AC;nXeLL1x}9;7hjU;20k)?&am7> zN`=b0iJeHJW|}O4RJk8+pSc5q=aHOJo+*!#?JyCMgkydu{>QNf?k6_wit#=1M%!H4 zxgK+%Z$*jN5_h00Qi|mm1`ppsUBBG2B0g#w57|8HPvO5{{#9n28rh0JBU{BJ6eSUQ)w9i=Y>_ zo~I}5tPwW-JL<08(0{k=z*(&QLpor~-I&kU@gJd_XBPZl9J!xKmhm|2*=D`OoSJHS zE^UO@6bzH4aW?&yOB7I&dGEDET7knpw)ncjJ?e!iLpiYSp$wB2itGUg4chCMEm<+7pLL3`ofTR79^*ZIDUJg`=c zu&)JAxrdRmNwpGhdTeoCkDZ|Lt*TWbAIs-RAGpMh3{g(1RrfpJSR+Ia7WU zr&TwWmJ4a!^VIBkfludo@D;Xq%Eay2)RPJOy^#Lah$}O6uaEj=%M7uuxTRBhO43O1 zzP=uB?JOO@u2Tx?ZAdjmVC!!9wq5(4M@E@op)Y@N)dJ;<4h?!v(l<2LuUuU85eY^Ne$au`lXdQpyc`}x*rMKu5l|C)T=wT+ULHpk|8=1_UfPC z)VuGx{#eh3ek$wd^`!(PfA^Ku8|#uwaD=GYJ;Av&UbpaRSoS@CjRYl_8M&bxyVr<- zQB8|{H$8RWdAoY3Yezcy%PIKC1&>XNdA$t#4|P9v<-%=9k0GJ&+fj{)!1~O*qAz$1 z>ZcmCroUhBQ?BBA-p{gD+FP)^HH*L-tsV)`FKKLXjKv~I24^ZD3@08x=c@if* z^>uzkfqQgZ3T|IGP2)?}@f(;lz{@`ScK7XexdOHZjpUN5p)K^vO4)Z%dG*FUEoUlJVc_OYRjh`E}BNbj8UiH@*-1 zH>Ok|txLMONtb-2A74A~aa{VuTYFc$(P>T3Yn^4%5y)DjhE6OfGmiN}G6#A!S$6iI zFJn%~NQz@Rrf)&M^i8SI)8!_qH_c~!Ipsi?nOrJ;^O0P*m^3ijZra)!D zD4mf)R!@9wbQzfXvUU>3Jjvwe$Ic_)77sqS3*OR ziys@`Pf4D@y1F?2AWTe)nFts^T__QH|1AF{CK;-vns|~Pk87Stgt?nDUNyYSGFyKuI484AW@%I&++F$l4Fjf2L z$(bD(yw0cF`)m#K@vyGi&CsC?#IvF*KSx6x-%IH7%qQXK<^0d`zXYKTY)?)w^c*Ru z5=G|7%9sqNcuW9CWxrwT&3E=FnOEqx4p^f@AVSedI+x0%7Q3=_M+UDUkMlO1c+uo# zl1qd-h_h5Q2LUdW9(xv2y|JQOnJBQZT@C*9i;L{aUf&-izGrRoik!CrF-s4Ih6+Cc zsO>iFYHR_1=sedqUTIehl@b_xJpMDVo`Z^i>!1Y1db%_U@hkCgR;Lk1^q1!P50_0o zOXE_gtnH9^Z&FYGyb2rh>BncK?!zxaFz0XyCg?9!Z?hF7vyzrU;cV7R87teu$FKP1 zp-?LYPh(DdggV0MWyDJ^J_GMmWx%@iZ<;C5p&Wg3sf;FA5%#obDPoVMO`2I=vB` zf95@4p9{KhU&?mAq|i?kyX)^XtggPjZ&=(LGdKH<)6GoBFgjUgyWi5ugrVA(ECuse zMRi^X^6e|7AljR4fjloHhHSnbVw$D= zMFKe?)j|^i=NPqg)h*cN9`AiZk%G#M+&9HlnJlbZdyT*Gd6D^Bu2|iCHXL@=xGw+3 zBr9Z+j_x5v9We3STW2-)j!7g>o9RtcNb56!FQRXt(feM;=fZgz{S3O1N-qv(x=kUI zCUmQlsHJ>!1@1=F+@2cYwzcFnjK6RSyk_Ag60=H+`lrmPm7}Tn!@6hRG`LZnd!%ly zptj+pKoE?d`T0`b&2y&a-_L>w$jTVRh<=%^YlS{R0iFQi(-=3@bX)c zZ*{-^z?cYRgmr|6j4olMvU_ZENrOdrJ+Yt|?;xt@$zzifi8kN!1cXGk&bjQrHnGje zivKf17EE2^%wi&0U&ul-DsulrHnsR(Ku_&jTai}*>@Fa!%^bR5aZy*}cJj{bH&<;m zUs{zY=&Cjtc@KME%z4inE4nMq;aMV-$pzZ1}^Z`^V6OZL`%{(zH`(Y0rB$cMf{W^P zMymI?snHN$8bA_UGZbG+HZxNqJl&O3-NP4XKW6Qi!WWOMQhC1P`qA4QKyF=6^8(PEmc78Q z`~$Hwcq>mX#geB(e*|^@vEgkn{O5e)74#=$agB-q2c0bUu?S<3rP zCq<;C0m%lU)2|&W^PQMV|9pcL%qGn^8s#L%JTEK!qSf-X`SHDs3udp@+WF!U((jhS zElTWr!P-RK4lV4xhI;m4yH)X|6>#@~@n{WE!`+EGb_NqedW*3eQmU7ZQHcnBifRoHdxd`IX?fv%IwJyVN%j5W;U zf9a1VbWoW6sZ#Qkm#oVoE%6!Hx29a-VLnc5S5^CHAs2Sy z#EUpzeWBALKKkjMl!9Jq35z#K)Q9X7>%Y1KsXr$SLU+}l!lg67>GaX+&h~ve;=e zVr?0i{jP1Pga^g-<^xIH&7_tI4;B4+QFkj3A<7v^F<9tm4e% zFRR!Y!~gDQj3UncA%^^m7>^Ap1iWud*RgBbFN#$ORBe)Y?4iekS1kg}!eX|aYo?rq z^-|&S=Na-a3IlsVG7au(@ng@K<>0zE(-~)`&d~2aeJ7(0^0EA0G=`bzDx_~^m#3a#Xlo!f&rE>0&KlZTm$X=F z1-cF*b4-{@E*BKN_De)P)Zj;TJH~F${1~f?c2J8Oph`qjqq<3%KgmB3#s(6f@ILyt z9)zFh-f!3 z$dFxCHST&<|0d*8P`B5xa#P*>ByWbSdF)ILCtm-$E3Dx@^9xsdQLcIop5g^{ee^4` zjSA^{|9mz!<}JQ2#}nv)e|WbyJ(^VM{)TqT&|M_Z%r+k#M4&sX&?DTXhouxt(J9Dd z51omm%MA(t$1BgV@J>OwCZp%C9wm_(8Xnfz+iXQX^|gz=IN0@9rQ zqP_`f_!*T)`JXY2eJ&rBgq2-QY$9s$!A0)a7w^K$i|Y|PX9FCa&3hN++M9DzQluJ* z(|H(`LE-3R+sb?*CZ(GdBV&}0ld)<927+?NvtS~y?Ag!F?cRrIN$D+%i|F<02|a^j zYf~muvfcJ4Y~1aqsc}MO5>WV6g!PM##3wJ9-0dyPWJv1XnEC;_&)p*bFXG+;D6Zz) z7sW|%f(9qJOK^908*G3C3B%y-?i$>JyAAH{!QEYhI|K>In|$B--*ax&eXr_Oy{cC= zGkbOK)v{O5Zdt4Qx85_=!n+B@1G2IYdg}Bt0X4Jd0-&RE4{v4OQf1dU8sF_%j=)&K zkE-9A?rh!Y9e8W{^L>68+eph$VJg7Ui7JpdHv^|v=^i+oSSMIhb?u?THD#_|pR^vf0G_XP zp}<=sgvphQE;9Q-z)|()!C6O62w!!xE#l{biXiw#HbF{Y`S0xU#|qCF(1iGxhLn^p zi-rbX#tgc?rOm#UQd%$PfEYF^S1z(-voticDjgura)>d&TpxkSI-qUq&0V>G*GJW% zSOq%a4l3dm6=`XOtfovwV{9%8IM`zi5t+tCUELo*pEpP9t5V8!E6OmIHYE1zc}?S2 zOWk^3J7%jiM1o{l&%WY-x$bVXzH&`(<_?A5c^47-vbuL@MPn>S zd=urXxnjSgZ#w^6sR`M~-^uV7+-=yomB>TnSdJ=v#4!}(8j-l#HYjlM9+UN`jG3gF zR9`)4NHay~I9UQIC%k0~<2m{&J336_O8c$X_8yD%JORv6P&EH5F(i9_rI!mGgfNYg z_(YXwnH&>wUNB+L_*PG$Y0>D+6^H`+#lv*RXRSmZiVH4{dtIaO$=%}m#Y2Q7@OvdQ!$mIfJEgbH5uJ6avVsgzez!^CR65Pf*Lu1snUDKdRv zbh<~PvVePzQ?e3$p=GCMk}WGGau!8zBVl$2wrl5G!MXM#5p9rSfuFKkxA&qzKvb

n`VAd9%D@3(VYf`i4sDrhVthPkZe~=-%|v1J`l*H@pE(t@L05J~Z21oTvlq z)Y`$m_M{0;xe>|LL(6OfuTN?k9+1}6-jqN~yk5(c+ZO}lLctkR8_mW^+lujyg8B+8 z-gEpcQjYU5Jfn&1Xgn%~{KhE5Yp%A#m;|l+U9DKNlo5z&6@%?Mr)tK6P0D2dP1D{h zPI0Ziq0w8@s14UVgbk+6TE^H7TP%dy*gJyCc~zZqpZS?MI*i?65P1DDIuAw zd4z@4{BjxOj<*&LiR4R1w4DtO)-eT`eMcHbooE;CPQiBkAH3*l*VoC6X-H%QG4;Dv ziW6k52oB}~qZFz3**`&K6dIH)J+OzV%-|i$Bj2;wa`@Z9IPn!gq1dT#$@_32S=Ax2 zWj09jjC+NwL!6v%Arp|%(DHkBeTI@Gxd}s%0wpuDvZDq(2b``F+X#zllh0H z$D}i7SCb`?D_25idJ5YH0SP%`G3B5g5W2D}#mzam*&DZqHXdXf|Hxf9yX&2j3L+wv z$$=w6#UMFhGJF1$+L=nZ@#H z^1fmlDTAn)Bsl|!b)K8RMj9GPc-Lh4nX>OJ{hM29PlMiGE2YcI$8zhc25HL9U$kp$|$QtTsEzza ziMU;6A?(e#GFW5!n-?bldGS_J`VB*mK@rS_$rT;))whe%Viv;BIR@VM_IW)NbA2Dx zy!|0$@6saGTx;47pfmqysy{F%oNWz%V9>1Txz-ACx%UxCvr@wNKJMTjhMY=y1RiPesD?Cxll+TBCxoStrDbdXdoB zXbUWTbB-8f2gVb@-C1Eph}G`Z7OHIi4rDK=Ae17*$+)*7Awk@K*M_!q8?XIok}!c zLvb!x^Tc?OHUCt$zq)xk)Di60DX*H5Jvo`S$&KM8-aZ|)(U+KtdDqA8XQ>!Lw8~x> zeiaSDp!i9uW2bBg#JBcc!lZ3W&I2V9jgB`4+mS^!oJ#wj1O5={-}kwCBPM>9ygAB%{oj9y zMzgn*V}un7nf;l*Kyfi>hWu^lTlfpn8x4yuCe%7}FUkqbz!9TiqP{*E3*Z@*@wEbv zI|R%x+kP{NE5NIlbm1gJmV5Zu_rl3u_4jT^)DJpBl&X)^588sFM1Yo&6X5krgmCf} zc}wC7;6)VR8>*@?9PGQRYA!3J(uo_>G1uYnzQpDGkVUfL<;BLye}1-33{U@8@bm&2 z{O9g}$Ndee*kssHWiYzP-Cd8FH>@={4cKlCz~bE2HVU?+1HGuvwPIfvMM=?g;xH)>MmicXWb7t@QT4L+!$ zrkzd=0!V-VB}taB$WR=x?Hk_;h`E;UgD&Z!47oT>d`B+GvqyjY zfpMa%Yv1|F>BSh`#Q&<}A)G90ps1?!&WkAr%1|`bb*TC!CM6atr*_xt0@={1_6j3p zOSC0*1$g@s;gkIThNSKww>%5qb6FyewZRPb3gc`jkFVMn!8qN^D4^eb`p#sr{Big`LjRi60NQcwml|#{{ zre;CcIW2Vpw}Q*#mVS8{N%9C&;PxMw+%E-@zO~1@)>m*WHt{j>=MZqf&e_L4{8o+z z4OuPJA%E|}C0D@}b$x=ig2|$qvEGMPa#HSeRM3Y$xS!3TA;xY%#gQvS?;4_hUB1OIY&lLENuI_B z(f04$DUc7Blo{@lrzcuMLoGX0a(iPR%wx*YvKYEp;-Z~E%+gx5WIju(*wcPnkd$EH zt}(^TD`nESY|QGg)s{_D6tEEo>NmttTh|PMz>P4K6UXdy=e@V6=P0V$DZ?}??^P%gHp!0W_T8!e#m2qx&&WySTxCBNc_;=kFmfWZ)So9mt}tZNVc z0A4=L#m{K(DF7uR|6;*u^jt7t_bStWvvH#D_MJ3_p2$7pCoDBYC?}FPcQIjTqY5kr z8F8JSN{x#^9tOT&b^@(udijyYotj<=Zazw$l8*}aXDQAvN3^hRbgJFWIcfJ0EqUbI z93870K6ipvn?ZAz>l8fNX?{cV^d#EOUr5S>hMWhB_|Gw+Q+jWxjwnoGSg)Jd6BGOm z<2nDv%7;!I)EG~Y~ihvLoG8c%pex&!^BlwtnC?HPYrcJj>i_oAJqTAES6x@uNK)P zDiIUsjGX%eScbX~KiLLj z>K~W`d-L)sjycd;K6>oqR}xGe)S$c7zSrz+w4xbb!#O%|v`%Q#0M)1Pc6r?+SsBpv zAb~aRlCOM%z6E;L%|<8NW0w4)@AvwCuvfdGHHp90*X9reD8^rBkeoo$^aza?$>#41 zD8RD2jCv^lEur7j!M@(!s6GIW1Y6sO`9bjd8 z0tQxr1u9?}=h!Ey1v%Yu={Mw%20?9(z1ml6Uu3VXH$J9MvMqU9?M96wuR7(a_C=yb z)S%|As^SK2Te?GTnBh&qAggv$LV*-mqea{|kwo`YWUR$N)D$5(6T{kI%>%skkcZ54^$tC z6(k+%czv!zS)yomo$dm%Y!TdNrz4b^6`H>HCeA7?)4?5o{0FEZqsRjBrdeW^oj*aIZZg8bD`M5ELRO1$;r^<$2z=lgv(zuoRYecc zH`ZxAcVIR5CB_g0l|uLIe*u3}3TU=PSCJ$2LvWD1MaFWfB@(}~f@CZt_xzYHs1I{3 ztI%0cglcX`zlp**@XGg!=oLol#!=3=WaQx(vZY*(5FV`*h94rWZbv_W`S4R`LS@iw zFHuI{CMX)5!(9-RP9;_8gG_7R?xTfTpqn<63p5!Ay19NG&bdYU-3X4wPpOSV$8!*J z9gTeW;i;pDsK&0F=Zp#0AduJKBIy=A2ZZ5m!%-yJA$1Az*OX?ktmFFR(juhp=z6{U zyGazj^D&rU>35HlGv@s2Gtl9vjRW{3;1{nRdr6xr1 zGsp4Xj%tZWz#ze-Dh33$-4lPkIWOq0aF2@-t-fu^iM5;5wOmqIM>wUkbz|ldy?;g#EGlc>-4zdX&)}X4-h!7OMksHbj)*RZ08dlFyC5 zdjHcvP?KF~x{|E`JL`{nD=#4TlFZryTwcFt64XEdudrkWYt~2 zffnud6qcY_5Z}CfhN|Fo=2OR*YB_{p`a>!#Dt8-IrbKSDa9SSCF$`$B$!28hW*9s^ z6Q%g3e#@OQhn|bFy8Lt!RF-?Bks8=@!`g60;u=@zR`j{T=kscn4zoSVm)h6zkFNL@ z^unMzP3@JKLCcaP^aUHs!uZs}N8a(zqVKw~JKP-pdCgjO>c8I-oAq5SR}Hj@y)cPW zp($Gi1HZnsDdSl(8Kj2g>Hk37+PKheKh3DuTcO&db7JkzEz6sdx!Dx?r={&d8AByy z-+!dsM{G539hy7z&kO=OFdC`V@#Su2Z8;t{mnG`Lv-`eJTZoaXQox|#fi{p$>g*p=+(U1M|xwd z+f{NuTg-%j-;@N#XVUv=<0@lVDcADiiqr8@8~Czx%tgcRVr-fzI3bDuNY+rX~v$YFRis7J>{;#=id_5zbI>itRne4BKA z_+KqJ%d1F`)2;Xtd>QL~2>*`i?wQ=FsEV;MS>__B<|+IpZVUOi~8(Sn5YW?|2LrKBQ@6G1T@F=YTx= z(mq#KH(;67kIqW(1R#@^%A&<97Jq0qLfsM%h@3 zTdI^HH)Y1&(6}%fTaM<{-%g|`NF3Mus!pu#^`=`Vkb|WN=E~|dJNb)sQo{Z~yA10> zEML%q7SiE$|1B~>2!G=Dy-;S$9sszzJV8;g`ZUE@;N0P8O>0OTkyRjFy*OkmS$$4G zAtPbd_43tY;gM+g>yEqJY5v8!CYlDHo`1^^fc7vR{9b_xOgckmO|PnD)=y(fm*pW| zaL4(HgvRnNhSTzlk+LOt?y9r^TR#iFY(pQmgO{#n`OeygVHi}ua^`B$i<;wc6X0uMnOso zR9?&MYX&8DMTTC2E@B-UX?gao6SiJ~2U^-h-&uJ%v{1>Ntzq!YpLw47N>0hLDMr$L z3h*X|R~e?93Awa()v>NL(~ut_bh3MDMiB`SNYnuMJwqDn+U2vFE_!m}1Dd69s(~u} zp6QFB*mEP-_B-fl!|=V7(?*plSIC>>uZ?jHGSs}TpThrpPDi5deSj++OB0hv5wf16 z`VFsYR+j%Pm3yk7XMaCqETQeF$Z$t{eba9p+sl#f=@Hr=guCRwxNHsS411SFU^c;QOVJwlNL2(kvnId5f>(B;jI+AO zlc;i>st~SkWKi@yKz&Ea<=e5T@3dI%X?56#cC%0=`gMEs_jK25xnTeB@sstnNSDy> z2$h}A-8kA-F?F(eiQ8KX!j$@kuU=>4IY;FVVokLojYth|jzf;u{NqdRwU|P=%Q?{$ z;wNpMvJ#{0E!86~K^rl9Isf~@5RyR_d~tkZW$3@S?08&+>oZ5?B}5u_uhQuy8gjW9 zcr_PZOn4)~Vch1_sk*ou5p(t~F_ufKL4nH4abdw-Wx6%A0N>hAU71Cwd8TnKmXa@( zK;|uLdox}IwhIzzWw7x&EFQ*Yrd|QOWoN`OAmf!{dAqV(X6v6-;|1YM-jcFy`-f-} z{9e=kw^09!^SHAH=cS-HinF>n?oOs8!B)0&t~7fT-Wr;(g#Fb zvtmZ`HuW=CvTpJlk5~E02n%^L-2Is53u-1RcwgT$I;R(%_xicEc16ZJMN<@|`twS_ z(HCWa>Cq?nUt?`6OaDY=-4=+go6<9s@^zp%1|m zm`gRiQ7k8F?ca+yP#VK)kR^EBd*UXu)4d}i=lie5&EPRiY-Aq`8Y-^QSW15u@Wg0o}VWw2!yi1{6DR4|@5q=hPcu`JUejn`2bfJ%nq;je9$uj)S?+ zaCtp1&{>F?N#0XXWa|uHW+U~(kRX2aFTUa?h!=bVhIM6!V(9$pJL#o&S8L5V{x;hF ztDl84spPz4gd#Cda12Kb)*QwNxM-yj&C>h)d0}6%;%aR;1yVg3x{G)gtReA`3MxSX zRpKLtIPy2d5aG_>$=b2w4tnaEm~Raug)E-0O#;J^I0X;)A-2Ne;8hv^sK)gGzHlQSme zXzBYI1(0!5T=_UAi&+g1$pwC}KP~ZxO}37UxJ-V!EpE~ORG}<`2uxWrk8jT+xKlP| zah0htLr^5v{`v=INORk{iI(FKGBYHuDI%{&;RUJ~wr!FG*EKOorS?-}lmfEAHA`h< zS<4-&h8KgoozV$Wru~QN1-RM7L7ryH5J*%JjZ#In|561rovZ2>9gFm{?7)jvb zHGLvT7dCmKK{QLqDydtBF9leb7@i-Tp}wQn;W_w=Tnr0P&fp2U+hwG*s;5xvjhoo(B*KYO`TctKfX zI;pWihXVT;N73}2`{$^&FEcqAsy%dbfvwc~JTT{(_9Z_qHNsblB3iYB-Eqz1*Gstx zz}L06m_+s*r!nuVHi9D`D-Jd4#S5HFS=Rz`@1GJ4oHSm<=byUjyx+`Dc^iM|G_L0K z;1O&Vo9aE)SY%4GYIbcD1uEzaTkH!2tZeGPrzo)UvXEr6Uy>Z_KxADkpB%17UT1Y# zudm+!MlzwZw7q=+DC^=Ti3z+@F?9jOixgfjC)>NMqNj2Ff#J+)K*Ea3=XsAmFuLX6r#Ey2S@yONXACv1Ru|BvNEIBGpde4} zFqVEaQLdh^a#q^`(6fNI>1HWuX*-#QS13QedWf~Ilc}*fVJ4w|lYZ#?f$dY%I$&(g zO#KP!*ykeVy1dPDRlx8s4~4!J#>{|OhK?lR#FPu%Wmeo+^J4q)=hvOC#w`PaOSF5) zvu?M$U6tGH;7h*`xGP0;llT;PTl?htyeP!-iTn@Dr|iZpFy=p;%iZo@s`#4>%*bVWVBRJrZ;zKjWjyYVqM^-h70HNH1f`j>-2KT|xHe3{8R>KEIuOiGP2|d82E-~IS6Q5X%iUC+R$Ms}GmWoG*yir~UA^*8xNqK+can2MK6~U& zmiAnfu@sL4{Hf#wD?-U@0JE}^#B&B}9FfzHi^^bgAJtJ6fiFl6S_$eO zB*i;NLuK8km)v4ZhwYx-ezSRgG5%)^<(H)30mwbBo|DBdlznpHH^M||7$XZgh(>Hi ztu3S4yR%)p6r#M3J0P#u_gj0E7+_@IYZZAwSn8=Mvh-h59Z~O@tIqq(y0FwSuj;fW zrZ4nH37zmP-bTJe0jhssk~Q$5=Wj|Nk&!M}dyxq^PZ|=lanz|mHdhntIj0#vDcF7$ zAtI$s*qv+gVUqN>Z`;Nk;l0 zRXc>1xq&oJzGwW1Wb9^NvjRXU`20kd=q~SqGa=$kAC^+*PG@{d{TG#CA zE@R7Ii-f2}qhIq?NWuV24ewg38#91X=Q1u55W2|%qCm7Ij4-XH2g0@E_C0m!&Pm{k zvODOf{OPnTPzm9vFC*x$spWftHJ9ma~IhVdq_&N zbMb-qdnV+UZkj%-k|zLiq$yW*^|fc<4)oOOKBh#o^C;BFZwf&F-9+W2!8VS!x9%@xg5A-p&~mR!l)4Mi?ru#`GNV4^$*PZ0`er@0_YgUN)bld4ddnY z2j<;q6#OOHf8=wIxTAn#QB;7$zmw3mj&!#(>N=~L2ZM~V)5#yiGabA^YrCAiXl~f? z_ECG1Fi-9nS1&uc54zdl`)~CZ?uys$V2THUYLa3WQ}Hg}~o=@2JiIjakkPnY;_p@jI~ z(=Co`xYEU+;q{uo#z3(Jn8PL39x-wz^et?TmY2eKNsc8WYyH}945?ice^mt7He`SW zf*2c~mwx97Wqpc!!OOmU445Uyk=<{x>wlbjzngX4cDmZP?Ts2pm(--AzrH9R@`jZV ztBQn@kU*d8XxTM%Nu-SxGn=kP+PZ-wpxFV8>cfpPLUl~7Z?Y!kZ`QAdnI~ykAHa-O zQDV-h$UfM`Wo8{H_s!+ahL|6>2A(Qu{ZZD@t~I&~7XxKEjMM7noi@S zaa__?$QYML79FkKN_V+*Vk1R;oLOoSuJR$lvcKP@*my&8}t^=L}S0t;#!DCvgPokH$BgGD&B^ zTnr_3H5*PCtoU11D#6$0*DI_Lk-8CCA22C74eBVZ5@hBdy(ml$XOCGsSdJZScy|FL zc-%iFN_)4qG^3nNe4d@VR&8l|MSUgF;rN})C~t7L>kH9GtC-}APD@T>`}e$vmNtjYuu(BQ6-5*7n{(Qs3ak)8zeV!ny>jA32(o8M zOGKxkt7uhR&;BasBT^w;I(_T~E{o$BU^?WZ^hGg!#+izJF1WP(r;(#eHa~-Fb6G}3 z4B#INQ)?$zK+NV(^|v3~-5)2owT2zXuWcoTgfGOYh^}wKB#M%>_!h%HJKNcy6c2_| z(*Zf9wFPxgOOmU@PPxpoHK~HYvDzvD9f4r?!)o(T#RA7#^1Do^(W$B{n|8V+N&aeq zCk{e5^Z5oczp)${R)EhOnJN~#Pi4g27RX!Q?hAdUeeWg?Wy1yCP#nYQT|c z7DWhAM&u3}lU49SessWpEXeQRXpq?Wbe-PaQ{~6ql}+isUC?v%tFO1FjIht#EM2xF*f#eamgov1r4aDuSQCrR5S0;?_!pb0a4^p~1m3ejS zFRW7BM*}~Tne`|_vUN=Hh>g7{HIp^hXzU4zoO3+t>$k^f-a-Xx>2V-%bSkMx=!0## z_X)Hj#o15&L<2T@xM9mstFZ4`rCvm4_Evo9A5WuN?V-cPq%%i>F;)7mgP8~2gfQnO z&^+7zDHk9a54pRY{RR)I-yvcoP4mk1o556lzh{~~)KZV1iWW)Rl5|Lz?l5$lsWf?K zHPjOCJP$x03FhdmP7I?fvw~<$V6b96Bmc%3P{g1q>VnJsxa;-VRh0^n+)6kO+X1uq z88BVGSUZ?xl0IIM-U8@}K40@ljnffH>W|hylW1sJL6~q~ivHw=%cbLhtQi4FA~nt- zZSaF`>r&uc<&*z9sOdXF1I4LWHBlMG>(wC^yf+}Xad$uflWK4pa;*s?JTxuLVurc& z@MNPTn}bc~^an0|COAxNO9ui*4M}+)lC!CBnIq+5PWwq*oh!J0kwbk}Gi$6zKEi9H z0gESq?eo^0{T4M*RI6+G(bfqvP;^#o)ld=aP}v9bY}fIjR9M%0`!yv31pW7YFdae< z;!r~bmd&;={%f8*)BZ;sT1BG5JpejxBPEB$H|Q@oaOEDI=q%?{?;L4>lu!GK z^8d^7=b9!-7SJe3va=$)lbOxY=D8-Ye<1%|<0^fT%MH24$r+`9Iw+K|m&Lwl zm!tniqvyAwS#uRW!erM!^9DyRnnWpJvLoa9FnU!L2l2x9Z%c9BI;5!Av1gx&F!WKo7yuRA#q*?czC+~D6`%YL!PEW<) z^oO0{|ABe%Maq?M=v2ZZI!`Wlccz(|!ey<^#bt0KiL5B(%X3fF^sJD11HpVo@`UQf z?MfnE91|$T$Z3L;;Cc*&kRFh8kMumopZ_lxte!5VWlO zJv2t-+0FH4T`G>=o_+1i!6}J9rSElF66*mZ`4151EkF=7MH}>S7+vHAmTHQtIIc>g zZCH4%wpBKncmap*=kuV8sk`fARLLufU}D<%b6b|m;woX(I#|lPHoMHFw&}06>j{|F zyiGP&2+O-6{;&!in;QDmREo4_W>LTp99uyw$A6a*4m|x<(Nm-@a(p3q^^wis3c!b3 zfcLKdH4Lunr$p}`RyN&@{~b*htUo-2r222Vdd%#^5~mnQ7SrzA_^cz% zx}y6dOcHI&n}^G6(}cj9gBLd z>^Nl;7;%)?4;k5O(5rA$8s>aBiDZ3Jgl?O58#SgD26t&Zahw8gQo3cJG^YoYzD^v8 z)4vSPR`Q%CP5UJxrgc{0lWa8RxBNB*ywop3Kw573DSDVX*1od96Z#ky5Icet)Xc$y zz`xIwlGxS8; zwFc=75gx1?LMLUSpLILLasL=>?uN8xZEFwC?$h772C8J0r6{sH*U4ED@>FoAfsa0* z_1nOKS_?|6NNe4MqbO>LnF2qY5Tf8kG0XEt8Vm_L2OCF2Lr+uvB^3cfGLA=0z`exWWCwe~C)f)%wiT=ws<}@MK_+ z9HH1#vWW9A&*b)~*5*`xB!OjZO2gv%wrDy!3G15vMczGw3oDYQU59|WYk_GJx-(oo zuw*swmIY_qVcg0z=29EQ#F~}vW9+Clykqcp1BJ69O$LP3!@z?34)-~WP-HB5TvvBQ zpLm#F3A=9~3CAl+R~)0nr4xT+5?yZkEnQ51-l+h&;6g(`K#`kwiP2t_L<{=LD&HZYbI| zxJ}^~TIiX!5?d9Hm%O)%smlBL&HnLhF1DVzYcEEbP)i8MB@;vg^Xsp(4e-%f%%- zYxqdQq1%);W7w)>8FUfVmrG;^2txEsfy=&5z6ena>IsGL=YHQsTNA@qq)8Yo7zmPt zvFuBdUXoVWk8^O7p;u-Mt-=yuC)HRj2clAhGb3P22KK;Ih_zA;M8F0AxPWaVjg`yv z2jXe9$7$@NRU~{@=U*ZQdF`C80d_oaFkZ<6N4+zFbK z*n?DSPAGsc^*m{qXO6v~Ne&>`;GxmF{#y&;BAUdMrF?+$!|6$)b$-P!4Iq#?iYwYV zb)fJ=^KTTC$4xYuDN7Z~^KXP2#DzDBK6dQCo{1|;>(Q@NO9j6y#HlbuZ58^Me6Cvi zuavs0C4W0C9sJf`WrqJ#3PwE87;T<1DDs+GxvSQuTCN}~R7igd27#d!3;S92D^KP3 zPZ7Vvf28IF4H}-<(f{SZn=HNw`6mLJqc*8k=4i6Y_cXszZBo$Rn-A&(MU*@5? z_FAR+egEfCa}2b8kl&(|HB20!lr~Nx&n7W}vwuP*1g$Kf?w=ex@(d5{=ruD}G!K4% z`tRzVlWC7-L50z`1Qi0Y*4^`8u|Xvun#35>_*W*banR=dE6t4pXd9oCX}yH~x^!Rz zkeHf-ha`cDY0M|-HA@>-=FNV1zD(|gqBQRAIWuu&TX4k`m7Merb2PK1?!&k|SVusv zDyIO1jlTAwYw0hIHA4nMfjo7^_wp3h#U|%Etp?!w?_=x;ZGsmVkV#dURjSVzk^Jxm zlui-tF%0zwh%5W`wWmrL+?}ow4|*5J`m32O(d7juPVm`i>HF|@rrSCmdiGl`a>IDyxlo>;_kvK zAzm5DTg5gF&pVQm#W~0=Yhe=0^rd4L(k_}Px+wfPm#6kwT`)1LA)7vKbPt22Q&Wnn zO;^Rx){+Z^r_tRz$foht8^VC}aQ(Rkl~uq-rlqAoIy@ga+5uIQg@CqbZQN95H7j%4 zA|nO^6NU=~Yxh!~(H@9L3nOHyr^K0COop(s`tVi~3cksU#R(zY$ErUDf2lf+wVZ6c zOJ4b?TAXseHq1>rVtOtXdJ-LF`)J>5Sq(#E*AzoYl=zg)gqEfN<6AnE^dMnV*#TM$ zcbPeo#Q&+x=48j9fVQkG_NpF(h&O!`dchjZ=#5J5kzZ`-CnDo{f!Z9u6gId#WCGRsmn&tKvghQ&F zQw?FU-ID+j=-m-;{kVZ& z1FqVA5H8v{B!{K~sQxXVw9Crp%|rb8tp_%g{hzx2oANJtar;ju|EE~ie|Z04fRfOR zpd|J=)$6Vug>d5}9keXNUBCa3z|Ftp#@`xf+Z5aT`g?&Fjg7f78V52*@1V(OSKrz!RR?gTj+BVL{=@;9%hp5uiSGVPXF| zKY+oeL`@ z6~t2BrAGevwC#M(s`DP)Xx^=>l^b<%%;7IoS_&zu5)Y~^I&O{Ut0E7@)rTo&osf~i zo$V!Qm^M3jPTQi<{CUo?un#(6WuGdZec$|z>103|#ouoHEKvt8E2bpd*fJxKMD;UA zgGoaW#!xBBZOfgrx}wef`O5$VI0@@!%#DGTyTWdMo<`P{<;?a0g)g}8`1cTfI~X}h zz5O&f&f>E!;&cNMmn<8F<6swSG@l8Mb?IRNWY9G456s&YWhBSvaXaj}m3ta`lK4f-ISa>_nTzduZ7|e!$ z@hf$UBj~G{zrgcX{=r_FzS(i3KSE&wsuY|eG*fS?2VcWqyXb5^d@(mPT;|D zKa)hjLMEVSxaKAY?fG4Mr|WrFVuT3mZM4U(F`^!v%aXhK?`-l`rMqBT7CYbxHVPj7 zyO1S;P{Mhh6cg6QmrO%oyY6Dg4CEu=pv=DDILr+seircUy-`(NRq|4n3M0ZkAj!<0 z3J0ODBTom>q*S3nh|x^vwiy2Fjg>8x_z8{gCnA?|G5%$yPjC*s0vK$Sgl_3%WKIP- zYS+WMq1-(jPqnDUkNn<}1_(C$t|)_Ix-YI`JYbeeF+?!>k<_d{mv;50R(;c@ zucbXQK*5X&l4D6#t~S!{6t0;P$ue!u+SKiL@^HN6Kbd=k{em;kIaWATdWsc!7en_( z_deq{QU|Wd+fYfb&u3ouG+r=BWM$&B<(WSNRA_9rzbqgUuGKa}eMg36&$)Si+FsCJ zZWGA*RUCMSD9ZboT~`TNT8^~HRjD>(+Hj}+UY}<_sqRXirtoxP!Orv3PipUqD%%fB z^LxSMQn(0*wq!n_dXt}zcP7NQnQQMZe7-^QL%<6|d^vIe_`=y@uv~EaUh9+0&(5ZA#gBKY_6x*>ejiF9#D*1WfwgMZj?njE>Tit=hzFYJcI=codJ%lzIul023 z7M@-p5{VYeyci8(DT5Q2nI%WMvG~2zbm~|dB2|2vjudkJ_FY^}eXq9qMrObQ8ki`M zxxGrPfbe&bk%lQNY|9wC?n>6io;syc)5F-{uW`>4$x8p(M|`&H5aC zU(@gDJyHsakzP~$nI*ZcBwSXG!` zls1|&fTzcGU*@@eydEjWCT2{IG#)Ba{aj2dey-od3aY~?8U_3eyCQn~7$;OdNlt5l z`>?B4nGSyz^87M>Ix15nuJ7ChXb|Us0xstZLkY+8ru+`ojx((nx zB4)nfsbE$6p&AN1u}wZ<{9sT*m%vzsDeuQL5!n$qM0_wT!VqpSPZt5>bwU0t5l-)koySO7Bj;H?gp=JC<*_78-KDc59WU(z0qYY>G_K{SzdvMr(tQD=93Fg^ zj9JsLwI1r;ao^(YC|7QoDek+lIB}M|zoyzB04Xm41^ijRroq|hgwA-WSjzmG!H(u| zmJbgkjfAOQz0cvBi(#;Y^}~E4|HbcZ=hH9v6yg}IkV^QstMysDT$9((dkl{%#YuY% z-fUfe09SS*OQ~%r6x^wMJG~8^oi8>z0u;L)JN3TQRqBA;2b8Q4#Dg>DxhFB&IluQ=B$wgC zog)GxwzD=2wxB*46UHwuvC^IZpXR}%hD8h*J_bMCP>=Q(>7R^tBkQfzE=0xYx*)AJ z@Fv{c^+uc^%RLg6d6K$UD`M{1yPHZ_y_WZkwn>LD?wOuYx&hk!n(Kbxh(*fcOFSvf zC;Sfhf}riP%BLW7FGqN}q$?;_GI>n0!~lnUwesLhn}Bxm5118#!=aP;MHCm;pN-!y z30g#uKV%HPDS8InIA_>vxGO$uvFF@Y{c4(*Qj5xONvpyOxOA#jx#(~cqo$iGs* z66m_{-)NyqlGJs`6t##+3R^B5X-*6{hhp{L(n?5RON@V_G>3#Lz!*&zUow8TQ(l^C z2sR>}{MeeE%Kif|h*}f=alvqoWVk*oK7mCCOl;G*{hk@RK63?@SUwlm%2Cmmw8mq# zUKkzeI=CJ2QOgJ&^JDb+vrl89y*i79%dhTezAcT-HVxEgnp9cR2?<50sEz5>QnT~Q zvSU&hMDGGg5YGdYwPsL=W%L~O3e0;$t)t5>Wk z-|^p?Xd#7Ff;LrX?;SZ)Yb+zGQ_~3VY7*nSSx_EB15iEh&$w{-U8d8LPcgIp8u7#SlGuHKSjWF><1I+*HELk!eNr1CyPFX%=8; z;&C4e*%X3ydw3t~a~{z?U!!4T#<>Q+5yx=fZmgnB`jYe;j@{O_;|%U?4l)FG11B_d zN%PdEZ-6Gtzr8Glk(E=31$#hCw~<35P3!neh^b@JZNe^7MV%TwYcAb@TyFPc^5**6 zc$mW1u6!A>Rby+eJD4c`0d=#DU5&gwt%YQx=KjKl4RD7y!xSHaV;?d{>iupCYk_t& zUHw9xVsIzXPt!U5fRX|-aiKbh@zQ*J7y0dY_jOB*Q?ja8HJ)uZ= zWx<4YWW;TJ(NjLaz;nRZzPR78#710lpO$}vV+eK~3MqOC3w5zG)vv?X_AhYbFt78$_CQno5JiqPLR;?%P_d96K$-%vfnXIiMy<=rn%3l zpQB5*7_=RidS&XHTgM(;Wu;j{Ng{53R1>wtL%9m)!FCH!6N-D=v!P8`UH`&msn}f~ zqvncghTB01u>+6LISAv>VC$k0T$stdUL?7fhwD~C@j_&7dv~;seGEc*nI!7j`+*eXuNtt%t zra9Gmfu+Q)TPwB}Sjt%XCI-zBXsxk8%0n;`O*(902-8uKsePf`QQ{RunQbxjHf7d+ zTPJ()=Nxdu0kdHD{X*7T0ai-~w-5tuuZ_di5Z+@tvj<)y2TQOp{aryLTXsf@#va;e z>oprb#~6Mc#u@=cs4FIQ-#)B!Fu&8d5~U~3vLGf=pIKKl&xhBw~%lTp29~W4g(von|KK@s_(daSS5sQATtZx@D6-6Yb=VYv*(kj*mfRctKaM z0vo8mo|<`&0)DmT`jI&k`GV|AQo5iwck4%;Bf_Q1w-QMP#w;7XO#gP4%&$nO!kgH3 z_9p(Fbvf$?#ewMO+=+u7F^dg;Joyw9=tA#O^_2Xh$?C`Tl;$c|K&Fu;nF)wkcS0Dl z+Q2f&{ZBVc^<=HXx3Rebl+KBhvkwX}kd8`T5tpC(OpTuK>?{eR!Sq3a?9C13D38Jc z?HsSM)J>%W)(jP=*g}$}m-HveeSL26`ZA`r_I}fQf?u1N;>H5QG%A#_SN^u{P6!SB6l}d8n88zo z{24UPU{8x>SZowVRa=>i$*uv*U@YZ|DWKEv;C}^)fZWAhX!@?IJjc72A8GO0g6BNX zv~b4ge}=SX4lxfAMu4AmWWF{@ZE$nMA;w)*0q<>CP`qZvu6If3xY|~Lfv;lb8GOFU zb7$KwI=;>puR6Nud*CS`7y^@`i>=niWC0gU3fNQ^TY2;6p^-D7j1^f_Hei7%5mZo( z`JU+nOyBWi*Q(@r4PXISS=^&L0;Z!#hlc+pBmQ&EXzAn!9${FOjwvFMh1lEe!dnRk zXl=Y3<1Rl2vc*;nTS*^cqD*Zay|&rqvdVa^jDNDy0^3TbJ5GdHDz4Ah@bY|gfX2Ck z+_Xz_Ta830NHqx~sNQ&I04K_;%k9^K??qZA@`#$}Xzb1mr1!QgUgJL3O9491*o8vP6O0S!3q9`Qs%ptq~Xot^^4)?AllHv1h> z*Dv$&HEFlbKn+buQj2km*DdIgNadEZ^mC$RrlI=$gS`(*eE9LVI|nCcN0FU|af%t7)W>LQk4dqH5IYlW+9N~pFC8|z&5Pd3 zw9Oggnxk>ygd&4Nds%0PAOrK4a~GXPkTFQmR$rHlYT3SKi&lk3`Ww?Mh0cMYWoY-? zzBkc5R))sm?ph)=8cam6PH?5$`eEg(S1L7--Iq-5T%3<>@v7?8Wf?^vAW1N@Dt7EW zxJ#+ZFcXZ(eTWK7fwf`m80XcV#Tb|yBP&Je>dvCZsq^RFvl~e0 zgTFhM>5_Fbs2%-IVik@onQi+HS7lg|kw&Lm(jd3+tCLOzcUSNBXB6$u47W-(pXKFo zL--*?C8- z86C!Ke}>ULWGQ#Su!4M2H&4Dzn*zbzL+n}&AIl+$0(XEily29)*~sV^PjTI%ap^1N zR34eD&?Ce%hMH?+=NYPHpvoZEGq$l-qj(`g7AaFT%C)|`0M0-WwO&=f3jr+x6(f^zY-_t^5`|6_DiZ)j(Z_SHIo}leOmWvyZj4GG7WNMmD83s z6T_5c62412O~sU9?#OqQZMwE;FiOsVf)Y}|w@pRxRRWeLvAiKqG0X_MMIVD?i@U@c_uR|g zK;JYA120!1K?-@5b0*=|&l3XLQXMA_CtC|j8|hsqU7b9w#?!LylKly{JcC$XHW|>b z564UU>8m&++l|$Dy~{$+nOP6=nt*aK0gofj%~VZqgQmi;9+}=E9Xx(}YCX%Rcj;n5 zVt$qS$jsy=m_|JDSzE^Z^-5p2jOOGBE-{D^77D$IM!3;H0;2-Lo9^FVlnL)`U(=jD zKTNTL2+>mOu~eY3de5Jh z&pUV*C-;b^8ErPgrDs=>_T(`-ZpCh#mkhff=<^y&>S+)ghJRU_6-1JByBZ3i9TGTp zwi=~XQ|T>nIpUTuT{(~bbpbn_@fI)&0Ovvs4M`emDD9A{+F?_(GWI5|G9=Xq!1P={ zrxEevf-{+W+Gxev;NHtX7!-OVtM7A-zQuLJNZZzRw|aGP=a2mZK(;+IYT%Q6%OU@m zPVhM%<##&BX}go(c%i!TLVJQ8H2>uFk>4LmXb}}Y!qGL5wx5)H9zA&W7=XomSr~+0 z#|3L>>8wpIv7@T#mnZ*tQVu9}|*VreZ-ncX$-+o%QGHgr`^@HXAj~`85 zq>Y?ivUhUdpxB%EPgMS2;?EL27Vh$Oh_GM<@7Mt+?J3X@+q`J7pyPWXe*ntFqZ;j_ z1YAEl{{ZxQsDRQr6ryxYLgPUd$TzLFMc5}iUgAC9*XW;hzi`e()S*Ay${Z884 zjt!+8l{pox;UP*{Ujteh#gjCr`E5RAZ(fS4-d=K_(Wpp8T^bJ8X*Xzktmrq}hbKeD zI#dK5fS>zEEQ>v5SdY#(-nNXo^SmqYz6_y=%_d>R2_gYRM_6zM1P z`h5H>tuSXiFh4R&Es^Rbf*I^uw@Ql%W#L#4heZe+cx2Kx$(;J?BuJ9~eLGh`KrAux zhr;1@C$fM>5Bm&VQHh-}ueMBpDJw!&^Z<>#!vgfwYy;mOruEO44?cNt;(tOE)u_3r zt^ur})O(WbNgaJq>^vZ+U(?p3B0@g!J%=2>N%xLXD#Z8RwHK?gfe=PJhki?~CiF?D zW?XfaG3;c-MT6RV_PQ3?)4eVIuA4>ab{032dl@=%ZF)HWINF22(}c&Yu}v8T2A1#xEx zUJYLDi2orWSh~R6;~Y2ntg~5h;D;DoqUg9LcGWM^xx6*7`SEYj7o@PTr&HcVAHM$b z1p6W!bi}RTlWa-MM+z9~wH9{q;XjXzATn%DyO!C~g;oke0p5-aTX4vwbWtFuUVSdP z=LW^oWC|eD>lI9rJ=SlyGPt;JX8w5E#hy+^F~X;ZZYIUbUUq?ivUQ9nczT}uemq`EhaSJ}RE({1XV zd-(CfmU5YC+{~}DhD#S4Ok&%OSE)z}wYE4n`Ty86@G@@3yA@)=;C3uW0>?*(T-A$3 z<3*~((4l?G>Obp}^u7|NP#RO`_}#u&TxxZ4tmV8gn4Ej3)3xfa|MOjHiI-t=Fy^~K z{;i%gvtKTJ&SuLRzaK(b7WJ{!6H3wiF0c^l=)7vEbT%&=e5@!|2FGaQJ` z!7yi0b|zG@ro)X;^&vw02O!}ln+VsM)HH;##Wda`lsZjBtI|vfMKih-!}v`d*UZ#F z&kBAxvf$w6IXhfdxj_pyd36(FqBRN;=f@L~oGAb+`_Mzw<}m|uh4si?fH4$|8p^@c zKN%MBq%R&LmO`t@8!OF@ET}fUdE^kxBa~=>4vyZttV4La=vomzZ-o)V7-MuozUY-m zi925YwK?Sp0C4`Y2clVgZPoGCuRw@^>=UJ$F~{7E%@UqBPU|%7tZa40^qckWeLDVh zNBzs9vGZam7Nl)dbE8*|{6?#V<_U8aS$YAQpElkoOC)#Ej7IeUDk~B}=9{|)5q++R_Y<|bS zn*m$zok=yzsI#=FlYBpWXFq{Y;zGSP#td}$|FMLs!%K5)@A?6_ABa)9ql> z^RM1((DEUle(C)s3buQK&lf|6V5#Qhm2)>Ia@}U!pQLp;DQS6M%S{{DsWEN!G3ybv z7!s`~i1FNG?PGheHg7f$U#LbNZrp0lx9zV%VU31U-pPftcY_nj5jquAav2v3-QO=1?fAJ( zfELsJ(7oBfq}G?LOS3r2!47+2@E-?Ie5Dg(2>Gh%A$0j7&itX4%RDmlRoj^@0IAxC zHC1M$f5Z?(O;7)dl@?u$m$Lb!cbdLP>)UJNBt`KUu%C z_X~X-r0g(f$qC;3j)0;jo3Z?w`7?6S>S5YDy_@;bN9iT*iQejfbRt?*LGIdPHYiJ6 z@*s@6*0RNz9K$5tS*qn(Fzx_?PcYOZVKScQpx7cyj{!x@@e*FGR-fl7!ZkJ&h6_T^ zs662)RJ&*3O z?A$9mxMiwe?dj=a{2afZvt9v@zIe5B!?Sv*w!GwUB|<}SyZRDZo_!gdvVc1D@@&`C ztpps|5n*fCsN-HiDSmC+gd!f1)mv*IhEgZ~nKj&pC9h)3>2UJH?S=0|c&X;ZfN`zB zZ^Al?(Jd=cwJy@kPAD1u(jJXrII0B_C)<}AN^J;d;{ybPaLtDK_vUgVk0zSm5i2x} zugpfKFKuRMgrgdrG@9GCbVM5PQwm_7x{}!&{zo}P7M}po2x@5}vT zD5hn`v1)A1Z!2K0jNDKiN#=DcA&TxWR0>H7bLtwwSR=WcP!?(W2!aH*i3LK{5OM== z6KQdC91gU9l!=MG;v8hKk%&P)6_&1YmpRrK z^-6w=rv$~yE{`qV0jd;r8B`HyTU4s!eZz&98~uVaNyrQ)`+NKQ6fpKnKVO%(spD$4 zY|mknOG>-NQ<)Y-l(ye>+RJ5}sG{qr`YA1TuN4Vk>s;^VB8@ZG#dqgttHH{Qmv_N4 z;$H9_8(SOB{P~M{<8=_qD%X#YcFdkM!j%inYB_Itd0_Wyxn}g;ErSQ*ob_05gwbpu zORFY51TW3bysSW%G)lS}#9Ia%tUv`_ajrG|9O$sgyj^TYmN(<_%_AW-9F|G{1)RF$ z%t(MM=v~@mn<+JP;6da9QT;>_X=9|# zvQA9yJLZ$1pJy=-Nd>o4luY~WUB}wpd?#SOO6Lyv;W^l(?r}T}=$4`2=x&55k=;}EkV!2MgK9@HD_C@MA z;2l}gPQL3ojpU8&%|~O;p39?3i+4D9%ewlZ`bBSXdOfcVfUY-}LbeOYqUSVEx8v@_ z@y%Qa2jqlX@YYNtsk5=*iZeOFM-tGN9a>{@qI8;_Kr6@+p4*JC#2KLt4MdQj`MWRF)Po$d`Mw3%CG3^UI}M)7!{j7`r8>!(Yg|S5WC#^JquO2A z@J3P10?K5zHF+!`~_ht@mv1m?&S|;$g)3C-%Bk&moR`RmOaO*8GGF@cywq{*9VL*$yZjqyQ zcj?q;VLX-#z$-HdnIDs0!5(iF$IiYx+TIWUeNgX^xyzxrz#=!p!t1#`Ie{~}>$K;j zdTNZT+t_Cz;Bc|C)h5>-LssH`b^%AheIX)UX++Rba zotkHt?3;G^#Pe-#ERVvgq)S2@m-fEA=~t@hX|OK?Z8zekA=Di&9}KXBLB!$b!Z0sj za{Ff{bKiV&2B`4GsVAlVe#_+OiJ=ds=9QM_b&|x!ixr-f&@;fQ*G}YVGxS<$q%5l`l6nL*)~e&2jt#X|DVq)$;q4;YNlqYy z#-ZF|iHtOF#_12Sc3Ta9)oh(@#CuRr{hw~szHtotaq4W)Jk(g|Hz!|pH?m`+dPpmC z!N-^vdQ`A#6^H2pItwja1El)}}_JT(pP@Vk(A2JKj8;yRG?3r>6;CNb(Ru+O+3k z(3K9YrpsV~jhKA6AV$-hr=>#=%((}QXK!cjfeC#o!w@cA{imwy+EwP)6YyxDl#JIp zzlTvUf3`c$4Hk?&hYJaV*-{T)Lp@71C`3W35V-EO)@2d4$sV)od=N7TN`WC+1eJS; z=Z22_G`e$&S!u1xxTa0IPa=!kvu28$FZyIXy<{87!hPxMI#QW=t6u_1&&-pLLzh7=MN9s5T7FHRMR7KCt#TaVRKNN_@_fYS z$n5Eoc?8<7qYnEsB@`-PD9Dng?UU-1f8vAcnFyFng6$z^&nK=TY$LSrxJA*x7(ofD zQBas0hp=Cx(9WI?I(Wz~2ajgo8l-Q{xsBO3{8oP8#eC1J=avuWIS2b)KHZ0Elph(o z5vcSD3m0t1_#HKG8NPC332mPC`ni^Ga!%pdM?{s$Cbu}wz^~&=y&uzKp-H@=o6#@W z2McKM$ZNpP-S6ZO!J~GX_suu1=1OXKzR>=xHPDSkwU=>&J!5c|#*{C_iChw2GkPf( zXgf$xu3C337Z*P5e|J;(X1i1BkqfgibQos9pKCWvE*=HD!Wjg#TqHW~e}DGPLAoXR z^x~fmeYyDq;3GQjoq9rr#c62yM?p3?R(P}uchuWW1KTtKnpTsEZ^52mqP>)N0L;K` zo~@L_m6+e{7J&Ud5@54Jbh<{#vmE;rJw8RyCP0CI<86S~-U3m~SaZHIxRXL>M51R( zBr*bt(qwa*b7tL$P-mk)wgj!PyjovUhdEqj;`I1kUI!rPWVvs?k)>d4KXG zML8^4ovGuLu_#%l0`%n}iJ%D+d&Z@e={rrX0ng_jz^@|=h4O<-C|~*;M7;dWw?p=p z7@8DM5@^Yc4%@3*hHb*SZR^yI$feHo5he!t;0)V{WnGSAPP zE{(6O=BzDX*raX7?QCGYUX`y2Mt`w1gTa5>j4d+PCDtYgT#44afEJ(@X)1;9rgYWt z{xh6dOcQiqI1_XTPI9V*7GEI3kMU6Igh`zac&Y!<)veMpLS-Wz=~CM+dHI%Qg&I0_ z$!*m90mK74Q@+2y)6D(odEo+%vw=Cw{_3op0sDlJd`K@jNbmiM-2|pUlglmL8{Xp) ze&%tuTGQCwsU3;YJN^mikUDxVc1K8lHHzAwThPtJ$0UVYie%sxOaW1gB(rg<&=2un z`!6v!Tx?=7R1!CHE<`vSK0%Rcaz6>{d**UZ<$rgIHbS|74*UTa`8M&-<($hul)Nf` z!2I2!WFb$sw*Vjy8Tc%o%;2zIZ7}Ntu-@*ocxw7PQ%@0Tu;05GmZOxBy2Br9k+0jm z(`AET^Y|gIGwk@yr>-tN&d*7MR09;nbdT;6>1~Bz{Dsi;d?T2V_B+Ng{@d#iwHXPz z!$59#<|Gn0xtp~O@~8Dup3Nj~a2=k!aHmKm^C8R3j3qgW=SjPq1B#`&zH&?B+}Z54+%&#__oHtc7_E*SkzPiYWu)(|jtp*A>X z5cqAruu#B*6Lm}FtR6qp;;zzMkuQeYHY|}nr0GQ$uj|gP5Lm#s34>F*@;m%)8k!!A&X1qXFsm8 zv*SF?WebaG+B)OdPVxxBFB8I|e(})W$BFt;wG(z%m)lj&l}hZqwbx8Y<4lRQH=xw4`A2Ojd>+dW8q&c(P;S=P|{Ek zV*k-W+?c^_@$AIq#7gQ4q(zdULr&eYu6GtQ9HT13e{H$(D<_7K9+j)wj~>gioTPKe zy>+*Um}&sH{+w3(=!6~WfmAUQS$7wA43`73I+0#%HyeWLEQq=wf3t}QYp{s;^7zIA zQ&@_I7&NyML)zhCh85c`t_mof^=C2BD+%88pPgp_*{eMzrI~6={-bl0s5n+%Dp8U)j#b zhN^F$};jg9velKKy>Co)zO-zmSa?dcD_NzvY{d7iUpC_WI zw1KWWqn7uL)H+!dU$lDXs7qxfQ#+3yEfXFkVJ%gHE_U5%H+>TI@~)c|${X~O3U2~( z{Z%d28X4?r8{S5NbX%+P4^8Tow}}(RJWE@R=0uAE`P=|oo6VeGyi(c*Y7{TPi6_OB zRbQ$w?z;Bc?oD&`WI{TDw+WWnNJZoJ78UpL%<{SBy>Gf_M|@ncgK+901jn!g#!OJ0 z^w#9GjvF&{X@x3FziJrC)pTv(ek);qP3OU&`gIRl4VqqenlrGuT&}$Bl71g8w}v;3 zbTQ;D4(4BDAVUtP|Aomjq?EquE7oFLVDSby;Brs)YFFdxt(&_CZ&GUT79k|T2>;Y^ z#OI8yc^|CL@GR07qQzd7MM4(|mI*~J{j=I*pSz!J1b&h);(a{TtmM=lvedNd{kNYi zWtrS&>c;Py9Sj-a`A@dahI~RCCzeZpnUiN^x|RERPC(_dq!2~^9j|d)HRlH~ z!~AfhX|e%J`avUX8m9d~%ua}F2*zAh0aK9~-N4w-?_FAp<*dWXcH(b1m^I7>e6w(? z5`hJ*ndas+8^gcdyq-j))iI%M_nKWXS`#9w2}BG+LL%XWap7p{9H3}gf$j;X2zAng zikOux*~STr3Sm*fEp|8aY^WL@fZd!$hIjOGQQk540QHSq^Nn!Cu_CXTKLGnLuv@G9 zGn%41ru*#3dNB>FZgjkSXZjO){PH_1Bp?sdza>qehchsa!(BTbQl8qVa}dU~2p&9D zZ2NfY6eNTSk~Z9sD)u9go+kPRbYo?nDW*; zeF8VzS!??fJHZn}u&r8QK&?bUmHWx%RokWsKu zoeVV=lOHU#F)luVvk`(QvcZ>aJ+%QAU5=p4t=963F`R9vrD*P8ebLB1=!~2E7 z6Kz8RF^UmHBd=NJz49_-DZKtU#qNAI`*MTkXFq*SKY!;iciF~#gYg5{fnn~;2$x#(c zKUf~Imos$%IpitPEPYznJl<|1 zgjuHZM<~ET349bH9MTWhj}qvcZ^b*T2zE!KvLI-M7tO{9J5*`DfmCHT40B9TeTQLW zO4q*#g9pta>35yfQ=?(c;=T^5Lissr<)f32_ow;^&YP!GuRyh0WGSiLH6);a&k}oQ zQQDwQL|SlLM5Ikdzpr=?&2yb; z^HB6VI32*WgaUb^n^vkEbu#Xh(~-?<^_yF$J-PQq$b-j_LnM%z_1Zo|!n*48E+Ep% zTlWwXf=E_59P@uou2@ZNdmj=+(@!1BpB&z}{mrz8G0fo3p1xgKk7nY{{<-;AQ4{Qm z(fo5j1I`@#ya7XM$X9XDnPa`xZ418;?&~*70XPpvlU%LQ#Sr&Xc8D)Y5*?;PXXk7O z?Oq5|otT{NdXlWkLG&e@>K0rE14ddbGD8w|q=#E%3(WI!c{RICs~G24a&(=)Zn+@b z$GnYn4=(AbF``3Q9jKZ=;qlCGqQ^1pjSNk2(d9V{v8Wf&$=a)-G+WtdfHx+Sxa>hP zdR-%uk!CZd-~-(X)5sp#}t4MIe3y)|y_SekevrVQ7&PI-fm31RGNy09Ar^44A-BI-v!2|A7x>s~W%i){!o6Lq zysIu*WXd$su6s;vaf3N`Jf3n7v6%cFVs_p5dkeNu^d<1HUft{MS%H6lejZCO(V?rV_J$ghVTS+BT7NTXF= zcKoEmRG?lRjh4*m_~$AO0<23CvQxde$GRHUad)mr zf&w)V@5c6om&o+#gqx-id(CH$2-iLTG#o>t8f4Q@Ge{u&pL_FK!)^!+!%N(z?-Hvb}R2s$flU77=7OCK1IIgxF-u1 zWl$>iBlJwDfp?TXg+8|K(oLl%RMGgDw%&e+^!znRr0Fy0KGpXac~WZa7iiInCtrzo zCmS2b29I$h0~I2fENCI^`OFFl?HAl)u97wUONM$?!MEr*qNUl&A(#<3>{ zcZZ#ahM4O`cUNcO-4!Y6V@JoK=$ArduJ_^H@?-3(WY60WLduQ9Vpa~ir+@@v|Au^` zk1>(J$Na(e&pW$`P4kq>me%9~*Ebdl8z1c)%J=C+AM?>3W-nyOg-l;C>(77=ZVhoY z#<(?27|VI)E{3K0_4SNBJ%;gAs>J|Wv6l5J=cHA$jmKDw+EGOE!ljTq~VH zT>Kolj~!J-po3ZOVab?Qh8&LzaBzvIantY4p5pAgs*;gm5}TXEF;HKRc4VkvbOt&v z`s2Q2>>_p^$|zB;9b-$ejLjE9-}TKP-drjs^kj9OlFnbZIaDb&`G!**iGSau14N;V zf<$6)1gcncTkA1;6oj2^`8r z&~__l0&JdiR&>krEzQhD*qzyUY&fZ%cYG0F@!=f~=A(@sYG-*Dd`zh^wc`XlQ1E?-yo6pv0$ zRAiTKc0&H@x(rEUQE;*S!U#W?#mK(r8|t=nCBphQ?}b6OiJQo2ec_I0u%A!x%uY+O zoNkBfFMh>~Rvg-42*fR8uQql|25YxRbzkx-6dXWat3Kp+oaiFyJzPT^l9xu;i)Df^)WZ);=F2uv zIRP5_NW6A)KO27wRV>{1DC1yo+vX zlHv6}+{E0(`y}oIu?;~5sWbiqptu1SuyFM)GdxI8E|d_V0;!r<&KOgisZ#I@!UYX{ z@ryJ#X?ZH$HFUs>{oagANAYPia9jt$bS^$e3X#;fJj5F~;w$!}4M)Ic*u^$=z2O_X z2D;b&8h$YXgdM*dS1+&7k8R1d4^k52Rft9fSZGWL(4~pb?aJ-ZPG9hF6US>pJ*B1u zb#$387{bRWqH1CL)q6!ocgePOG6@yXiX!J2s>>)%-%UB#z_o0EKG^>qF{N1`eErQa|xjMOF#DvDjCNFdxiZ%0FZ$%+64@=DJSF3Ne_RQl+%Ap zrtMN5($0Uk8}tL`5zoxk2+39H& zP)W;=jKsxB5#BZeC@|cKFef_jSeWagl9;dsHIUMgp z)j28q4BL+P!1c)u$Jiq;^Lu))?MkyxeRsj<&pNZ|91Zp1+k5Qg>1#mayDRNgO|~h{ z7oTsD6+2IZ>S7GS*)A-kA{>RJ;ecmBPW5193+ScgU z>rCO6q2sY0cdNq>WA)+J>A=ss`Qr}*AN|hCcc5FP?6;3Pps+;Vd%3&6c^{AWxCI`* zPE7ATrRLX{T<3gy?*%$IciY6)Jo=f)#~7D^g>qj*wr|H6ky!LZ;^k-eJo>$FyE*O= zUDMPJ-)=l#A9?NH0~rkV>dtvexge{#O!6UGQrAaX!t)*nR^m2i;(7Y7i1^rEK4YI+ zEp80G=pRA@Ou*zCLJ1F8)dGaYXS;_n)E|`D zOk-z)IZNU(btq7&W8|~t1_YTr3PZZnLVLtvwPW+A~?k%9D z8cI?{Fm^W~tTnC`)BG?on$+M*Pg_0Jo~DAwfEeZa)`!FFP*GG9LqH=}cV%vZ0?b5?abMR&a-X#%hTyFfC~&FeBWN*~rDn zizcYrEWgIpj07+0f7JeSRWT+)lg70Ot5@3OhJ) zEqU@!LV|N1nw2D3$fEkn?LwY@0WEu!3KV8raRun{0-lLvsGy{BlU;9n@W>`Xu4aK6 z)=LQ}+9YG{rKYfVFoj%MFosF|DK19_gB5G10A>L^7%{bp17NDr%DEGoY}w1?-ocr!v38v<2(bP1N?y1yXhY5dDaDB2v#ed7L~@Nhox*lE639fQ{xVGr?#lr> zA}y}^_4wLKmkBX|vV7sp4kSa`xvW>)mAeIYWZ3kBaDgRnKS>a?wM&$ras>gBwM+(_b5 z*zSB%2SgWUlwzh_WglLJlv&cDs5O1mWIj$&c#LG62ZXyv(l;)zJ9@`Vw{YwlI@WID zaq#1!$6AykqSHqlsf2$0ibrlrkc|{EF^Ti-jXE#+W8% zPRRz1)n;>25RY`E%tKeSuH?ahMV+Wmnad9`iR>hgi*N5d1_x)OHru?1>jkWh^~Kys z`h}u+DXa8}%+PL;V;3E{`45Ec2(I1GGgW1C5IaCn^xUG53It`w!xD*O&Ai1eGUjS< zda`%~h$IPpbLfpely@6)9T*nJnc5M`=PO)mxLhEKj&j&KGQ&^GV*kQ67!pW*Szy1U z0H&a`iXIXvboW8<53YEI}2&!EoiMl?qjXC3ep{9b)nt=P+J_oT5J3Zf`ZVG zHb4Ur=~NQPQeQ1Yuq(G}yO(i4=ozDgi8@1i#2)>Nt6JNPhcYP> zwv_*JB57F0JCC}T2Oa$d8wAJq<#c*FDkeK|7G0cZ4*REx7Hk}(O!|L$hXf68TFt;~e%dnNTHixD#K}|y zmnfpIUWJ6F^U2!BvoI8DeL0}v#s-tjp4blv zg06;CQd6=yZb$~peu3htkJ?EH8^0P#jgD*dmuUL(1N0wKMZ6Q8E-Fhch1dH*g&6W#?iVZjgm1EvQ20EivT7L7s8(%lEM;^C9I`||EpRMlCP>7%M_ej=NK z@~AMxz!ReW0elo>s=#PM#bw75`kxnK8COav6PcpwG<0;<2v5(rVd7XlS00q}z+{{tW&{UxGXYtR0dR|#^I zw7)RaK<#-pYDF>u$1?B$AP(b8rv#7gSM@J#|I+%40f6vqmZ-P;RhK1?$AJ(h060Q6 zKi>yS0?x|+CG`&ofI?MKx27=$1H$_9x7r}?0dG^z+P@2nfkgm0kWu2lq=*NzLQVkd zk@!S$f2EAVoN5)&2WoMY4J3ssL5?f}1OZUA+9>mCvGu`m-HDL}0U#)f-GlV^;941h)*u+sR^2gCFG>VH=d`6ZY|;LC8F zGx=BG)glH^!h6y`pvi&ut%4`O0IU4>bU?Y&1J?z?<|N7e+d2~@Da*=$(x!je4N36z z1^$`-0RfOrk%5%OSb#im!~rjdqzSO-=SLhSi4GzK{$EmmIsCtG2@}-+Zv2 z5-8jM6b1f4f&91l-(&wb7`Xl)u2kMW?q#YNpEWIL4qBQ)DMuSy4v_u;{w_cNLQn<% z0fJyUThA*_m3cV#L{C|p#G0udV^K&nuiG>x2of_f|A!i?^!qgj_{o$M`a!Ei&5)hxWKBmX>Gju5U7&LKj#CW zI(I)@0gS1YB{cp3zBb3O30`I6WD?-{XyQ=uHlOthn`!#68)I_mU$!hr;#(kj-&!k(Nuy8Fo6B#ifToZ9~i$D(iuCdTM)#$ww94 zwl$8^N5+pFtQN==0QH{eRe9h*)>YHu51={v|DozHfZF)pKTtRX_uviz3dKuscXxLU zQrszC+?^u9id%6h1d6*;q)3~hh2q6&i}dFE`_KE{xwDzczUNE=W#SJNVJ}cvx$1}W6abodj!8oPeNUZVJP|EZ!@?OsS$L=*zze$g z7-Ba7$dx4f&kI)f!7LR@2V|Km_c-4xB-mbhsG>D8xMj&mav?8>Lcpr1kfk9NRQDnR zx|F2~7CWn1QSDbrxyz7kr~Lm_LM-~lPEdMK5G~j=w)B&VWLrx&e z8w@b{Jo(W|JES*~={LXKnO(LYl}hWUbujDxQ^}&T zoyPldLrdq|X<8J=zRCPSdVdVE+38A#z>fV`t#arWN{75$2C-^jcOcU_>ZU1e7Qqb{ z!<)$tzkJ!pC4NgHam`A42Gwj}2GSIYi|Bu*P^c(a|6fxmkglyam^U)Btab51W@zV9 z$Hwb_u29Icq(RT5 zV!pf@0E??46~rClTiVe{51Lb2jAn~c`s_7I>OuX&#hRC!Rk9~e=$UwUZ1`3~yi^9q zMZXw|Ug6zHA4B_*6+%s7kE`H;QPp{kU|KPc+8<8JPERm7tkbJfi&P-^psb@vCvTI0l{sMZ*_LnQ z-6~!&s9R#|b~QIYtX$`kIAYg;t&SxtZt`y}fbVkz9c5guj)Xif;N6N!{mf~uRaG2M zdFTb2B77xa7(8_8AWq@Cf^ESjm&yK%r?ooKktXe;mEwRXOBZDbRWzIDl$-7nhrPC7 zbLaIev$-9bM`-~Q2QyH@rquV5&cmGV7<)e#l{|SE>ZS$NszTx@Hkx>Y^$mNlQ>g2H&c{k)|>*qLSp|B z5U9d&1QyWi^iKt8|`1CSjhggDVtU+N*$aQ&5jAsMR8=!FA@e|9H{>m>j5eB zcIfF=s1~WM+S00;Rfwl40{jQ!Y8FkopI>rNQKj z@<9mN*H$xWI%HQQWcQWlW}ppS74>g7uFm|ZII_ih4NGUueg$ z$kjA>iSL>OhO@GoM`*d&Y9eFV^$c!u-C#Hg-+((QFJTHydURj%oz#Qr1O-}obPi1P z$wOv82$IDCpyjwcJ#5*&5Q||V3JMF*IY#{`M6buJu7$<Zgv!N$umv`_1UC;&=!I9>=5Sz^9^emeU24%?4BM=^Ii z#3*NNbuRFGl`-v5d_#GNBqrMHS0-<6mZu^uyS@K_lgLs)7`t}7FGfEQTb1YgzedxW z9qfU_9RjtWgI`hTm&)&>))LsF<#{oFCwnGYrAv|!-xr2eMevKt(kLTmGbftZZ6^i` zN4MFT74!7M-h7bXmrBYrXVK2k;2eiZ{1(+5QQP3$Rra`_VH zJj`Zf!N@^6#mA>mm9YFUQ-^&}f?zjI)Pu5nFkSO3lQqRe!Wo-=Ji6oc5iO3vx5Sex z3x-4+XjY;rImzU^uVX%9@>a@L?&qY{er|!}X91C*xZpKJIe}DW-qQ+9X>jwRMwiT% zXDeGXGOuj3>Xal<9Y9v6b`IYH&%%BYC!4*GXzql5;f%pdz7w?gr)9 z7m128RKo(C$`!TFC6G#d9%~DW3B20SU3{$OelYr|-Z;wk0VJqQ5f<6bRh~no!T=jS zQ&iM^0#E&Luq7iRW>?h>kXkewtd(c+ZEgS=?N+S*QA=VY`$fvbAwFz)jTJj8pke%# ztJxoH7BYbF9Lur%g7&8zSKa1^PnaVktwbKVzRaM5+aForm-jOzL4KKRmF~MlS2>O@ zC9ctp*A@ygc8fWJ=7$r0jlZGnS`sp{p4qs@3v}Cxoxy&Gbw1HQm@Vyh(a!;iOP}pP z_8)9MuFz1M$5tp+qhSg8f+%{VE=Dqvl39Z~%C1uhDM;4;0b(!=HDgy$>wLJ?6d*ge zKr?p((uU71&a_z>=(8R5OZ%*$5bkJe5r9cF2PwYL)ePiNk)FwgCMmEKy-&_5+Cvbw z`D3U&jCl9nyVJyx^7n&`ItmfW?=U8(R2k5j@QE9d%-rw*&*F-1HcOtgNwIon<0STV z4A$070xD{)*N%HSdVbSBwA66uHr#19f@jjD3ThG-ln(mwBo=UZ!q9^>IhqT4-s%3f z_oO}=HrAZKOMg|mWx_@^(b=FDv!hKu!YJh?(0sB9EU*0p3Hqy~7oX_bRXniDe1qw% znJs`ABq6>a>|j0>f5-HC)rJ#*3RJ3fvEFP4y8@)4qE>9wAuGG&SWenhp!D(=g z*cIGPI$EprqO~T1M!$VYyw6B9E5;TjTzJKNQk-f1qRg_Mfs)BomPk=A=^|=si;P04 z`2|k;*W9V@5OS88mfY}f3~tka18188&2bJgq^V(*_Q+GNu(07pw#wfbthsRqb%Ogk z*#P&;unJS(4PN@sSJdRD*cq!1Gr^_Q{FLT3<7Vs8nErX*;M9qGqk|t;5>hc4KUf|ACxCbN8uQ<9=41< z3>BFwI5>}W^&&Ii{FG-Gkes~SL0S~7-dTHm6A%ZX8o@_wKb?sp)zq{UoQZ??(czCI zy&<&!0K6kUt=7*m(Y{j>%X7pB__J5qFaG<;OA;-X1R!_)AL(3w1A2~8FC~1QNQM@H zrV`%7OPrqWE_y*MEEWq~RPn#Hvc&si_O@^kN-=XE7Xd;LRLYi4izU`+#!jqUc}oF5 z>Kc$s_d(9JBwK_lmye%xlu(>EeiFMuMZ>#R_W51Jm!WA=+57TZPy)dR`5kSEt06B4 zG-FvuW7Kd|pk(Cifa+*2WKTTjnrMWR9Ww?g%vXt5=@`tkB>)pyDB9dCVy*CxDvYq# z=kQKjpna%~TrGP9Q`qsNxv1 zWHSsAr8O#{Z zFUtSM&SjnBuBmC6a)DLY)HVX~WI8*7gQ_L#z5WYkO)@F?E>j*)zbV%p_T{i{?Z^JSH7?5NLPVGm@69ox0I2&2V&nBvI zW4W*1skeKn+}hVzBP?Udtf+^7(=$F|tOSHJAc&*5;cQ|~N46vb9`FCK89JI*m)nG6 zxiw73j$0^=DX|(vL#e*%<*VaW`!o3t4VUvJy!&Fgld3=!QSi(jz14USGw+dx1qQ|ND;(81Co#PrQOhthHvovJ`OK76P}OOB|$gU*?~4u{9;G?qKFH%846 zP>oxiZp&4hu*KV>o9D%(k@*PzWU!o;gC;`*?Urd#Xe+Z5g%O&WfJavXZ-TQOsd%6R zk3yv6uQZ5O69CsB`U{$(9|VWXxQ@0EQrtLkOMwtUUWZ(4GHQ8oGPxki7PyhGGtl0e2S!0aZyt#Rmm^Jj#+=Fx4+2LtDsQ5t_tMG zq<%*K7I6mpk#ppUo6_uTwbWM?B4-q$t(L=KK%IL}^KAD9t=@HE30*oANk`w5_J5u+ z3R^aK6-ddW`xKb^&;WJNb<#FddqQ|&~rH}U?re}D>a zq{GMHy0(f?zr6G=|MT*t^pP~l^Sl4Ph{mm_a<2dI2T5Wu4!|B2^l*ZWXhIdbt|(8X z-w}O{^Lp0M_WUu*ZXHd$H3{{LX(yAYJ*9L1s{h}?o;@9+{^mWl$I|z{BnQ5Hjq3k~ z9Hhq7{@)+czr(8h?|;sog?mA__g-xRo{&C$IRm}a2y^vN+Yb-ZSllWLE5S5R$Uf;m zKr19_TDW*rZ=kzTjWgB8g@%G;tLeZgKjpV}Ot;VeOkSKMpuox@g{m6CAYwulMPX7k zqmoaZjBYlF_Ie3L#c2gaz7}fm-`iadhjCAT*JHHn$$bih8f?zbdYXLRcJP@F4 zUJDM}+DP-_=>lS)+-vy4nW(@?hrFpcbrJ4R6feGfO4~*Jv9K^YsuIT?db{NlJ10#T z^xBP5(t+DY{YydQeR>z^Xp?R_nr@kQU@Qrr$FWG>D}>Z8vP*&GL0UYHKG*a_%8tNytcL$fXvg&%h z2ovx8e9#i+D^!Ohnjyk!=jM3H&*+uh@yTk@{7*0q+Hfg`jN}9& zBC{N4tnMnhd*f*+=QM2Zqj=~pFWbv-JEz0N7v#CNbb=l_nkEmjB+cd2f<=EdhL(GS zeLj^0WpnRv2?fdsR@@HEnkC}*Ds%OZIyfEls%OxVAw9v=5)Uk+bi@h{2RVcwgs+v? zJ7(p(6UaEVvxwFieZMUXJ85kY>v?@P40m%*3aNkPKDw*#nXt|m5O|Yjy@&G9#nWwP zpQ9U>8!YE?33w+=l95=@-&0bqIdEQ0p z;?9xp3~s*bL%s>!PUT5ORY74Sed>dkigS^UGvGCzS6g{M2`Lo+@|E||jb|^OW2lUF zkybtLPugIf{ZH3dVD^WgxB+r0cnqdYv&mUp_);4cD`5FowzucO;vZ~yiudohQAL;Th%>g4(kO0b1(qB!lGTN?u5XxhlZXkfXb?tX*UJGP;mKTnz7 z8m+vh(A^!9b}2i8?gL7|{;KgdnXl`YcDqfDhMfs#=!5aIDXAxXA~xo79ao zx?P<-b}niJOXh{F;{IXpnEt8zvhd8WW8&Th%MYTXOqsa}QL~k0T%PO9RF=fCyQI16 zP(}x=yA$){BZ-orcR2V8Z}lqHx_C`L+bx+*oBO?;l7rWrq<7ew8p#Qc#NL$Hsst{q z9r_CrACsH&wba(udgB1WUm9M^43@8!Mg+G9S6i$Q$GP734H<@}Y(GB>?&7(Wqy z%HV8$iD(FMiKa(nC`zbdXvj6af{4X_JQvAr5Fc=wd(C#vo=V^doV8U+&DAbUc_o6j zKMD^zM3Y2_0R0kX0JK~AyrV)JVj_C0e8kSn!VOGt??>|YY5L6iiq}vo$ZiNo& zv#!m%?lU;nUvGU4miawk>QjAVT_3I%(^vANCk?Gx`wx2F;f+`tb8%a>shTTAD+3=@ z%}eEv~=nBRYETx1%Z+20RxL#0X70 ziQyJR4+j%gSKa$_CXC%dFy1v?Z}ogh*j;XZlo3$RZXsRPle*Q0td+-?h*PgF=<0Uu zw0~y7(5kYYh_z6Y__nHf*Fe>-#zA5$Ap#mlFU`v_`L`7-S>nCWWYx3XQL}GxP8pqZxJ;;Q)WPlZB5pd;lqotTd zI>g4&eTkFq3}r7fj~42gkTNS9^T&BaY35O?`{Ce^igdn<2a(@wNSk6#7i6r?ksd?yH3__FE8+#FVeH4+iqKq z@2$=!;j37ZoA`hu9t}xmpIZ0~0LZEOnGChY=N|yc=pYTgIm<0UkV!IO@@6Fn%CGT# zX1~w~>YBn_1XwlK0UpSt4gh#Do6uv@*a&8H)3my4o5o=(eaHMzVXXv6ByC?(Th!g~Gev*+o(dw;x- zw73rw2Q<&!kLq(FOhcSjwlH#@SLOT1_zx*iegC6d7yOU7np96~2UdL(xU{6Z&m~=W z&qXUVPdAS<9(_#9I+bk(7|$gp!VgD((j?>YgtdeJTF|_@*WR@!48JmZ$Fz-ToLRDR zT_>-#50IDcFUkLQ-*rI6W3=N&?)KD@mwk55W`ov?7zo`dG8GLEYKmh_>kk`%K!lh2 zzCf$AO@AU*u8F@rJx%C?M!YzyLI?x**6YUnn*H(?7ybe4Sy@?`SKn{9DTTxke8yH7 zWsw7>MJX`?vej6KM-33T#HdjFL^hsk1MK3zXB?)H%n>eOk_iMGKO; zMYIlG=|J#J;s+BEBaJ~=&5|p-zCg_>7U?)%U^tKqqd{5ms!pmx$hhwlEjhg?e~THp zu$D%DE6wc=>v6|($|=m#<#8g3=56Jw&mk7h*DL2J17FNGFWej76^SY({ZXVpdwygo zSQ*PQq7>Ns7{=j-nE=tCA3mW%DoFV=3OJm9Hr>Zo2YK%i2#@LG8LX7R~+|)af1}_fE%+VRG|1DT7}F z6>KBZU_^+n3bq24@g)QSG-73v8J3*`I{+)$!pzw7JZvzkVOBfd=H{~{qh2?Lxh`3M_+Z~{OhGP*FD91u zmp?eER%z(yz$r0iBXB#x|#7#S!t&T%H$1}TIX5y0-cV49B&|W z{=o*?c1Xm6`+tZ95Cw@?0RF=)kT}kNF%}?Xjs=)k#yhiY@glNyNN4BL#w+4~kOlHo zY0z&>`Dhg@!Y=)Bs@&XzwM+*bAGvEV5o6E$9cjqV3OTBiK_K1Re0LPT!h8>q<-W+gTRq1d>RHn(~8O>??f{4*^ zdy8UtQkcBr3>5(;0RNEb(MLbkTjZGEUxLnF85e5`485K`Z8X4825}YcFAuwGVLQZr zM+KS~^gzLypW-5S1)~l)cek5{qS%n#tG*9C2^0k#t;Cj!I;1|si+Hl@lm-y3(%Q*D zn9J*zZnV216A5pqqd&coSFumqSMN}JXN)77j=I9n- z7N~1Zaso74*IyTer|gZ62Ok?Rp2@@N99mI(?MF5aT>qT`Tnc72=*XHOS=D;EyxSq{ zlRWEMWS+@s>}@xbRvMSWOLXLO6Ami#Cw--}4h8p9y5Jek7vyw_vX(W@isE|!i_~%Lp?*Gg$4@UBO8qNBL7Wiv6f%} z&Z#~iL0>7euk6p5Rr@itO6nWu7_UR}RfY;-4sP&3j}H{SO13M2P9xN^N0oFGshDsa zNCt{r%gy7e*bzN~!l5;rZS$qc{gju)WAuJu((4p-8uB0NWTI4FlHj*~7Y3L^)Wm)2 zArH+_dE^%+D^&ART$y(>mHlIb-oOKJ0go;w{E4bHWK z_aRn5!%qP#nP=BLA1WdD?L@4QrDWWu%5X$1lg*0KoeZ9!e^jH|C_~^+Z>rW}F(fc+ z`B*i_G@R*ETmexq(1r{vxz}p0ul}Yo;5iU&E0K+K7zM~`=HkTp*iz>{m8Qm@u#13I zvGy-srUzoF=!i%AEM%3Os*ea|D>$(7!8NS-%5A%6K;M+mSIUOEX;*JM zH4Z-%hJ%}EYXmv;2i4#K{9)iT?$J)u(wgm09#`#+>d}|p)|PfBV($kwI=SZr=`7&f zt;0p8qnww)L@lF$>6rt66mL_roN`169@9r$-fNxWBM>WZ6Bn*LC*ZNF zo{STOyM*)5VXC4F*0Z~U?H4n<|9x+7Z|Do=T}`qHj{Balk#8fyXl17=%$uhx>#H02 zl{I^yjc?l`xqsj*4+_LU8c7qS{MdO-W`O8x9BJdG&+1{}JLe9hNkEPEnI4p3r1;KQ z9x6xi0t4m&FpZ+_Q}u!Y^qnIFm3|_QsqUldt9jD#x-g!?v#>A|6vNbkmJDBN&x8K~ z_?~RoTnCUWu(!*sg^#V~4fvtu4FTPzZB$_Dtjs0)VzDbf z9=(t}FAeGmHDwA8UjXh)P&UGQ-SWwXd>=G7R7kGERv^!*bp2>RCjJYw!>s%F?m=EZ ze93SPg|x3!H^&7}YM4OQjpJ41kn;wzG>B6l=gw|k|w)JdI5IO2D5;%HeZV6hB@Hu_FCX% zyDlV@*1l~4&Ze90)!H79ge71mBF@>=Eyu+%ESUE}oNvhP^D)e0ZE7mLURTr#YT}1g z{H@*5sLd8=7%!}KeW{nWOPMgA96S*5h-NF3*;;CG9$K=lh@zu(!%5st; z{EWwcfMXI~*pblk?3XTBV0RMEiTCiEq{g8yYEl^hh*j&-)5L)wGnS6yJ4wlK_!sz4 zE;F?7nl$Aja{DEh*pwVobQ;s={b@hq z0@^iL?7N2XC`Z2Js=|7CDKNW_Abf<@l&?(*L{3qT#6d1(_@d)$>GoU*PCmR`}^tH zZm?fh;|rlg@$U#Bs0grkutp^vbZj@|Q(>JgL-+VoyD*%{A0i!`QkFC5*fkKThEqKk zZPUHYVv;S_r4<4KZHs8EuTTltjB z+$KiKCiJ%wS?Ds5ET`eFP};+>yRjI7sN70|6da|QIc<3;AEPj#vz6{vj57JXrL1H= z!&6Gnt+hg$4Xs9&>JSg~)RoQ=pCa);YVTB-+JWD6%4A8s6qr8hc2gy_ju6~gi>FO} z%9o2v?=9nGE3w8l-us2XtVRD6p(pcB6y@vMfw=+u@;*o2$Qqs2-iP9We%t2f$ps9v zRNO?hz<+>k+ACM732S1zjplyFJ+4!HIX+Dh3=i-asmbcox$yE ziMHu_!9`Us9fLC*K*84<9R_0H9+oFXGo^X~c8~xgQ=T9h2@=e;z%6VDV)8j^+Y2p_w z-dVs3+a(5jls^)}-KE|OwfVXl&|7Ovea72j1g~-bTvFsXk-Y9A zhgs1I8H7%C8;s#tgyWieHByGLUUwXUCm6(NOfrsj44#F5yWp~k$k3Z)$q>$c5uD3x zS1L2auTPxr8{##he#CYOspiRAm1JjWwC0khU2|=MofqlEr3xY)^w%jq zN73ZV!{Jkcg)vLBLi|j0a>(1;P+Lnv4nKrfE2gAOs;hBdS`NRg7pY(TYPBNofe zkGDI<%kd6H@9UJmL;#5{%2(_G3$LAoiuy$Vj%+|w7*AgUP$Ou=mW1_uPr!EM`9 zkwuEm1*g(pM^)BwpTGVJ6mnITYtS z?rnMx0Y#-AByb&i$v(VW?`DgqT#>*GtgkMg#0B5##5hm0w`J&vY@J$@EmETH;=ajn z55S^E?+v+gjy4#rejJ?jjn`Qz?+kv|{MCq7EWQJ`6?pj%fbBc7p(CCV2R*frC*>9P zApWMIv?hZSyAe-;O1}aC=rZ*&RNhFd9`$F{%n>s zJy9fkJ9gpKZQo`=@#^O@$<&I+f_5*L?97ISzs0Co2dPR`QuGV{+s^jYZT&n zw`;CkWCHM%Hs81@*ELsAP5!aAS@64+_Z_!2jgrnM+WI;83ueK~EAK07^0oQ@ZycpY z`2YT8-)7474-jX~^|b4-_7&qo+xXX45f_Te3V+&aCZSo63-9(QE<y%47$c zZw)%O65$v&`8=DDiA%#l9z(6bB+L@F4LEz2wPxE{=KEl`kFkMtrZgqbD%h+@rttp_)YCUf0g5#L*xhiO#duf2~SHeRdH z297iTosv>Tt>iyuI3;hA#!Q|4GQ&?^y7kGQnq%L&nSVhQz?}yJyt23E(epSM$P z+b9)uP4g&1{;!xBpLa{Qz7vs2Dk$R&BNwAH(i;^{R8vS~Ke;fCVM3woH5snSyDszP zeMqiVZT_vJ-%7>eC1*__ANcJ)Z8S zeND(L9&n)sBM~&DgT(nqY&Y%!gvJ;w2hqS-d(2Qq`2h~@#k@ZwD^4FOY4J{=c&NCW zRVdVatia1tx`hYG1iFhb zP_4cc#F^#r1n4Y$I^WJ$%6OTCwt=Nnr4Cv-Mf>2Vy~DI+dPnOtoG@O_^Lt(-x}|Mp zhDfm6E*EF-Q_u!y(&}?VE`Heo>Y<{PPNxwMvsIiyePkB(o{*PbsPs9vqgzkKFQ5DG zc8N%q4N;STYlk$*`6ArDjW_`;u#ccwIsVNzqb5i2!jUWljbjW9lU-*<|I&iOX3D;& zwKc1`n7j2dLI10Pg_n13#0`6ZX1aIXTen_^ZdJ4R!JOkJg%NNGTi&q}bJleyO zJMG9zZjU$FYd#E)ET6+x;D2EUBa>j+;p89S6b(gVrJxz~anv=|TcqC~7GkVDNloTt zLT^Q-WOT$3Kh4Gb^f2ljAFhH{Ve?Ft^v<(YXn}N5F9#>d0WUJOR-RkYfq{NZ=rFXD zUHy;DABK79S}Ma^rt0P*rPh;fKNq1}GLyTxasZf{$^(b7c&Np3@+a?B4{*_mADz={ z7_E`d)CFmU=^BA%q;WwiKuaT!XR~eFYJ%5bYX(X5rs@p%;lLSP5RZ0*uWU8QZ?jGS zNkEab1N@R!vaAUq_aNXA6)#b;#9^osnby4dHIICe2H7dkMD=3+NjgY;P&l4Gpo$rLTO6-fuGf+ z@*eZ7$nQyNT7GSCvL(pl_1VipjkjtCIn2J%;Dr-GB2Yc!b#$w^PsLu=VLoKDWa#?h z-jQQtds=URKo_^!Xtg+l;!x@kITCF+?uy#hAQm?N z^&6ry_F%ILWLEl(JqWna_IF@(R8;T^c8u$E4tjc(RD0jQZmv5>gS%qOgiF$fGuk`+ zGrXTMI%Op2X{C9q;WVt&_VqlM3FbT#Ak41rVH~FWXF@S-Zv{+%g-Yf@z5AO2KUR(z zP3fp{&~ohij7;Hvo#=HchSX@c`-pA|PL8|s5Oqvl*%)5_@xA%`%(D9&?pT^=k1IdhtTN0&XM`u+b^((G^CeQd*Eyp7w>`pn5L{t0cQF0Eyv;YZ|lP} z;j4#Zy|nTd_s?_9F^wtq3<&F6>0hU7_jup^!#y8!bj1_+R;zK;MUT~cISd@swiDgl zh4Nic=^g5P=6SksD0IK;YeLfcK3{}HO9*rM^jc5~gG#S*&R%`>=bs9|q)Ot_7(J$r z!}fK;LbQ6q1P#}I8{cw?tV(@m>7RYvpKgA4|0i3%XLm?CIM?pyGXm0V-S_O^`X}2y zS$BbEc9S*HXIJA|uiOXG-_vXM=CD*FI#evT;%3r_xT{az)7!ta2nYokUbL|q zMd~=`VD;p+?z}3@qh08 zaiD_5%Qm;aXs{eRqlR5T#6k^Y|`f(Y=xJZSGoQ07J1|MH+^{-gf? z|MQ?hNWD5RZ!EW$`B%nh3H7jy{=Dn-UQH3*_U_!l*a#a;nZUP8$lJ?xF`7MBgJ@wf z(vSTtMljX@c6ky7a2n;>;wS!qXelWQj6R&W4@TGaq(t)qes!SNqM$3x-^cf?2qzGx zd4VP17C5@snrs%#^>fgN%LJ#%{^S_J@v-icmB&>)1)5&l zz_TJeVo3_+q8EQ0f0OC9%PUI;#2nNJtB9A0a@52+VvE_*9qUU(F~@h+d$ zYJH$wt0O4fGUblGiei*A(T{^+xmVLMe<3lK*1&s%r5+}qqTt2(f=oh>>}LYP$(Pk8 z(Z@sJ*H4t{RZajC!n=-+Y6ZJV0g&fN98Z|oUHq+m-j$G@N9eFPxE}_Jh6zO0Ogk7= zz5st07Tn7N!%*I6Vyy?_b=~;--!_5j1*aJ zZ+LN|U2oD`N%E~SG_l{0u{ZIuk6)Mw%s)vbZ|a>6!FvyKP|gaoc+6F>jC~}2S-pWq zD`*@M+!6*XV`;oVC&hANzL&hxy%SQM9-^gcI)u?#l(omLqR-|fBTe3HyVASL zPao%>ygbl6ZuQP=tLD<2Bk{@y`+10rAu$-EiW|mJG~>KU^*>b9CYsoJjp{+vxQ`G4 z8_bM<0Q1)tW)wIAwfbS{A|jRclo8GE)+HPZ8cAifFgOVNRdqBzHgjq!&tp~Ker7SM z;Z8dieyJ$D*lH>$Qk>6cZt)=d#tKiJ)%QLABpnXL!b$QMSu`rEy1iarPdFa28NmXqC7p~}qf-pZB`Hlw<}y<*-qbK=y~;q%?VNmKx4zthV|oZpj2ixEFSUwn zz@X4dz1(C8v#OZ*;1Nksf`O0SrfXVgyu#fa`ZIs-UV{qL+^UE&J(Smf7xiSx{{K!qJzsjXu@O5*@8!!xf^rXORH=OhT)_#lBQ zKYTLhTM`VHGTG5F(ED?ouA#IP7+sKsy6mP5Lp@VP&A8>h0j%{M*{#hg<<4k-b6NCj zyx=MpATMcuS^A2$=!S{pFT4b`k60MZZfy=y01yBi==}ctKv};w9h7?ZQgGOKrGu;d z&aLJ;S)bhFcy}pEHHIZpv#9p3{Wo5@QQXc*W|TKaPkw@qML>OH6QUNF2YS6g9II z&~xc$MpF^%j%LgrYMH3&FavyBRM_!rue7675N+kk`F3q2e+U2j)U4=97HGY3(!iMn^b%HX4~f`Q5}WfV7NE{hO0HT1jGICW#kFc#;nQ-^`0VA=imuTe zqsT$za$(cxstP0c8*RpDQ_l>CQC%w7D)oc2EeB9&H3 z31$6jB-Y#UzP1J_W7)o2ujA$R!AMVeP6j%@f=2p)M6=x2el>EBUu|`4EO-uDmlXVn z3(G_y*LiQC*_dUb{cChBSTTjDU(~$zK{PeJT%s?P0=jv2-|g!r)>PA^SCb4?EGKi?vAYwSBciXf1WHBAvEh_ri`S01WK9 zU5R^V?<`We&m?D?KaB&~D2nCNE;aJc;dwv|w5Us+NYcdF$xONOR5Z?Sm{p`%*#}a1 z99>s1kni!s=RuiK7FRRodOy22*usSI$<4k%5rAhV{SD34-nRXnxCr$x3VY3%qk-QX zw!n0Ys;p)u(Sbb1^G!K)qEo9?|4jU;N&nXsU9aOwwOD5kCC)rs0w>nGF}byO9`<6t zkf_y4z_(1!3B>_Y)XqOGBy72KB+`qP$sP_Cnu`XiLt66OB(^1pPCU6G%Ueat{vHym z_iNR%FEAnql+#)-S z)djY%l}EQ+BAwrZ_O7Q-;wZd8&j z8vg{kk~F>Fq;1Y<=~9x!eVtx8X#;A z$HuClr*+%Ja?L#IU6&ATx!m-DfxqEOTTn(}MktZNEgzt@V_w4pxW6J^U|^4E?tb## z#~AYG4BQX9`0D$3%Cd@f_)~Y%2}3XHRW&D~o_i4ER8nZ?AHaePhb6P^C;qUADoR6g z0UZ+{{nwL^j@x(?3AYK!GC^+OGbE5Ss{jvuLu{*>6sxjhW34+(bZ63#gC-#w;R9Ps zBccm790l4_13Z7F9m&*6n5GzUOunkAMiuyJ?oodXr|4A_`U{qM)-VDPjCVJ2{@t@} zR*;qbW^c{YBEa@UgwPU#R@$E@J6sC{@;6<5E>t!%8_xERY!GlajPR&tS&jy!Iuu~4 z|4KKKM8UzTs5+tu@+o>D2O(L#_&pJK=+INOj%gh#p4O47^8Q9eBU97z390Oea(?4+ zCWAY9lAKHNVH$7m%cO&{9}r(#BUq48OWkv(YmY34d_*PaS9sIQz#C`(ANNCSANG1s z|M=8WqG=Ay^Wa_ppM8)UF|w|CCcQwy@4mWcNIRy%7-9eC$T&2!p$FGI0;?o4v%8_q zS0>?sPJI9)#%ckvB0H$Pq}q!^tv_Rm;c{^zewKeGiU*II;GR$Uo{Pk2@)@7DZuZAe z=_0>>Dsmd!Gr+_^{9qW?5BzSDF3>Z`g(@e*_~#^f1mPs*?Gg}pJI{?@t{GypqAT~a zr~<&2)qid-K&E74+-Cmpjta$vc_ax&iy;yQ@)F`D7r%^tC6DBMhkgJ+7hJxd;cq22 zaLatf(zmu9(`4WGi?~##eJexJMb|V&;;+Fk1HOY(n$g?>rG%3j?~zF(8D#S1_4R$* zSj%{%eqx{3s==HxA1)}bi7nEJR@Sq##}y1ljgof@Up9$1Fvox%&_nmp4_MXF%g|q9 z`VvZf{%R>9bkZ0!WYGF^g#6gEpO=X{_J^kWU)xWrJfG22h^x{lGZv-DWHG&hzoojN z-~A9sKR3x68+Vw^NF3cw2VVxzd)K@#|VFQr08B2fd~ z*QR%c6$_J%`{AQ-D-}!Uq#rqEV;``;z^^xjD|G0jRDYs(+tkMVfxM2KdmVp@8>P&E3(+j=!Mfrh3gt-TBi^z_o> z{z|UmwzN zW>8*PK3JH`b&^pZoyV|;7X@dV1DHrU9^Sbx0YDbGm^@`apW-bdZ14QE=-!#=J4AAL zqM=tej6N_i)eQrQ<1tcrco%gzg4W+I{v-`it8YwwcnhaJbLhK#vQyJ6S$l4!*ZDS9 z`CyNF;yDd1BuWe`{B^?%a2-fN%?KFK_*VBOxyftL>}I-wTc>#M8&faA-KawVO`*^${rVqBa~DLz6|xF{wXz~itQ1yl!?N#K;E>W)-CixUB*jIPGrmDyIv;MxlrLrO12T z<7k?}BWd5NoF!<-<+mJ3gP zG1M6qzY~Bez4X~ZhVcMNoY9Hf8aY~q3;_^#o6YL$3Z=2;f?g-GZ?aN?mjUOidaCn1 zRGRyhxY^*OW8=Y+$IN(ep1Dgla-{WkdOTJ)Gn@o`)s zac!@yQZRG(q9N-Gr&f(sYBKDK0Kszv`4uZ@qV#^mMj5%MjND2O&x#6(?C2aWUF&7p zr$P!V$K&`2&cBP>Z2WgGnS?@R{3$3ihOAtW-~#S zr*G_D1-Jo;ZL3wwPvPkeNAeVr-JyKwGjOVZ+khz6GS-%~JczB-wkJeKpzz8lc6n$% zT@u(uSnrhjFxL}FTEEIboGlKeBy}N8*O1d7EVla^hEi|e_aNIXw%*CyFok5ozwB#9 zO17LT_tZ=omN7_kz*DgYV}odH<8(->vK@iO)9we#?MLxjdpC?yG7iVogOYy6?=)RV3DDMQUBl;s&rQ=R2MBI%Z9 z$|;>s0?e@N5V6!T*@GQE;I*qNnUN;@st2v5E$@E$0jiL9??rI3lhX7!I91Q`HFp&h z<2pAXH(xP$;12;w3E(v*n){(qNkP_e?-2%1SCg=Ec!a%fMv!1d za7{ZUM!3*BkNGmwa&0p5irss zJ>$q8$2GPZ(fPsu&`!dEDpleuB?Te8}`98GTd)S_xMn5K6etvEWNR|rv58w_lHFr0@;xI!<-b9U5 zOFOTymp z8OvZra_`7 z)iy#9S4rPq1-J%B`hvSrq2U-!%JdTj39 z7!p35Q^(CP#4dtR{fPYpUMYIcV)CViX0F2yRk9)1u79HmFvqw!6rgiPj`YhDEj7wg z@&)rG3ZS3tf`6Zm!~^;3FYbop(<~1l=Ig$BsVlL_o=r@UNrofYiC9OlgJL%o^ZfwI z8OT(b@V#Y>gqQkorm?k?6}#0!+>myWY_s!lm)#IODgTLU#{8Q4C%)2Hm+Nd2h;% zuegJELoPhUa9=-M?e+NOc%(_qjzC+wmAAhA5;AXbIS5`Fd^sjST57W4c69x>ljOXH5Zxr;g6My-qWd zC7{3L;hCd{<7DCA4XoVOzFO0MqYsJo3GgDdz|=Z05H>!Tdr3yix^+`ie`wFTRl)KB zP`BmK4u#lX#~x`u(Du0yq5_!CzsqdHtx?$ngG2~{#CF9J4^W2O9oD3leDYj(!TS!7 zEWuhvvbn+hqKUb%2_Bz*`{`w#Omw9-xvy=1K}Q8j+sXvt1$o*{)S!8R8z^!fGSU>$ zMADnM(oI2Fr6PdU@eS$(Gto1d4-ar7wZ}%hNDj_fVqQKbBL2=-_Ug>cW+s#(CA{h@ zMK_{pa3U)D8zRJWv7eYC{)HK^S!z24n>CAF0~c4~n7e$*9ng$XJ=l7jl|EBRSB``6|C*sR!|o-{CuULdu~(Hk7(wE_G8l~; zU8?(KXG^Vljw94DK?nFo)d8c2tN7S+A`?@;tmv%zDQX02>w}IY^DZo>^{`@^ls3wU z@1^Pa5|aMo*S$BBUcErd&)nAc(GMYOM}F~j#^xkcS(p?3(nVolbTQRGhj*42P+6v# zd9fcQ6rw;bDZkByw6{4{}Sctzat3(e<10FpHcl`GhbE#TDzLuzzZ}+a%&|ncP3=a!-`5ik=n~yj&mrD z7njW7H!82uyuHBiFZ|MOJkjXDrA>?mr+J_Ix$}44DOA;ro<3a)r&Rh32{4(oO`-0M16-aQ;C*^xh=S(8k3JQ5iHX-ci33PyL1MoTzInIw%JEg$T4 zn!*_zWmA%zr8}L06M+KHBMy-9o4vz?%T`>&=Vu6XX%Kdj7;tUCz*wlo_cY|H(3Zw@ z-_Lv-VKvmvt7ya6{ViYhL7sgu0#3$q9&zHX<7^BFktmQ*|2JE$;nCb$fJvp}-K2}j z`dbDhO`f1=Q15Xd5+M8@MMN0u`pc6wDS`6ie8DXkA;0>Va8kd>HaDOG$Z#-ND)n!O;n8e%t)d!XvFKtsMs=cGmg9N1 zfi4s!vs)v#8v7q~(ADpq(vtIFqAFIaN*Am}tvdbI@=_*QNtdJN@;WgN>oVLrb+oI< zhnP_6!M=`#!1lx3v~mzEo76-J#Yyc0qW!62zb?5>sbF0C)$thFo}gt@Cb=l`6+Ma% ze!dgpV>fR+Gl-EAVQ-lso60V3J`(JQxjxdu(bYKcEXHLQT*NTFQ7&Uu62t-5Ceg5% z$3F_52ijd{JiA7|WK-&N(@cI2NF;UxPG(`iNPQ;WK2z<0U%68XDJHu_%+%{bjC_R6 zty#0EwRROm8aULwsi@$wD92hU4(rrEgh&ye*_pZm7+K8${(J&m9wv5tq{mmlH7hwY%XHiY47To4XypK2EuzC zF8b3pJlEW+nl;eAK%D8|(+Acap?{-G@Upnh=oY>bLP+q`0ah*dM-#p4LkBseNZAU! zNJ|e>!Ai;tf_>jBjDFL!r`dI_AcBY2lo<91wN*nvb>5Rt`@V3pRVQIgiel3rIMfD! zbI`!neDy7+cGo-M=O2pnzv=03_gP&5ci{wn-y?ye!g zsdmrG*%s1;bR88@(UZJNhNcO2zK-i}it+z?REZk2#(th5RKh^_Z`dDw25L4qlz`Kj zP%N#GaRi+lBDwi?#AfUl&ZQXu^58 zRVTycnn~CoFQZU;9o(!l4|;|~OYX|PM`d_cu7)OCG%WpGxGADmK91YcfD~&RrJN}$ z*Or$hc;T_};~QzyXTqc6;pmpq!sma%*YBPY-?QJ|KL5Y-boN6Z)*Enggiqkd-8a;^ zA0&*#(gY6H_r5=h)ma-QRGenr2%4qEC4??8U%8`Fv?X<%ZCAhDNh4Kr>`lnlxP*@? zJBHzaH?v80{Bv>!aS3->in=H~NGRc`*Kli$eU?$*H~CSCPZ@*@Co<(E*4U+26V#U) z_8x^-4bPV#9>_Rys<7)9GYj>#XdLi!ue;9Zip1C7Cs%IkbtkN5^aoeAzC$a&G1L}X z4P2J^zlok$k5L(OT$W4_gf{#l;yrP=<9`VrdLbyR2Gpa@Y-3*|0$4PU&Vx<=a0V25 zo5McOEzI$wQ%y>acawN#SmhSCEid`c*e*NJmM!U`G--s9>PU_TeSDf5dq;sE{{x(V zqqctSoW=&`G5tMMz+_&2Ja$vA?j{ht-XGz^xiZO6If_NVU% z$NvE)6WFCdlU*FVZ7vwA;m4!2*i4{K@ZqH0y)Vb_XQ{}nIcA^k=}tt#Xc5C5w& z0;;dQXL!WME~r2BxMAoxx5-bt3*P?yx60l^?q6b|OibU^{tlz)z#v^NygpLqF;vN> zib*^_A2^=c0ZQ&eIVi>GRSc>8vwR4=ML;Pt%*FWe%{XGM_ELl)+_+IOq>0fi(#|KS zxn*&5DF%9}haxdUkXSsz97LWCvKO(T_|W+6qq_h8Ly-GZk2%+Mf-ULI(D!egNxBQy z2}B(zTJDwiX_{(Dk3rG(1O8LsIlbzYR}B+&U*z*J?4%0zNY#J1MhA!WAnJLgQY($9 zXL=NJY*TY_O_Qaih)?GKZlYKpT*sVnz8ZMmH5-P;M@rrcf07k6nOpgEAd1$H-7iS$ zHEMQfs?__VhzB4j$sUPWxV+Yq18FD zMz&g4C&6|C7{7NY=!3*UsNu3(SoTeF+rd{M2>AWRZJ9QxuFgovA}Ru(u&@loqSk4D zUN0eZ{?%Tn=y%=6SM`=pYKV~)XBtY;g-1mRj}FM;!qj=cMYMOAM@9{$mdrCGjIK!H zWVgX@Tny@?B+&4@z0IRZJ}X~zU3jJsk;UK;xsW3dFsvL90L247zgr8M{c@KH*n8cx z7(6ESoq~*6HV@XVK$lC0npU%;;fJgvQ>_cXk(Y+52R1uY8{=>;8CW*V)X&ZNyt3=O z+Tb=U6XO1yEIoM79*wE?jW&?AzjkHENB$&d&at^@_*UgC6+1)~|2SZyXG8lfG7FAX zcs_oda%F>Z=ioOdr!xrJg_Mcvy2NXf5W0pYD;b%covBLRWb44QY--uW%~AZBonIMl zGFC738_{1;n<6O@(%Pbpnm(N~%eA`qNG+29P|;gBpd8Y3`qMf(x*2$9VQ)$xk#5fs zndNA~1VeN#RKx?FSd`mHur2s5?lv=L7-0ZQE|vnQ;y3IkTU9}JH2yv#(&bg4!%3EA za)%tm>w@G*ac2(O_#gWcuaUpmn(t-TOyDI|f6=X1=M5FrmjqxIo(=cUK&2N53qtq_ zX^By@x@5JZn~B+<-dP;SXGV%jA-5MfzM8D1{S1b4hKlU-3LY|3r!QvlwGwQ}Ow9!_ z%bnw7z#;$>l5}L$w1;pzt7{)X9Q7kSB`nN+X6+W+W(QeK2`6=JNgpOW3i60xP)bIw zaO{T?thp-f;Jbc6@@D9pnb z)Fa@$$>0Wf{Aar45Q; zSH#7|Fc7##;QulFBj^4-V54ZxAj1Ul%y#V2z;U)4>^=G0AYvS?h%JSia9yxJHD91* za)3p`W@V%Q3yvF}>L)?hv^0lQkg=zxV5b8!8a$o`)o>^tVOFIt$~=O-k)uA>R`A)f zYb?lMk_Tx}1~2k5swV@l|6`jRy(Ykh(k2^q@kG8j1jP0=rch1%U$tRyR6H@Hud^M&p^1X<{Jl&d7fN6*rt(+ivGuS~aWDtljkp^LBmZ z0K0z^siU0ErD1d8-Kny~YRd3;rzzDhKuJI%J3~SoG}~(Ky|TzN_6c7^1rXqH;*Kw8 zbQk273`(w&>b%XlN`%R-%pG}VO@1JG;YJYHK)lyVn$DfK*!v%aWn zX(<=JDXy@HbY|CS1>yMD!{G2I0_&9|LMq}%A`$ERpQ(7rYw*;zYoGLazbY|cC4LAba)vu@(ciU?J}xXXkG)gjtvQHFt?2DaC`UOCYa9@(YbWI94i zxW7v-*q;?$=>Uih`giraDoD3tRjg4vmb_VsYUC&F6^2$k4BU$(ay)%%?lG9U=32tm5QS94Rm z@iez?ty&*Aul_~=&Ipn-vS)juIeRRQusp8e(iR9O;e>QdwvRus8OYFGBHFAK5vydH zgaZ$t7qL{3vhGvbz-5~jMJmRxM4?|Ri4`}Hc#lR>W#hZH`xrx)Y1Kk{q)BTAC}vAM zR#J8T0vGxn7`h|&HAaqdOJhs7w5XNMZr8N$lTNy!MfM{4-qvk!?76(_8IiPE;&=8u zvYh+qTd_uM&F&~0RhToz?*I>Jl*;Kp`?$v@RYY^Iu1PpZ@?C#ku6E$isAEL!8=YDl zzESnZRMI{4bo0o<$8a;C#kw#KuoMUzjLC@E&tRiN_Z#= z3=iR8#^c~qY;=poB10 zF_Z@t5agGqi!v%L2QljgVH5?Ot7inAE6Ti7>9XsZYChrD*t>$FMLW>(cfGVG+HT+0 zUfw;^^2!Fhfd2r$CAQu>@Zuo@j>l;;$PN4T>L{0t4??iv4|oJ{h}R{eJN7Q~$eo%R zL4&gZfuSp-CEM6KYKXHi9NtN0Yaq|S+JkQH(vZ?iOF!2#dk7nuTcXdPxE4m<4XM1Y z^UeSP?w_@rP{I+;A^kou5ka5T-?$_)o83{wa6dJeV_vx2O6mkie;S2RxKOMg32Qs=qMlX6C z6y^NpCHQ~9SDSi&P^f_@YUPvhaD3N8B@`Wn9K;M8rs7aRsWaA*3hVflqOia6P31Bs zq=SaKuySglSbiOIdwlSGjR2B~?4NR?IH{E;gctx^_`4>gwB+>(BC94*@ZC_n_7+b` ze`&hE(LTeHRk!m?*!VXeRWl)JB*U#{??$LKCV+wM_o1MRhbbQSva{aA?o_obj~LsZ zn=DuMo%R&pbeNO{cMMcx|K*x%)AX?RW^NR)+L9_opcMaa0*@{G}IFGFeZ|#HX?DYS}EP( z24)~|BVC2M6gqr{7&d)Tx{xyX(*>jRk)N(3Iwa(s+9+F<15-IFmK0t(CP4~bdpgB% zi_1DD?kx=X`G+-7JGRN?uxu1KOh`vAA+_}~{X^|7GTtR%!FM0S|HJUIGxi~t)nLIC z)K?RCpPxU#zvyP@y>t$ga^GfAyno`W!_g=aDFHLq-eFL;9EUC) zx*|mn6O;lQRf&Z|GxSu#YHT=l-G|d9)WgRKM5ZpYHj$F~BXhFPYS4E%IJ4-`QKY+t z9&&XlpuQFla5U6rR`?t$8Y$+QxWn}cE3tXc&Doe9AosK7@nSsuq28f;hcX5vVMP zV>~Fo(sM+5sI`P7Z8D$AyatEe-3yk?6tU2WMs!EsCuKj4z*`auQkUDBnd|wb*rai^ z(PWEQprz2lxvDDjjv9uC0_ZNRzda2VQZ+$Q9GRSp*9}j_*2VinFgpKD@ya1sZKG?$ zFEeX_x;v)&{05RnkTI1C0y)b|o!ekOv)hCN7e4CKsnI5yOho~GIV!P&KY-(tH!3!9 zECUKG;D(5KdRhTE8v#J&dq6V+E7=&g12&v|!mSz_4hRMccecng{~1-rya7V_n%2s4 z3mMR8rnr*scM16fL_a)O`HhtKZ(Gy1=_|X@Ip3jA&?Bm~YDG#(L*_9LUgL*PRcrKn zTSN+)Jf+zjHt~_X$SAk~Fk5XV0*_@)a~*n-G;-r3(=x{O@g`E>%hAxY%S&{Uh-vfD zM;PnBlzu{&g{mlVTN6707CL74GJz&!)&w;^@Fjq?H1~BL%7cI&i(p6)g0xzLg`S}# zsMA7KMofvC$@Z87wEZBeEuR?XTma6)>IzLnqQx)J-BAj|!s5YM>QpzKmwsf1MRR}Y z959rrvMZURYIZ^q1i|Y10N-g`=(l4;({N>uqVW)Hkn~xft$7L6Xk|~YG+U=dhU3R%! zeOw;&kB|vL5er(@aT;$$D+E|a5LZMAMgI@b_`mgtpFd>u7?erK(#IdiqA7G{$cS-@paSJ3B(cZmM2tYu8idbln%`?Jtwme!S1z)JwJ6g}$Ehg* z_$dv)_EC>eK@rvpXn11*p@hn~an7nMT)a|!B6td3k`(k`RE^l?w;chjn| zLIouIHl1)Ki%_WZSme6gc?@03GG(a~pDVx!0gbii16w)`G)HQHo&(H3Skzffa>D%f z=ex#Yv`>fJ{5*;}UPYVbP@D-4ClIEgwlBcg0RL6Yx% zXqQp2HJ_@A5Q}&}+c;M7mw3%7bY6|_DqSm`a%-Fglh5dJ2fCLF)OvBe-WstO!abA< zN5jRE5-vtjaDzXN+5&=&<*M8V!>PU7%-FA93Ecb_&J(k$&)uOkvUJSMKC^ zYMrCn^Fv58@jMDif^c7y7PI<3_9WC-WFnLE!=IrGRu?}{BkYnw*MDQ@>ZW+g(&U4R zc`%)P2{#o?cl~z}GXCvH`r6GL)IS7GF6R0bk9>4=H{NXAc2%heOBR{%E&@znPR}r~ zn(~q!!Z2hljC_H$r@e9vApD}ta+Sr`rrdPr)!td0U0#z7zMzI)I z;>Jwa&&Xto?U$)V88HABGKgzU8u|2sG=U0|4Of8&HdR~a7(Qu8X$mvMB zMT9yV&dr^`n07pL7K$&#UyBPXo2Ie@Ac)-T?1L%b+l0(VQPaKk5}WoXP4)e5qC5la z!qO*G?_G`a=HKQ<8cnwl9^aMDYbO!Wt@LdyvJreuETgDKb?8v{>j?2N{Paw!*)3s? zqEcy@Mcl`uUu5KbPcNk9kBY(*0NYO&W87f~jGGnFT734lI~D@)F<3%~r;vVG@v2%` zS9@Lnd?@ybq%mJQ-(b|xxgC-cKog@@p;B(kPCg1Zmvy%{G`uA1ai*|`BIRZ*!M)YE zm$ZPzPMy0-_1{?sbDXq=5T6%*JM5L@4{9aH2 zoZKo}nM$%c7CJhI$zLpWmUKvd&MM}Kf7MjrkLeejmGho|CTU`IQll0Z-AS1Fs5LCa zV~U~&525<35^?%nM zj4lTHHLrKDhJQQ?4!6onDvXGmF|FORS(7(}FqZ3Qarus=w%e5<#ps4?L(~AN@WUtE za;jD(2p@27J+9@kIV>CT7Zs{@6V5Kq;rv(YsUolJp-K4hVgJJvDdHw;(3x4jF8_4&$$3M@O84|~IV=CpmAnk! z^Han@a)t>flze&+Y`qV-dAug|nPhs8wBdTN!dNS|VLk~P-bDZ759?K$I295AN4V~2 z;zup7PZ~OvU8Iqv*D5;1bTc5_(vT&BD$ygZQ&>0ft5hX@v2G00hML$8<-cZ~D>Okh zUDWguG2u0QqWEa-VUA5<<@%RefuBpmb7X&rJXQeMrCP32{BE-9WY4^o=Bh;%-8Pse z1NM=%a72BNH`yNZ;f&BpbbIafs*m)KuV+C!v90#)PlM~O+SQYm7ozBuOz?!VkL~Q7 zEOLq{%oOZ>??wgitk>^b6Sz2fBV-eN*nAp()Ql3#a*#~=`xcgJPicCWhK1-PU;}DG z`OKUJ`W@e|u8vMpxda3dU~_Y-$E#SCW1|V>mS793uBcR1nvW*77|qIcb{bA$lA@TS zN+k-11C;FP!!ZRDV>Q^&@^yU#57g7HMzy4^K7}cC%)W&#L-m*k{6-3BmhfWQ`%J*Q zIY<~L#5Q-i&s=EYg#QqmH)|_C9RJ7$Re}1E2+!Qa#n#dV(4nfnXcXyZrRIozap8P> z(wJsyB~NTzdSS}Zq^7e-Zi-p_VSg~obSd1^-)iaj7pMXf1kfQN=4t`Yj{|sv#ZZmQ zB(!DuN127YNJ?=e5X!RB0_0aKPJF@$WBAvl3AleM7LK4PV-jUVb_SWA1et2YZ)gMk zp7Lzy(Cjl^6P|E~9Ghy1Sycai6@W`{T}Kq_{5wg=i+_KKQF3|z$+BaYZu#$%h)Lxs zXDSJ#<$RNv5JD_+oy^sS3mX3`dY6;C={RuOBSZt#_9!g5%mZ6oZUI;R0}vHjBRDQ3 z&X|w43FLANOmt2^`YSQN z@p@YzIB90cj6HdmD34S48i&kG^H*EVu=-`86G)7p6{b>c%CdXyLx;BcrQoU8KSfKU z!KrIKh4yRFcKxO}L!z=ok^%ho=%?*C2bXG(@!Shm4(x-1~f=B+EqNUIs7lRWc3tGn4=bgnVMGi?i*0S@8gc4Bpt2J}s zuuGP6{g|nd#cPB3UdmC_HM81?f(F}Q3L0pq4gIlU@wnpMv-H1-s`$u(Y;Ddwbnads z^uZF0OIW%QEd6I7ae1Q%o4jnAEGmv=zIjx+s-4bG@uE<|m_WAVcZ-F_6q=9=@Y$k! zqC5>B#mzgEbmD?Dm=(seG?_wlTO6eV>YC4mO+A`*azHi`tPaE;Wqg2d>39az2I$Do zIlJ~uCX7G!B`s`6b%-5uVEHaV;izYDWN@PlVE1R$ai9MHTHTCWBZsZ&tHFYjhwWe< z{_<;bTL@Jp_I_N^br9WDvY?U0e*gu+5CyGki@yVlvS(2}?qz?YeLEmj{uM_{W7i>) zdQ*)LP<5pKCjUtgAY4BVqhfi3K_l9K{3aBzR@}Z#GZ3kY*CjlWhgI@nBZy6M6c8MF z^qf$lT>>Pc*Je_N zKhrcw?tW_}-(|pIVwe}R5yOSQ+yp?V0{|bXgC~`y%(h4umio@8PI7ujktC$@8}aar z@V5OF_JA=)8MD2}h46Vn?r>HF5SyUc&n-~tgO~|8@<&jX`MX*Q_v4-Kg;G$U zxfizJ!@K_g426$9lIf3!y#yTA3#JJ<;CifLZ0z;7x_@{7`*#Oi8dBnYuJa%8RAn@! zTr2p#8jV3J;IcaujZs5Pc!6cc`7`)`c=p07#SDryqsK99zQF@!?idjc58x)um0BT! z4m$@EhRrkLsR(V^!yo{#aA4}#Y1LSH`t`F{GBxYBFBQl^Pc2@3>U?N(AMIQu!i| z;mPvF4!i^BWJ3tf@4acyA^92>S!x6toW>t+XPYPXk|^W|KgBKjpnzi}7a8--EEQNL zD|}w2XRrS`@t2e=U1(tP;gqCE@s>t}jSGukt3TDJn0+_{CgAz?@G*ZHk|^kLCoD(KDWu z*r}?(rZehd!%jUvLNxk1{cStsbmQSXY?jRp+jsfskE!E+6Zn#tXQg&UIKy@HX~_99 z?n7tqHvT}nO;!-q4vXJr5=I8 ziHKQD;y22iVWo=E=&{SVXj^ow?|+laRQH&DhiNia8ue-*Cz>nY#kT%^Me!gnM8tz8 z<$rz|$-r(fjiM+$q4t?rG0c3|cjeWmdye^l9&Kjs1R3lJH1?onfo0L+1ZlNQ4*hxN&hJ8E`%H zt`HU)^pF&f`1@;MqIp!h-IQ*@r{CQtkA^JI=SHZXRtdg5_GCo#U6YObzn4hLNn*Y- zNO0Dcr{=2roRDL&1$YqrHy*@M^Oi&uMi+{i&5B1`){mlRVHdU&0VqcuEs;`gJ+{q-d@)U)GcLJ#)7Uf zS9js60A>|y0J1?vLCwYb!pggc!xRSYg=tTjOmnbP-(#$tZByCv;GY?)h6vkles9ep zI$WCG&*n@Ozbu#75}npBq?nBURYjVM|US>>#4TiYI|S5`6Cs|d`M{O-w3!$o}FUxvm5V*ZVD7k&S@6!)bwHAwFa>vg*{!b%|ZucE2M8jom; zyq(n2!Pw9xKScESWXjb${9c!Mrz`pQ5kj)YM!J?tpjEi2MtM+0+s=3l+_Jt#Nmxs+ zU`xLJ`~j~BY2RanwJ4o#`p*5<(L0rWa5r$EVC};PU?{n?)44HjP{BLZc4KZcajBOD`0vGh*P)cX za6U~bRqZL9F=ZbIFArTR2oE}!TPTIuBF;V3SqCa%AuSUAFsGzLZic`#mT znz_Ym&{_F#QE$7RNxC=zfSL(+XsJ~NPb@|?T2>c(S(BoqE7Y?nH(K!%hYv9u?)*aP zG2$`#mBd%t%_iQEDV(gFBVy{g3V4F@)@yLJwP65*f=m!`Ds7@zRmts&{86*kvO(MnD zcc3y0i>``Jd^YRilw@4>8!eril!u0qk#%*?$)zC+Q@Nt9CN!B9&xnj3x|u?^y=^h` zPpB_@xXMB<9K4|zX0mXd&%xnbI&dK8vYB*`DkPUu<3P`2fsIYtTrs8V&3aR&P=qYj zzhhAza1_Xw-zE1{!|No+QiI;;J#|8=*hxkAt%(#Wr8&K+>ihD%d*~y3t5lIbJ$n5c zwot|uH>VbM+pNSOtEtfw)&Bl*C+-FvPb>7Vi>p>#p5U{_bttn$=49;Ye*hm*{6RI` z;L_`8M|8CSa&KP zp%NrLP*p;_qqd;{~V)VR*h9J)JU1Ue}V7SC8WTNuc1b`UgnI6HhfphR=Yh&Do8N7-Q z4$7lcN^}$W{fnDV^H0Ez06ssq*4CANV5&^$U%LC=qm!_D8maZtQ3E(iLJoA%AGH}4 z$Z`+~rwLKF)k_u@@4Hb#Y~2r%mvtb!hy<}@9|rV7(wmf)0deM=_J2d-mryQ!MZJn+ z5q1(d9%V@^Jp34}Fa3N?9&xhz4|KI5v?w8vIs*!Ninc0|W^U6`$C2;m+NMH3_}14) ziP3mZ{WfAm`ukk|g}eXR#vTgS5yKJ;dOZJ!Sy>*NOF5WAzZyT)r@Vll?+?*aY{|K2n8epQr&9e~*#f7x*G6~ocqc8u?K7AoY%lQ8I=x6i{+oJw=s ziNq0;9Bm5 zJqc1F5o?yMsv!^Du&Gnt(23-b;O0^$kp+w1aByl?OI`HZ-%NDn+8w8f|e$nue5ARm8EP7sq#3)ai!MKhc4t` z`31)B>YmkADkw8g+3M~nE{ier2QB?p-hQGrqa1whexh*#;Dd{zmT1>KoY}kW6gt(w zu8Q%+$A$N)OTsWT45y2v;@?bWLleJzn5Phk!057*VtoRL2ASW{4Pl&O{LEdqEe6CMOJcb5kK`!|50%ZwaAmw3HIB->qsdW=YZ zXftGhD#V!RSv#u1idYF@`&;jfk8DRNY54E8IhDIMv*uNJZsj3HhQ3F!sNr&3AS_G4 zS}8AQ&&RD_rK&q|DRCNhQq*VjPJR{3;tT5=6niYba*GVzl$XNZT6p`2%Cb?2Oq@lpp zPwS_5ljx)ur(+;<+_BfS5S{_GhVC!e%#;fUuR&j3GVDlJ)5O$Y+Msun77(7?PEvzf zmz75tyqdj<%O+<>O)?y0ja5#j2TQP|9fH!1nDaPgVw@zT^>xkOQ4s4`yfTKd4^YSX z$}DXe3y7xYPLM7Fq9awSE^3>eo_;4KLi^@@`i8c#R<*x zly@)z3;reu_H}Ll0mx=#e_Bfm)eE(YEKfAh*xmO{eoPv_09;(YYq}Pfl8`Z~_ zXNV+Yr#avqEQCUNE5Bh?qa#-uG?We30rw}cN}U#(NC8LaBW>y>azn1zc#cr!{zDRb z%|U*{l%emGxbyyvkMB+Y{7txrWEu%UYe50Yj3D%&Vn=j8>o_WHwzrp4Ia6v&H~SND zHMo16A$pT_<3kFt%CXlzIw6l5nRYOys3bd`P{fiCqnb)`Gle0A(uiu=`#OS07bWUH zfa{;(E*zyVL^sbzJt>v*db~LYUsp&yW6)lkE$JuWxwCO3h^a;~5wA?Lm%tS(Md}uNMnbqU|`R?_FY%{dYybz=1^!`C35W+YMC*` z@=am^CD(Y<>e1E>MW5@;^bx540Ueb~z% zhV}U_>@QvQ4RO;OKP$d+|9SE={HM}mKjn|gy~2i@C>~Q~1e9sA9MRUgm|S{8f)q7*1Sv(slBL{@3l5PHD3OZ&IC9Jt=3>p#=icWznz2MWL|8)dM*A;)pe1V zpGITqy3l3z+2gzAFv9P2VJea3ph+MLkhaS^_yr}~J&AM2yadhoKQ&SS{N4HdHZ}6lB*Ix&SjCYMfg z#P*^>*du$yw^nlC{vN zFHNPHJ-%8tpbR;K%C3ws9hnx*V^+(Py@Y zL@1QMR^~cxaOJWwVO@7iyxsWjIvN{Tz_jzPf5Y4XSLbetX1P7jZtgv!sB>;;dE{A!=`Y>Y*eJ7Z~pTj$7MaK%&-C(2OhaPCY5M&UO0vWru}e*jUJ5f=O7e4@a$ z%K9zYzBp^=nz8z~{Cz9m5#!K_cl&}Kvy7tz1vy^(Re@TOG7=8cZoc*SivC~p$TkoL z;eA3mdCSzKN~DkJcq%drGK25RCKqZDHV=Pv5@|!_YRCM$k71rURxE_Mdb~yT=K6Tz8$+=oO5Y$vcwOiahAzxH4%olBe z&f*I6n$|ZBp=6HV?caZsP@4{kJ;};h?O~K5JCPhG|IKO2dgS)9z4YOXxQpieo0S=b znmrFJBkJGMqI4A{&R=CsfHG$d1a2@xQdQ#qYJW&R@dVgFcDdf)waFJAMW3%2;2=58 z?}IdSaR5)@ENLL(^lW-aTt2^uV{g!xBQ&dW`>T=CUy;f+miAMjM{suV@q2S5LuyVr zkMHvKy^n^2tDc-Jc!k`$X;`9 zzl70=VQ0}V9@XB8)1C2C;(kPJaig2=cr_0cYFy%G1cjYnHR(q2$A4@J3Wqs2{-KlGB+w2DTl`q0`$OzplbKOMM?(pyd8;1ckE0^AQH z@VBj;VFf{U=CCPEqXRyLG}R%jQ5YG?2ynHzy>yr!l)^U$VniCdZwuj9))KlTP|Z%) z6Hq3~44MQJhhr7Zlt&br@ulG))5bU3Jc0m->2^7#$~9~{_e9`%$-=<;{tM60DS|)6{oX>cQZn9yS#`r^3R76XI17R zEo5=5mELlzc}>k;&aQ17v?4aZ3;bY*KtsO_->XT0t5vVLPo%($sBwR?MaI;!qXMh%XBLEuq z58m~P;YrnSoC#If;GfVKHSYjZtUMR1X2(zXDZY{xD(W)$T$G&~-X`!uhh7}MYP`A} z1Cr3ICIfOqQXq@O8GM<&P$>08lc2}5c~KzQG;|Nz_umE*g_1>RtqMw~Jvbl;*jrOa zb9LWrmqY_F6jtWhlEzuq!~a(@}`Mjl>87s-7_7h7m#{lUiwZKjKh1_q!># zfTq(7jKUPo)j?HFIa~lUC=g3*1hCkJ$|r+hhJk>gwC~VHt&s$3Ivz>0Vlrbw<{eosN)pqN)wU?6&zqTk?Q}S<83+fHlRYJ(~`LqPk$ZY2io! z%BKZ}D2Nz7!MFf;lJBVul_U1PLv19xyfn8HC_J03niG8Ug`DaTx)ogxUyqn;F(E3F zG`E(xI0%Vm@&nx-%ZMi4KN99{tusi1Gu&t{?m zxUBMM*s0uYH)L7cBHImAH%;Ph#v=z*7_|)_Zu`bL`-xgr)3%h}m!I?pry&sQM117_ zxG4BakDsM}m2p?UrOTE701v@=`W<67$p@$GKv#)^oK6@J6^7q>(esU|co0>KZ!^gX zU_sIo4SGCl+gL;hkU*SumTmz6un!#od|*I<1vWcQGSZ|yo|@-xyNoB5h_5W?{7_Z+ zAlf=`;K2p3za`V*^MR5?3jDWkFD@d7Xoj|jN>k6q1<=T)X~!3S`MT-z^U`1{r~uj$>(X!3#;}8Ap|@LCo%`dvSqg}Z)jQ@e zyeyjqhaRuKBwk6V)mfv;zc2a)8}&S`)YH4i@tR^qioPKbrRdK%B6$dCi@U(r90R&g z9;CZ#*7}*hC14u^$ACTz)*8(OYmZMu>}M}&IPv3;S1pmK`7PthzW9D85DfsFP<{7; z?P>HhIN<$focW2r(roTqVWr3m;OA%4ynyvN6ZwBlHj<}2WaU|sd5b+2iz8rZe5#iR% zE1EPcC4B4rHIQIKe9+hO;afTqwuqugb3L_}I%-jhj@??HI>A_@_Xk{>dj@MX5mFoy zan2b6U9*(B-8A_zXU;eV%XQp7#zY!Hfe3JPGXe-e03Lz({{Y0#{7nA<#LrRo&sp}*QTEULO#cAJ#w;h8_4*~vrcs%QPS(Ud`pR4G zAgWWXBU^*hDRc(7Dv~dK+MKWYoB*7OrvpGzrfQ^LlLrLCDC5VrU2M4~U;qLJfd2qe zk--5+q{S-D>F3UHp#(J4*T?$y{p?BlzYHx|El7XeTquNk5HAX)G6{bD{{XBY6loSda5x75IPvZ2mq%&CI>ZUYSSkLu1?~DS+1m&d0v>(Xaw5!IM~fWgKyi3_FyvB0HGO>p?!R8RF91`dp&62n3BVHZdANQ`_hi|K`C;SlwX z-TmBMRCcOff0OsbeEj4+$OIKR))56>ed@;ClCD9194vflzj^dBbF>vfJD5u^I8%M& zR1xoq#0@&Yk$_#ke5X>VyD*wJ(;$F=C}W%jtC{}T2N<=lBc;yp(Y3ZWc4=)T%f?~O69&qZP|k09aETm!+G%Jl8Rm%kpi>pjfZobBj>;sqI%(Q z@nRlwLf)9pD`4)qaTzGH{4ilo)}iFUcPx_9*H{`LUR}-^^!JSMaR>%TN>mjv!H_K^vWkVL*-(J&PGwcp5e!a0o-Zaheq`mfX-U zlXTJ)g@iiNcw#H6ON9Ygfiz0YNMi;>EQ02|gJD^*k`RO)fM6D=V3VyOzM#7D5$SPv zGpTULm@E@!%fb19(>>=zQzD();aVzXx6}RxoFRE<~tX?xG2Cp zB#HUP4h$IQCEiEY!f3;ntnOpgpNvsF0PoOzWg6`9igxZtF5o5uE))qk44BPHPw2}e zNO|&L&qHC5fEt}27;7B{T+@K#SRr?>Ix#T}(^KCG)S5&+nIc0}4)Wo+0wcU-m9Tkw z&SOF%{@Aq&HY`nhE%AsJjuEe=->3K-{Upb#lCa>#qC`lL*dg?A93~wpwZ4%X>yBp! z24hW}Y9%1SO1jydFZ*$50PNXKm{pPlpDrR0gH?P1&N_f1zL_p)L*>>XdK~eRZwrf8 z=_vXrC$4HR35dLFkE27brb8N>lJa7xTd}oy$(vpveo(;NyY=caWeliKc;Y6TO+Oi| zQl2m=DWj~h+u%n94X(?Ppe*MJ>ku$N-z&kc3=1u?DEeSIN4N9iDHKi}CNOO@abY|; zeRUkBP7gU!PUdt-1p@3UDUk^Bi9cx3O>_Z6z~R&c4P_H<@()sdkA+i)g@i*X^;vb` zRV}n*U^u!kxogpaCH()NXPJZ6!7ohZs-n=hog*p%+wDQ<-4| zNFOo0g{X=Xgji{#?Av+H+D$KIAoW+B;qW3sGQl*z>wWwmo39X3cLU(B|I(gku{^2my}ai!*nt5L~1vC|^RM^OM)y3o)_;GH6$qQYv~>3Px@JN zAE6=X^a@N`59ar;I12X^BaP#VX`e1tvM4PEl-R>u<2@y9BiIeMQLH(@bb8?R>=N=F z{{Y_)8K=bkK1kqQsB3uu39VdX?z3e<-sNGYp(K9*r5%X%NM#G;Nw|!gLCBU6QWbtg z5L7_AryzCTUbD1G_HfRV!s{p`Q{DZnS>750TpkZN-wDmm92^`*bPMP#QZOQ94U_}} zde2fMTyrjhQF|f${g_M`@rSk+^NITO8L4D$1$izJyG&Lu8p*+XM8D~nKvzs>z6fs>%;}8LhKRT=ec*3$kM@F!!7?x){GYGIrftrm=ali#V zHJ-*3;8f(qV9e{A6uSImE^cUgF-H^5#$YKhsVuj_&x+{UbCn6F)0cwdK*W`X0LFFG zkW@>~VFVMri+h73f=$TtY?A=Iqn_NF*LyM{4gUZRTQhdOUf#(Q*X+*uVIXiiKoSy0mOMi&_}(IRlA||MvnfIN!JpBV5_79 z_i&Ja1JfQb{A*z=#yD@^UJYN+uCew+tzNlv2E+m^Va{()YMe<)b!L`)qQoJ zN1q+3C@%pIg98Hs0s=23DXI(t0yYN%0{RO8`CVc^vrzqggEW$n5C!@A?=0vpPyQ}} zc97I`1_6OZ{qF(=$;!d{E`)NCk`sg4gvCK)fne#F{H}lyL`qcXr^niPw`Uhn!t?U0 z`qcE1t8%8Ocw@~}M(;W-wjLfF%y1MmNJ>&TKnBnbts}V|*7re*DGcCv0VQge3J^s3 z0o|{|II{BJk>)OyW^!@1n!Z}z`up``o<%~UlXjAP+mAoLxHxlhaZz{UV@buH@1bjV zL56|t9Wz`xilg^s`u+3jiys>0oz_4TBb&p%tg#W*7%uN9aq{5!__z-%nN=|e{QGf` zu=IEypSRgwFF^<^%^UWrC+IMbUM_fSf(!NZ)YSLeg|YoDw8~WJ zR;9A_2~ezwaUgQ)>J*E~$jY+xS;Cslf%y%Drrqm~HRwD^x@`9hNTF;US(P@=>Z z(M*uh`msY8Rx8lyGU085b#|e-AveO9Q-EdSBqkFK0)AHCr)PU$EK^Ku?EL&Z(;K%? z&=GThn~Tfb`Z}y&3N(cS*mzUWx`?AK4oT6;(aKXgclA9X_yk|7%Q%^^m zk=^!~79%r*+PJB(vNBv+r15IbPh5h0_2!SCRWo;{}&wJ4Qjuoed+4Ag?Vw6c~~GpII1$*fbA|Nq|O@Q$-A8kX5rd zni#DHV@L{0DOzTejJY|2K-J>E!sLUrvg4 zy=+G!g@$GS*0O`gIU{U$u4(l1qc|p3-tl=3OGSmn#bsZU@sGqd6xyw3M<iT?41 zxbsG&o2x6UYJD!iDeq%R1~zzI9e=YZ@q_}-pPUR2GBGi7g0(wCz&AFCcm+b3Kg(jj z*|m609(>XEoEM&gL9y`%ix|C-)r18bJd1IG;-4Nsu5l;0NwS^ZaE^3wH5^sVQhZd! ze!a!xI??w1_!3I8RQLB^5X)UPtJV~xauF2BS%0NCXX7bNW?}JdzhDhZC zRTN#695Ys5D@jYDQbFs`C8^OgLZKk%ct$u!V7Lvtw={9a_RtT+ttaRpkWsE+*=nQ^ zLqQ4t$MPd$#rqKu&|4YK2}WC<*RJ%%q&Pvy+KYTQ3B__xHgxHYS^3K_@L(PdPU6&4{`t znT~o$XG&0sQIKb5=Z{>*M+b^Dq~;zD$S|0#zVj=eu&Q{sIxGh(6omT15iPN1e{_8n z_Xe&thT6Yhzg2mz#&*3PEN@TQ9N2%}=3_w@?@2ZxAt;$3FR>XE4$n7sNsBb^1Tww5 z?6Jqp+xS|cjn;qvcIkfG6Z zol;c+*?g3YkWsJ%J`od?T@Q;Ongf+d)#~#*=!;u_Fj{`?L`XGF5ytdq0GfU*E`}JR z76`o|57Z~-YSz)oE{b3=t_2Vjoq-OdWrWfg7*L4{p%|j%phvdYgx`+oxdF3`<{J*~ zT`}jWu~Wz-6Sje_-oi-Wpu7@`(^gtDwMvY|*=;CfiQ=|AAMUy#A1x_6o*%D@(1&w;0w zT9LwrMnN3x%#k+pR)*jMW625TVxyjzw`FJaWXaQY7dr&TX}JnZyfcr%sK9kFrGoa? z2YVA&^P!(~r1_Idba2-l>TZr_cu#v`ry4!6rkYi~uWT`A{F@Gg4;G0;ReVC-u75_! zaztr*YlMomf5hy$!2$*VeZ-fVq{3xU_dRtfhN1} z3vIe?Q=D??1W9(QioN-KY~(sNl5@`*fG;~9y>DmdM4u3hj&u$8u%dcPm?uf69uZYl zDX;&QbhGAy_6$$(Cr-u*^S;jhZ!rr75C*|FqOw&{HaJ-hwFVoWKb*nB1*PXVcjiIQR+hh$k)#7{ zMI`a<3-5~$zh#U$2o>3j4}LY4NHy_9DYM}(vy+1U?KO68MW#S8!eQV` zK2rw9ysgA@d-685as<1x^*CmVa{c?j6gtb)1)h-;CPgO%WMtNKy%xaPh%c<)pxhY_ z0ikGsTk8cbo=x*#su+G;W4Cj!$O+g{t!Cec{tGO7EG!Tf$4;R4iKn*gX(kpZI(@QE zdNKv>|CH`8WcCvJzCrO7V())b>~r^MTLSc4Skkcv~Y5Y`eI(5#U}&umDfG zIiY6hmOIspofG`~l#nL2&2i#D*85VXZ}bs0%-i6J+xdi7z^$8)mllCxnePp^&;%t6 z7F^g$^A?>FnAR4gZmQ`LK!;7atN%*I)#p^N1 z&@>&8~$I+Kk?ePr#{ z2je7WPfiM|LMucwcHUZjvK9;BMrXnu405tk92~rI?uX04V&tZPP!KppsB)B1J?9f% zi_KA9}i8h!S<)D<(NP=tz@^sqHO!!t~&~N&IXyJ?9mt>E0dmuu-CPsneQl z4=9*G$=bc5IAKZfJP{xvEjR(JdKzeDE+{hL6xkH6#x1Xki&`m#+_)+?Pdw8gwiHq~ z*SO!F3!O_NW&2S*U2l8?DLmA8EuQ1Cqyi#N^WG<60Y6gIDNRUUq z-k61}?~HfQtxy#G!z7Dk3d=VqWbnz`MF@&L0pX{0BAq5Dzw{Q{n#r2YYe|7Q4V zTkI7)k^&nxcip#=|1e^mqfChcTQ)#^0gi1Q5lBk5CnVFpQy4Rp7*yz)TcyhyAJ{cr z)Pq1?ul=&Cdb826FmyaYgvLN+24tWbXdKjiin+=mk6W zcMQ=hBxqo-D^P~!JdU`GH|PeuY`0(b2l<5Jm?3s7G!ElhA9o#M@xLQ`QYnWeBxS|S zEaKK{-osLOIe{W~3!;-#uZM?Ktpc=Ug)OFC}F5?nT4d_7^B&OHl)=5D?1sE{xFGFKW2Bg`bsl$^Hk#zv6UTCibj|Q zE=<(}7eJ5=ICEjdG}sr{@waDN|INOr_oNiMKy9wbBgm)7XMnotL~HlFb755bQ>7o; zL`|xzWZ@rZZ@nNtFmlJGR8UTart1p%-aS1(FMnoQZ`})VcXA)=H3ssZd7sDM-}q)k zs&pI8yv%NtbD{3hAR`lHR4t8{Dc=l8faApe$qK!DQ(9wW(-evTO2Z<-gorn_Ip;Ll zPa|Oog^;GXXJQ2Gok}(#iU`daW`YDqR)j3h;x&^ND#{KyD_Or| zYc{7Tpoz;4aT=WFIlXGY5kp{_ z@=S+c^ZH8$K1eZJAORDbYT*e)b&6z;6uWq_mbGpP?}EBU8r!;iTTS#*79*Vbct=V@ zX(+$a)~yS;?E2Cbb+y}-Va-YbrWUF&ktCFBdD+Tm?i%nz~xoHiwp4&B*OqZaHNo%CRr>aD>;{nApF`Q8qg&{ zrk=@@s*3Pj#?T(%?4#8e$o%8a?;AZSK(*P`a)cXdLJQ zmvD&4#Lrf8i>qU;>5LSmlchUd;ONUb?$ln@bw+ppu%fEw3qOH~{OATq`usvA^~f81 z`q;0Ax*CPq~7_7KPM&nN(`8eo`+f-%M@Z5fn<~>+G-1>@TWE>;?EtcvEN(U z`j$8M$O#(`=gf&^VFE_%EgEqyiSNXxQW6cCnSQx5M9>0dPXdMPrz92h;5E=?ifQA| z$ac8lWcsv$HapGR+c=H`U1Vwlc5Yq~GI)ks)Uv^$B&)RwJ`c?$E-%2;#x0tm&( z?~e`n%Px&+O5l*OI*8Z@CM}Ga^C+3Y5$lVx-fdT`*cz9?^u((Xk;5Tjb70h{TOB7l z-kZRy#E-XHvi64!gmBW?OTw}&-h6a|lPgQ|5p8QG$Kgj|f+V}+ zu-XB1hgy{xt5tW6xdH5uCQIUn%ocm77n~6|wFTwWE3lpQhpvXV*ow&WpN996(8Xlr zV91sORuS3b#id0>BBv9I${51XucGUJNQHtHA!wyMO$Zdua}QN(zf{v6Mx(u*LQw^_ zGldZ+Vfn_?w$JsNqH4HMwLk9I78;IwN)SW(l7ftSXf!o42!+51TcESEvWjYJ5tm)H zfx|@Ee7ol7WpVq^EScx^-WgKzm-!KtvnV~u3G0;HJS4%7pJm?}EHjgfWB}e^-Hp~$@${GMdpqIQ=%c>#>_^Ud zK9}dLrhC%!C_G7#bn+k+ctsvPLcA<$OZ0v7J}yRmdN@1eYT98F86I6lgK4xr5EFr@ z?(D}FMC|I(O4C$2 zhbopudkb5$i!oVi%SI+hBGbwn*GZ5MaE>?LJO$DpTBkZLZY#I$WqtR}p# zI$ZQbJdGJ1e`hkSN){N%-CqTs_YWz`EoxJftAV=}K{nr5Codlh53f{_GGX?}ytxH> zhBl6-^w(djpOt@6&Aj5%PoRAS`s7UK$mz43I?#49ht8exmXBr<@~F6MWn|aDd-)dE z?9!d7aC2zPxt5RoXd~^u;sBWy94oD^IUR|z3O~oCVTt;g=QdV_%-u1e$jPy4e=-Ue zh4!JKn8w|oRHXRQL*LJ~iKL_OvBi2h30NsWs~|ekV-W+-jV2H)E^`OYE9a51{}n|u z*nGnhA0^~8YY%Cq-(oRpXz*l`(6Fdfv@$tGC8cG0)q8D3>n*a(ZNIW>^6__vK z2anSXG4wl<@{9scH=(emtIZw5ha7=Ll6!3k;!r?-#*ytg-runSaYl)AmCDvQULDaM zvD}5o`wg11b0t3!PfqA|*|K7In-Q(`0%vCQqOijW8QZ9$`QrJcy!_%a9n)25SZ0%yQC^#-5p3Vo!d)Zr-fcVbMpx&62pW6zqBnbg{=r#=>hzzAw4& z@gmPtd(-pE9_MQ_S6W`nK1$)d#rjU41v-WH{_X?jVpXcrZ|KBAmSmD_I;20ivZ~Y} zSTOAfuWxTF&E7m9?2Mc%{&pD1EnmnK?elrX$AQ!w23=rUBwh<+f$|7)%P)sN!e7Qg z3C`grqu4R22H{~<+UPwJiwuR@3$ZG!%6tPMX=PBcgE4de1=T9$9^SkW6uWy0#0&I9 zFEo+1-fMANqn}b`e`q7ldG{N6aYwO^xZiHakRBsa(QAxO9Wz=lSC24fX|)f4<15(V z?m#T-jU~Ga#OP8MEY%$L86z^5G3$8#9R_;c6P=C}n)f?Rgtw~T&=;F4d&s5h_xQ18 z6WG_eSVl=)d4{|&=mY|wR=mEtWwn!PE{R@shI{6fPRqt7YuiaR10HRXj{isIuoI41 z5(!8-ndFx!@bjlJvC!IWprkbgCkGHoaJ#Et0=$=gsqlyX$BeK5~9 zeH$|>rpvy_mak|@ewsDNW`QL#fnn zJ3Dn1+R4{fqa~+%J8iK)48C$)j&~?<@Xay}?Fe^{+{nfwM#l`lbkKO}PZpZ9m@vy= zjR;wTTO2wcm2^KGu75|iOpW%)WqDYpz9f_VColJMmGKc>tBMkQ83pbX0V8Jx|5DZY z;b?36i6va)^WWO?t^*+ilOyNhVNAzKym->3rK1R5ir|F*c(Qtw6m(v;Xk<##&dv_W z)C_O;*==K|nuN4!{;|DT+@(;(O04i%3#M!KuPo!F;)aLfI0~~c6_#U_&+#ex37d6h zg4LgRqvLU#JCSQZF!UlaO6^_4l_q$@`0dbNDZ-Xw$DxhnCx!Zlmz|eK3wMy%E?_~X(MZZhQkaDz2RF+?X5A)NsroPO4^S zbO*tOkn!3}OhO`+P0pDGI{YyY%!s!*F+thVC-F32^1itrIJ6%TQHX($&pTxhd8x^2 zgQ;Xabg^sYpU1`ZBOT~L=R(ca(oj>_B1U1Y2GvH<(a__Pp$0nAAW0e|_7+~^)o-b+ z6eUSxa}v;`P2;xv0WKs-o!kH9TO^4mSEO((QfkgFqR@G!-!A%tgDYCHQ z6W9PHDIVL6gfEWJ&<=I-qCCz>FU?Q=w z2%btNrwK&1-aJCzb5a{g`8G%fVgcj*bUmF=tjoh;+B=;X54OqZx}o*DMh2W}alZzt zwZTpba=pRhxF)-zWa+LWy?VDHJyfG*`jspiS$KOo#q+m4vVG5?l^3v|;p???*NAOF zsDY6Ffr{*-_XU!6{jmD$BA2kr<5qZJ7#BPVUUJ>+_!YCkdGSN`l4t6@nL&mb{Rl*P z#Otwy?psVHY-92)zEGA74H7@P4ioUFnPF3jTJI8^WG*Ib1bu8Cij89KxbHr2N zZwb?<8{h1T@!kxfb{&f?C&-rrQo@%oL}0yEJKYItF1DfMm@8-xZsA&TjW%A+Jd762 zmhyY!l-q~y?^zRD+CLsUBec#&OX_xi2L-s~wp|8Bl`=y*=t5Pa4PccZQ5Gkrfg2XO zp6lnzm$%)F=Bu(f8R}4EG%=^ORTKv$szdnN}}iU z@K=&*3_a?n6`S96@Oof|sJ5^HY@nhe_xiC15)x&`5`R4Y+Ng$UmTEM{oHpfL_Dq6v zBd1?TcGw+M`nFA5KwQwn6rc&z29#l{-RE>|$Fg?-uakk{oB`IL?}JOx5=n`~JxlF1P?R;JdQra)xI~7`^Wf0kQJ@IR`n!)v7@${83)xX3GHM_Q`znHokua_bB-+1@G-e)c%(YyrY z_5i?9qMMf}>>9bxoNwh}`(k+L1*q3Hn*DW(VFWiEgE*oyy;%Fp+jb)qCV`+ti^ilj z)YDxI2dCG+at-+K<$Ipy7Ah7hRw_NAN~6{5o4Hv3l~&e%dOE4u?nAWo(a-0RC&7K; zG<=YL^XKuR>rq{AIR2_9`k*p>aZ7*z9s9Cvk6vf_Fw458M1yjUk^?Y6-YlNlUkH&R ztr`|i@jTA=#9UjRO+)VuD$xFB_jtJ(tVFbGlQw?h=;h_h%g|>qcF1hg=75LG!9OvP zO5=H-7{2;ECk}~8n`0YF2zMPEAj{4(y6{_-RV)A*OabdrRa%x;^Kig)66^F_+?zjC z7Go5l-+G}c+=RPfHbyXf?Yg0c_GyV14e|_puQpgnR1eUB7ahhvtA((JMTtf#^1;r> zm7YW6uo%>dC^drcT%~8x_=d#UvOT=q&We*Y7VL#iPL4LX7mKrDGK{eQ_2g%_2w^yKuCID2yb*E3}`vIYg|tnx&$ z&5oZL>zc*H0X8=#;+zGmBv;bHJ*kBpd8(0*OHSSl!sW|}$j`H^^zM5rGqc}QvZ)&W z{Wu}@SOnR9C9x++t=Y*`SRFm zZ)ou=J~9%g^YOe$gLdcvZ2=po&B(z$GJnx}bLswpaG;gi`&G^`dPHx`B{}^R zY0~wl?k^?qLEl&r<@eh4qHgDj6oJ{rL@Jyq3mPzxd?t;j=I*;+k!`1Tdww2n-$Osl zA}yo(*PS}rnx|z`SeN9H=^K?AJ_tt-r)!N>$tAR`YA>+TL@n!B-_3Bi-Bl7`9o#;M zd8uKJg*sB17P3ZHMirnSy%MueW8hH1r&$*gVz(*oMJ!1N;a0W%}3Fv9zjt1Ak9N))Q$!ofjWt*(s7NND2f!~vmg73#lr zbm?Q@vhr!aQqFz>N{s7`kM!IwHznK^I1Z$92M0-hQmcX}+eKM*_YVw5uYUfa$AE)h z&^#}hzaD!+>$fl~E2~ho@)$-aGn;^;#~Nz!wOJ=hWk!P)qb{Wm6;2$*_s*fIX^Z(z zS|TBJ2`Dxv256iM4m%LmH^DHak<2m}3?>eG1O%^R5^a~SXP!H%>Nl~q{xo_3DzXR0 zBIMU+XS|nPa$zGyIuhg`qJ0@hOg)yUGMycq_I{W$UYH5>v{ucBtEk65^*azvb-R|9lokJTQl``6dZz=r0` zzTk>|oV&|se?j7;Q$k_{Mn}sG5xjUt)FjmOo}WrUTq4Z2o0R+CQpW#6&lH}N8NBqRqSugS>BkfRVvhoe7pFDU(i|} z-V>-7B&bx)io_WcuTbxCgM^3Y85Umo&;N@g$G@LDB%!MJnwZ>tg15!f+IuRvk64sh zw}PPJ+#-(F@@Mt(D53zkx7md3ALd5hOrGpPEL;-e5KKcc)grP%G)E!yyiD=i)fq)7p}rd3&d8z! zWh(l!>Xaho3P%qQG-Cr77vSt2NdHJUek{uombm!np3{;zNr>U{<_7@O52?ZX3Y1m` zDGbK=d#V<6a{~<=6}J@GhDw>#9y4qFHa8r-f!J)c{3wPOr#atnt~7^fg4{yPq)Lw{ z3drtxp&MW{FuKM6X~HO8Z@f%j(zN1kz*)nL6&52mg9Hg0w^tM(-iWNu3*Mx}+C{Fn zJvuxSlHuCQA3P7$Olh=!%m_s>;<>RqIU6TPW879nGU@VbW#c|FhKr1tvkm@q@%vs{S7jSOh4bcCr;V^PH)zJ@phEBgpAn{~pXXHZ3C4KApYpeA^7 zG;pER!G?ZCPJtWQZ#DeiY!n5!Bc$cL*-)zUFffUgXKBEbHMT68*e|yVFhmCLSIUZ?P?Qwb@~J7x0VDS!>zw zVxUjQ6GK)iNl;E2H=C0#7G|TNgC0bxW=kVel13FnZm~tJ0H9OZtcz=52*cdI=$s?l z!o{5#Xu6%{?83xeNJmSbq=^w|w8(hBsIRBViy=eCo~?*hTZNAdW!*5w)Y0-RC1?jx z-qz9akk3l~L_-lLYPs0urfOOOFO;A@FoK4K3(;cY zhzEqsX{7ud3Y@QRB$?BcY2Y?)oP~Wpzx8`${qgxaCwm_31Gk`D$9vIGiF`c}2PeZ! zzmD7A$U^zYGN??hj77vNPsR-dDTuxpD+wvZH)a7KVPsB58LAJPP$;got^y2UYZRx^8Zs2W5ViTVEKqtxVWStL8&)?BI;=U z=j?^;T8QW6s2;m`g2;;k;XXhJsCQUwN@R1w`e`kFe?6Xc+Yr~a9 z-)VMOipmWeP5H^5nE>`*X|8x+K#Ce8tT5!Xm`eN>c#Ji-dTFW@n0XvpDs5_KP;&ZS z`VtE%CbWenYGmm^W~;|vB`MEJ0SDa9anDndO`o1?dK&SMGIrV;d@VXEwSx|4(VIJosmIlIgnXI+V~jo5(eoW>)hc}MuNp6CMp z$XvHQSOeZ7K~%__F#we!KRCQtF>Pk1gC+YeHwRd@4@O60mfd3iZ<&TF{SW?>+oXWR z?abkIwMg9?Xa3y6W`1Kn{r|b1ga@!_Cr4UTL=(vYtYIs&oMlB>=F~?XGvZ(r0^`n) zbjs3d)ml=eIwK9V(g!H9;;8El^)}-RWYK{4fR8tXW|ujbYUA-pQ)qgf-ZcXUO>Y-t0Zed^>-zCN6_9~VU@?J|A0E{{zL zv6QG-sE`P@_6y+oylLrI^tH*LO=*vLFljzQ4V^}_6Sfc%BPS=~vZDhKciA;^Ac67c z6hQ`}WQ^LMQ}i&2FwHItraLG|&-eP#HiyAb3%p5r?oJ*dm=YIGk87j@<;WM2J1R71 zK=lo8zEgGBLfy&DbhTN+N=-CE9(}p>%WnYEh=GVs!okS}*ehN%{0nQmdUn+$zHVEw zg_NIf9m%HR(0R}FHB%VJ6Z&)XkHs!;KZ>gjrU@=g%rhh!m5?bhXFGVIGDe=2hvdpGL8 z)B6r3-~28JsjY(5Z8C}l&SyZ9$Tr-?TAJ~mPq!`4ldsf8`etT7Tf$%Q-E=H#(U9QDx09=F^&|2xIzkjPV$c_YH1TEucq ze60e-N=lJ5uym;EwQ?xtaBShKjXWA%I&(Kz1CZ&kCEib~+Ao!q^_^{sRHt6tFR^n5 z=r5|`2De}Mg|JY>kW-y9o2{3P0=%kc{>Wk*gV$si&0jm%hHFj#FkA%qAjHR_SPdQz zfe*(Kf1B)Vpr}Zdr5~>Z1nV_Qv?_{StLpKwu}A7{Zt;d!zx`h%G35F3M6$H!5w&TS z-Tbd!KuZEpVWW@%!bmal`9+es!lf?bg6vMS(=J23ogu0#w{q7Y@k>WrdK$A8ttZdD zF0d`E+B|Q4i{g0P54>fm?BdIEXZQGT;|5rYG+EEr$JL%Ue|mLylB)~{VJNTf91l4# z8} z|LYYKknff8b+*L<{-+gg!DOLytuZ-oY&Bw{p z^J(?d?dFqTK;dQ_i#*)E6IahuJEDWO^N;+u?b`WkFsF8)z{|42O>?eT&voL~$P=&M z_0?B;@7F0u#(3@aBEcItX6=cw&5`)$v%ggIoHp5|Vtq(_M7Jkbfp&Ui;P0!ZBL$42nFswL!S7yA=2?GCTX0K@?_THlZ>*&7@ zM@04B&(;@$t2fu2Q#Adzb%8zWuPgc`-7k#K{lP2a+Z%08To91-0?No#o^X;CsySS$MY((3tPbv(gjAsNmGiF5|IhwWsV`UiwZ@sgLxKN zn8V?5F#g08+cD4vUFx2AT} z5rb0NYW4%OzXB2cUuBMAHzUyM{LlD&E+@ARe&l~(Qn!APUA?d9-^_7*ap=Ev&elJ& z5g4fM;qyNY*WL^(d=hQFR>s#a^XChEB{%yV%}`)fcO@wJz0g<`uRe@b`&=*Tn{Nh% zYC!7n4SRoGBWBLz$2)?5y}T&giq@t0zby;w`r7-jeKFSMR5q@+Kioj`f2G!5k8k!eS1>5z24_>;sK=8F$uzifsr>i8E=_ts~2f# zgIK|6C<52o@??(DY{dW)GF!-MILXNY#A40c7*>g--)5KBf}_5cG7X6+Lq31&H5wfF zTZX^+r68^y@OU4$*wii~J-m^qPB?(QtGYKhTU@5Ay?0C?E=4L|2xARPqOBm4KF?{4aTSkzqaKvQM&<<4D zQ_=AspFEy&ALY++R#~L$077ASI)Ty2Gc)sp2J%~h+x2}rvgJ>!c(SbZUS)W_mUVS{ zL>U>j6v#~LHqiWU<0>ok-u-&6H!<&KTDxM!YTr1odds4>UN77oziOB|FzvTh{O`C9 z1s?LdPJ8|Hp%DDi$2;v0KL}pI*U91iuSfHDyL%r|Y-E}m<}eH`QxJRK4##arQ|F-qEh!&IOz^yYdfepDFb_uZZX`<^dGL@t=El?|%{VOa_00mc@dSYU6hW z_dcgh^1B{VUrDVsQ3dPfTb zV{SuL6i19Zmwb6ugv16jn;lRXH+0y@HHV6_$Hl77i?>9_SnjI8&#%3ime$O2w}urH zXD)Ai3tV5ODn(+(Xq@7s9F$1wz{w!C7cZ+!Rr(1s4(Yc3BltxitTwjZTVBIsR@hzZ=7aT+$m@BG{ZJ6Ja3a;S8v1g+fP& z!#jh*8y%J_UGZ6W_wZnNKYOw_-!0^si$&>4Yy0;1SKegyK5up16n{;`Vx~M-$NPr2 zZaBd6rSW)|Ja{F4kAr6H$D?ic=vRlY)>kGk3wHRvKB$YR?#`PG-KmpMq=B`GhJCxy z`qQ=?UHSg6&$Txf;~oUvG7LNUUk3;OZb}6XYdKoXyIHlM$=x@mx4z!+y7ol;<%w)i z!0Au&XlF{IeGQVZFO;&?cpaW}U36Cmp#<(xKTjL;&He`IiL?90Ly7ae+^%m0l)`8C zyg~GEx$%G9KrK6c;0Zv`44%49dLAbd)SpD+=Xt#&(LSE&d4Wz7_@s}re}>S-Uy7tT zvw~`_jkeltjGb*gfN}#VJQcqkJP1itkYlEeF0SrIQn2$a=GMbI?Kg{9i`2L9?K)wf zm;4C$_-5SKRDXSFEs?E;J=^6UF9jqE*y_%H8F({~4@Fkp$jkW-pEs3Rlxu|uN(L^x zS_s?59c`E8Dj`#(oknGZwMbTFutg<+u^x^A`heI5D-kRIKp7_tpd;B)Y}Z(Ii{a>$ z@kR}u;hs2l$BuW0l+lAtS>sYd{9;|$CFl2y-b=YHR$UZhU z(3mZC)<-;oY-2xYfj9+KDpEn z?Y?8NS!+&!h*qz2^)_XPEo=f=7cNi=oPDY7PQ+!u*#7dlcCQG*-n^edcWGv>6koQO zqJ-?^InnBgWAL#8O=~*xNhKF>!T-<{8ZE-ClU+-hh&rp$nssO27z{?-7=IMixlALt|BjNe>BnsSp)gQ9?_Yr)YMpG(WjJWX?c}bR# zxcEasBob|%-45vcV-oa~1$$pT1H#MAG#X3t?LW>qG~B+`nL%D`-e$kV=j|P>_YTj? zll?nLuih?&`_`x{gE#Ct;uos^$1UBIc6~smO&~mGPei&-Tc-V%3oM?2qnozN$3tn z)?@bcxLHjt3}qM_&^WU36$Gl${xr4oD;w5C2y|{U>bARu4VAGOv!_BLO zuy2P(9m+yjvMw%~ayM|L!951qI@sXWt`40_7s`yIL$G2L@vgm)*r zSf*c0Bb`|uHj66bGxpD7ujxIU{KQ#rPS7f%4HvEnFmC_(+tsI6oW!QV?uL70a<3|* zH*0VYig7^EE{zJ0e&-E85&HNdW7goPPl+d9?k+z}sDv~4X=s(-zCLXAj(0&m&c!ZE z!aeRTiEZOK+njMk(ShZk?@_Lgy<;wg`?;y4mh9EWj6-){?WPA`tIr+zW!IBZnmx!C zY~8uMS~c!A&)1Fpm@oF{#f-1d>0*AtaYy5i`}u0zmw0WzF_^}1yj6U?IzujB`qbI8N zvzEtm!&?9V4qWMsw&_Wa)8QwY{T^MTHIE1Z-~0hYG_mN!bH$FDJh@O9q^4=SWRVhe z2P#T1wDCp-7q#;kE0m5^7mtg(0h_r+WUa-)Bu5k}PL4$x2+TEUP!p)nZn7}%I5Kfn zsK4*3K9}R6udyWw9ZEy7Wi3=Dgh-20q;p3y=1eNg6HzQpDY1!eGAxS~ynC?kppXI93szjDzRV%N@k^ZC`k4j7gCz!oNZZ?n z0465X_uQ9QOlHUItc0M^Eke-HZXt9yM2M=4UBSlX9nmGD(3jc=aM?X?a@SC;r(Pv}|G&>h|Q4_dju)lGke z%QhWGP@4(_c}P8t(jbd>kK;mCGVP!VJnj+f9NUQA7b?_Y0gsmUf)L9V4d4Y{-2Fl? zzaDw2>;@72&&xSGdc};t^!PnSvGcgSL$fy@Nd7$q3(Wj{bM$*J4jY4hUhRF0e+d?N zrbW!XxT{d`4O;!J-eYW>k-;;j;Iqpz<+aZ1=$5Vjh1uI*kw%o$5iGLE@~`vHRGe^W}fXJ&o#t=MPwLW1%ZsI)#A z|GokyEZLAcs-fEW-sgK&l6Prt`O*6Po-Z&K{JAINkofEu8lApoA_m$vB$OK5=v)BX z(!u9NOhJIrsnXDtl)EiJMT`2E`Yp@#QLe>e7@gNJl2}Ud!SOv|dNbf4*0u!_TE==E zE-A&%UeG?H5LxoXsLsTbKPloL=h2p;D6-q~fO-W`A_ZqpM`mHb$VL;Ied+u(F%+Fs zd0Vo)2^sxA+Nqm9_9RWC06Jo!Lj(Xy9e0G(=%ZLu)h%rUew6jW0)dtF(nPUh7!Zd0 zDv@9%^49|();8E5@Zf4l-uM$Z{|JqWmy20|j6cOPnKdj@<~tYQjcuuXkqw%#OX2mv1N;}kDz zKM;vyO6_5tD)k*7&PQ_9l+e1qk>HW;mDh5HX#7oB!!hq9793>P*CwLloGzUa%5jU#xFmLr2F{7`@g>Rj$a9Qg?_y* zZw8+AqzGbqj_~*&*WS6&NHrSWcc<`dejwZVVp7^>^7ugYzMi#SzOHGL1~Q96Dcm{> z_=5)zJ?ZqJG#|dK%zgqVGog!8{H`t&Yv`UQ{(Vvko}y(4jGJN&yrZD$(sXq38-9ZnFo4$}&R zC0{hr9Qm7CsZmTBL$n;j3TYagHDn}yps3ERNLiwE&#nC&M^yD}A|*Tsn`td3t03<* zdBa!IK{(B}F&(1EVcNKEoUs4wCz)kxV`sB4&Z_+ZI><^S<($$?Y3v9@9EFc4f+?en z03#@7i*rDVE69ITl1{V?U1*@S!HI5$Y!U~AA~jTr9$|wym}+LHq5MvTX{H=NMPZ2f zC5F7D71TFM8A=0QZ3VyOta`T!_;Mo zL*vZl1f46izCdLt;=D&u!(+o=(n(~I7=_Z5kY#bv!ulA|E2GHZ>z#JCwpM9>7=HgD z)I{y9A**}yjAb4>flADd7I2h`Y@rUoKP`JF7&U+S_ZX1R(>R1Er?!gu5~6&5>PBkH zJsB4^s_a&vMGGSdrz7KZY5%mk6xNxA?sxU_pzD0I{A26sMIrx<*8j--i~LLGNFGJ| znqjJ#w|}Jax8Bu8XR8ky;rAx7?A~#PuUm$@NClrDT3o2#F*mP;sfV_smqY-atR1-i z{)_?geM@<|njdMO1rps$`)@^%mPOC?S8vel9hHBq*g7`o6ZM4*eyQQuz%99z->=l} zdV|`Bb)2x#6omE>jMD(0UR_A+DHA+|u9LRNf$4qihF(bspYW;1Po_pcLtPvsj@;a4 zAUsmBKeP2eD15eSP#I_(9D(teD%K}$oujZPNIt=*Yyh2E|8e)yv)$ff&e*8Vj&M1--+(w zf<8mpT_>K{j9d$_Rmg%xm}*$0HrSZ509korB7&1jttJ8237Lp#QC={HQZ*kDDDDb5 zw1nb;p9g-%bG{`IUnS}SDU!r$oM1v}D-|6?S>tTz7)h%_3F^45g2p9QST~{^se=Rx zVagz#M3cg9p%O5UCZ@z~f(630bN7E<0Bz}J2vbG0?TiK3brT5af+Vo@u^YTdY9CTC z5(x4rF8>1*|8@@uGSXB=;faQUjVcL^>O{^VLsJfo5t#AQ5y%U9C{s9bnEyx9HwMPp zM%y-NY;$5xlE$`eyRmJjv2EM7ZKFY>#!h4N&Uel|e`o$Z``uh?uYHebWJh~ZRj;)2 zi$aR!)l!`Aj)`XMELgimyhy#MtKsFxtbyedbnyAPDpe_``z~BtUb-hJAc7OvKIhfh zTq7gPH6&Zp7egxCtH9~7ga1P@ZBntoIs{jTTb&|^qAEDz^l@mMjHZ1#UTr*>cOxaoD`mORrS zNoiZPNv0FAcJn&}7c-fPLK>ra8UvaNn^By!48pBA@;6SfVcY&(0!M3Ybc0MQE!*YW z_)igS6?(uv39fN8lRh?tFmR7||GRw`q)m#UKoQRgaTStgpm$>9$LO2m* z11i9PSs352NSJXrnM>~A1O*3&)ET?Hn14v2!To6vy+O(bvTMA@S}?p}(?`JxVOWIf zoCltv@w2t`IK)kv)zmP6I8K;yC@@Nf@@KHz_;ld{CF=aeOat0EEvZl&U=U{LrKALX zz;X@J@;KE59Gvk_@_ZbvLs)3*3&|8C31pq0vyR5*LC!sx&Qvmtv=|+&tXV29ZS3h; zc?Z(ooK69FFGBw45x4Ue>~G(h0k1<~L*)L4coqsUxqyTB3uoGk2R<{Ouud{Z_bzUs z%Rd$o%n?Gy5(k}2YZ#M@N{~pJ zb^~wprlxAd4qu+Z0U#6o?Z;hUX5$(l4mflh^s^0SE<;?0s1P}A$sY~2_)3R!?Jh{nTC~CAtN8BydX{l+~&JaLs z&2}W|bETvvaZjWw{H)<#g(Br2YzwhLWaKNtnTIf|rtxLjKlMzl=gW8ij5GvSwHj!0 zJDt-r(~g#=h%GwBKD)wA_y9AHimnL`Eo=MtG?YZ)p%-`!&Q||WJNqoNqJiMZ8-iog zM0EX0NDgkkW)~)?*4EBC`pFGUa1EFh_?a{7>Vpf_Vlv?0Xxh$AU^w`;G|gVhTVbzm zir2*>(+^W2BcG-r>;6MjG zO`e9Rks5eb`h-Bcd1;Mvxy~FgB^;$HF;Ok29A5t^#>87!-0y<{-P9g(xh`GHM^}Q9 zJK?rFq|*DBpcjx&w~-LP9)v5cOd>9w*Zz&`f=ez^U0*aBecqdXF+GH(J0?c#sF!Be z5PKWwYZONbH0UY)p;&o~*WgnJ8AO|#dO_6j%{-gLNDFB@9#oT@1L4Ko&dzXOTp zxz(|6DsY=%n@_zh*P`xg3!!bz#ysFg9SJS~^CAvuYK<_NOu2#;aok~lM)8CCa4)VY zwmvYr;oT_uDn2IX*Q`O-Kbx!|XR7*@=sk&LdL{8>ZJz+qaeTWIrR3JD=Pp=mf~h+CYIhCX)x7@_F%yI|^-2=bXpB@7V)cUV)qA5PfX;2$-wp)^hfSJ# zlf;yMMVKCr9e(Cu35J$%5?HT}t`49qD_#40Qs3A) zZsHt>6Qqo*zmrm|CLNp*-fQUBD4t6D%5L1MWO(%{Wzp%g8};+}4mqbF4<5~SyrEpF zeTv!u-<{l9P_r7f2hKQX%;G%#i?<*`fJbU%&s6 zBkd@-=uE=ub+)6G5w~O;Lsnp7TJv~-Sl0{yHJb__{X!s=&D`VbZF^|d@9C#wsq;sS zrnO_S!G^WNRlu)*lQY?swYMR4{9=Z~h;jMbnmGBPLp+CsytI}P@n32J+`X$~&yNai zMb@AG?=fGw>b`qW6(M(vuzUEI6@6?AzAyc&t4$O?CVX>`Z5}yBi4OiV0@Sbb#sn;0 zze~^1Ndrj9D%6w3C&T;a`AhjJr&a4ez4`4_e`=bv;aB3}rxqXC@pF)(&S|qZd4JIs ztdygzlHxtUwMa|$t(w}JCJA_+SUyhAIA81#mg+F|(kfcU?J^3PLP^N%aSp!*N-GXn zc2>J@Os_mO)Vt>*_q2`F$5-3Cu3fmEKA{(XTXkLN7>QFz#iDq3o%p}uS5GEY0jDXZB_X*uVCrK`u z+efwg9@o^i(%*Jd^h^@`QjZiEbJ5sh7YE%Zo!l|J7@-~R*^|>`0=?lw|C094+-7Cd zp+F4ISe{0D+TUT(z&O-j4Uw!Zq6r$YDNJ2;t9WAif++0_?;TtFZnK8&NilA*Yq)dx zabIp^DqOT#wDvoIOB4yV!hGQ!2u*8AwMoW*zZG8h18@E(LgFJ@JL=WlE3g+mNWi(l zw>gQf1yfTXyp9%*kCO;r4EvqyMdENG6&xE=|Df6DEKEZ7ypAxRXiCI@s+9q#m4|`_ zu)z5^lfKgWqIHcj@dY)*He8`VqWZyT`G*rhE}ipxy5lRM+St3?ta4=7vT@d%$IkpC z#2)!oiRz-)cHc;^9DKYZ9ik45brUp4Xat8Xj-bT#dajM4$Iy(r;tDQ^Qw`0+K3Y3L zTsuQ%nlvaDT}$LY&lz|AuFP%9+R0{ND;@a5&ZCqDqL(P39w7`^criDej#`7oK;Bd0 zJ@z2TWGOQQmviiMcvDu+VSH3ZH69A3gJB?3#dA}r{U~3e8{NFbW8(L+4z&`#DxM!Q ze~?QN2|%0Mx!@T#`2z2|dtlFdAMWzp+YM`Pc1u3>iY_&{ampHKsZxE)GqFA#%yO5D zqn~+-{BAQDd}o$bWxvkA;-b!QGjDv{pLg_e<&Qswopw!WZUI^EA5!b9o^w75vOjQ z)0YCn>(1EeefyN5RkXa$H)!sgZtyu%RonNxEG)n0`$pHSZQobG;a#KPsOl$vm17l! z`qt{|82W7!*3#26arEU4jM99EZ)%XilT}>iQ|}7>4sE`ssZWkc@J?a( zrk<|%N8xx~tbB$U*Y5TJJ@R*%b+rxBTAMqAr+bL?KQIY-fO&nxwEA|V`y60smQ9`G z$C$!pSzm`xnFb@zYGGf`$hvx*$XM(?&O>U(5cUzW^V>q3Zbb0sby_d*jeeGDprlivRh$j3AOPET7)EF4|{khtJI{dI7`DWZjt~o78eA8(6SsH zTH5-1r^mC4R;uactKmhztacbxqAE@jwO44_@KT^umVxwo+9r0rkizI1%)Q66WEK?T z%tQGC#8YUMj2`AQl&~H2)RBR@DI>x_ir!X>hdRVBt;* z8%}!M-NFu*BPxaKU7=JP*s>Uw^t_zGy5Jt>I=rm^8aShz>=AEtiYPm$YCm;IG0)>3 z8{Q<*<=dGuPt}4GajDhPw{|xAIUr4iuckGULX4pJY>dxb~pdwXklJCbs0n-$%1I;<>R&>`b*h#O&EBydy>mBcutuoqx!u!i{3YE2Rh5-5XPC}i3B3l_3Mg*5QsSvWL{nd9Qb zTuIUN%Fxy>mP1HlTeaZjVmCE5iUTBYl9H@O-akEswJ5jbS~JUq8`jR_F(QwK$+6Q? zwbm{c8Q=n`i80_bG#hmrOc64$Y~0|2)u!c!+r0^R8zS}vaXjv$z_12}moOY_q;rwO z_i*N(uB%1cLoW@oyl98-5QI%seInEoBd-(qWV~AxoD1c-rxm5-DXLMp_`hcf@edCS zSl9q>pz!G##8fvZ4u}*mDR@}QVzVOZn-<{PXN#Ys$ zBlV=u@+p1+uZC?AXVEU~aTHC#?;mg26^{@Huv{!qBDYiYpJ22AOI(Ise$1flWx-Qg zUYY&zDep6t1&hj%>{$q@a%j=4e zozNd#IPS1c_u$^#FVc5{toq=)I`*9Nj|@{50;g3f|KylU)6jKhrk*t6p_`M$b0!=ntgTAv%bw$GTSo(s?QoT=hbqv)yLA*yh7Lc`^*teU#*IDHQeG`Ru_ zjxCo++Ne0E->1gBpCNV3dRampkq@?0zAB;HAB$S@JpzKRzF6{?6k6P4^To}pZ%d+s zPktBQv%h)-j6QR_U~2thTYQKHtvYq4c%KfMrnede%X-G$TIDM3cXNNU^*)|B1GpHc z0zNS5^?hRDtZnqW`^sN*M0Ck-1{v$wae3g^m?eFE2)v`GuGGb1_cHyLF*v^E5?$YS9Cvse zqPcIm!KK)tLz4sv%{qC<%n}>Mo4T~IR4R>6ACNytk9S?RXS|qwi!DG~^}KH|M;)Tc zJ^p=eIz21v_`W0L!Y!&Db1W4|oS(-kxftC8@BK%B$l11MsAGg(Lu2JsCg8l2m6GzB z0K0%K0EN~1M`eLYr>Ni4hR6SII)lwxt<)frZoLA3iebO%&m?4-|cTF9=ArP>FS88TGF8KQ&vgRKF zkrRpbMG_fI&r<4JgjHu4A8ZK^O})`DDsHM%bC=dB1H2;E6zXBguwI~I5CC7orL9d6 zS9rAuBi)L08`Aa$a4iQ0uI)HJpNb0~G7AfyCyufheKx&X#R}OBtwvwha#{txFI|k` zQ5)A9`tkO3oj~M6rHrg!6g*^g>oY9e*i2`Q4I2cG!j<-huc^~`M~=Uoxg6hgJlj*j zt%@A>)-lbd>_d(%m&UQ`HkI0i4soiUbxcoBvM0A56kp_Bs-+`cE;n?RBKDZqjckn} zZXLW$-r+S!Uw&rty{4ykg#{YODV!+zA`36?)Fzz&RLx%Zzh@01^ z?b?xl=o-Xt%=M8Q^bUdx{WW!bLc5Otj=j*@LNy!^JY61sZa@FHZ64yWOS2%k$yVMr zc6qt;Q#?F7+&-$%A0PYcc#)Myb$Tsw?jb==t zv;#t7EMl1O7_yIMzvKIyE(C{=rKXg)mLAEIG)S7d@mQK4A%5MieqlcSWt$Rt`Xc<- zJxN72lw>)2n}W%0l`>;E+0mq-sDc^HJ*Hv~W#i&wvc5Y&MKrWZQnrnyoD<{me1nwx zz1C%ZC2-NleWPA0mLdBf?CMo1(+{2&0Z;Bit^Mlgib?~x2+U%dJVz@|0hAN${T6us zd29XkN~Bb7(i}h#RrEh?x;{DthIWG0A3_8z%|Amod||K+s)OOP^$Nz9-+ua^dFFZ? zk&1zt=kmztNS?TMyv&m~Isc}kjH=etDOIvnvKaY11tJmr858%8A)QN4FGpV$iL{ZV zxQ6Ne6wi_U^70Lxeux6mML5RPs9u$`c$S+ zT4pkjD+fx;nm}%`n%Y`N$(q_AsPsNwaJ>#e2wYb{@q3q+{o&vnGXap;2F$Y6-j|DF z!l}h$bB!>TDZ?`2sn*Vy_G9v!02|mY>eSYFwstOg>;~(WUy7i6`x$>Y0FY@B*;eN_ zG)uLfP(b??Pwwy1l`Tj6L|>dlA14wSnVGMlW2#kZpRZ*CBYB@keaCq(PYt%tp9FI^ zFGe@6AqAj6L9?D5`D0U>TOJe9RCPaLgJ>uv_P-HIWz^znMlK``qr@daEqFkJ=oR9r zyls*;hF%>4;QT0Y4s(s2^#iZ}=iy!VIp39;88Z~bN-}DmF!z00-k~!xf9RyP&wY}> zuO#fQ6|O~Gwm+6Z@w4SOwKkjRdxvv(2?~BFR4OHT4ap}nQSTcJHPf1RqMkHNKacw?+Lu5Ca8>lk3)K-}!3RW}La(@7+QJM599dH`=N#tCHvTo4&-tyaw!s8})>K27MGZsfrcR&U z%5#Rz$zXa=G`P#@$Yf-KzK0BS=F7e~Z=4gfzKW~|+1l}Y2Kto+iC8{W!i)6ap@tID zsBtce0fWeNUqARq>!&oj6YSQV2>_>i@KTB_08aieMBd)VVb5HjTky6`H;&`nKG8N7 z5SBV`;`2=QwAY(L?-X*#ik`i?!3k5#&^J}lLrX#N5}JwAiTu!QG3Wc{Xo zjGN%V61i_lcRhhjL`u5^|Mz@W1?p}P43|y*#GcdduwK*c?_|u-qtT-EPfGm{$bEx4 ze8_*VS5XI&3nJ=5~OPp64b z$o@AM27NMw!%E}tHc?a93ra{cr>(7FX^ z2TWav#t7MGopxAe{#N0ASaP10l}BwyQrk8!>6u9yA{qEWRX(Qp(4Fu*ZSaY%1;whs zR1rqGNG8irARi^M#V~a3L=h3Zcp^c$v9)Eu$~&ba0!k%@Qd_H#TGWd$NQlUoO$j3r zE7DCs2zQ8aCLuR!B~%zQy;_D1L92Itv^-k9N4QDJuy9$hlhE&kiA!w=E^g(X*DyPg zmea*TK>CHg)+U}-jDzS`Jw$KR@#~?AKM?jKzI%K=WaQ2#x>Zyum{xa@Rir)N zN65NRs6>m5C6rD<@-A5_vS&dw0F$~oKm$s15@XSi8I#NbJFVm*b6_v zDlpF=eX(^LXJ~mwEVVVY{bPQEli59RK1wQTz(eqV6WQE)xB=&d^Tx!`d)Zr`WLMIB zWr!r57zyq@*{q)K{`7BJ6A>{Vvz#&peKX(mdVjl65pQ$L5u~?ctn!)ZJ81t;F?v1y z!`%AG+=z5xfb#%$8`riQ-@KzGUSdUzgTaD&xYdUWb(-LSxaB}~RddD*^w&h~ z9-%q`0}C{#xe}#3@@oJ&o*`Snu`yhpVPb-v+jwK3#njlHZm-jya$NDDvlcOc;SJve zBQ3Rycr9;m_>FH^O%34$v51kPw)bWG zcL^vuhTQ%83SSqFRZgFG^~Ht7m`sbb8~jSREJ&|-BZ)XQgj zqWv17{1TJG1sY#w_z-o-+~c$oZ=Kh<_nTGzsS4k+jJ!WVg7H<^RaF{Vo4wN}yc2TJ z_GXP-6Dv*-!4q|sq=%1BM!HBs28!hGd~Y1wCt6|hG}9Ha$qDj42mn(?OO(+;Ue z_VZ~<*jP+JW`JVv0ISn8 zProecyt6n6TvGxwvU}W;H4{SU+n{0voX>U%n?Mi^T}Itv)c_n*8i+ub;PQQi_}wsq4W|Gj56ClT zJo&9}X}pEkGk`MY+Tn47*7ny8y=DCP`mZI@v^#u)Lz158y~w_>Lg41Yt<$#lFakaYqW z$;{0=x&e1exD~SE!F0%Enc=kQ}emC`L!<@&DO5G2a8>V=$T3yoUdP}R1Dm*ON)r0 z3?_606r?~&gEIt^N9*lz_B%p24wY#qBWAJFW{IY4RN-hjY*hRvr#YPEIeI=bR``A1 z?6~!@O#p!>jrv8~Q3-7k+bKu7-)MlZn}2sUF$v!Xdiu6JLHr>%_o(jiladc5vT|{q z5m}so#?8(zq-qxCGwdBnDHgj#Os1h~g+wYF_tvo>uK&8*8?R#%2qBz#DGe^i3&y;! zDXP;a6`I&#&2bhnoZ41Tqr`Pk;-ZC;RXSBhvygtst3Hqb)Ckpw2+bRFo<{Q;m3z6$NRf^sAJ#h?Fiv zfwpVKsM^t!f|Do%N-)#;RMqq}`=2iRRAL85y?B1=DnaeivZuR?JvLodDx^yxJh?W3 zVt$^P$5$vMC_q@@xFvg*>N)+^ioe^}fjmuKYY>3{A|{qzhDBYJFqn$^+z!y}=a zfWU3r%HvyoP8RgdkPb$~5Ht4b*Y`o7GY{{!5I52yHw@y87vvc5j)c|`(wb#YT2!^7 zp_k9Z*EQu#G~{?zzmRYlrx-uG`yc1sTU7mU!;jjSa}%I@S%>E^TiwO3FH}R-RPK8{G#q=cNs+M=ygZ zw1TL7b;kx^JHg^oW#SVjiG7H<-Lpda1E0% z?nn^tC!N6k$0M+$1SuB1zMgUJugDMfRF=a0!RbvudfknFWrwSORPE<0WUM-vfS*OT z7`CiELefS0B5)!&-b<>Y`UvScxuskB6xR@T`6M*qI&77!8Q4uYIs0Dxmbt$(L2y+S zum2<3iZeBY!C7erbrOfJ~o$X7~f zew1bLcVOuG#Cj0V$yfu@)6qM>K7+|````dVQs9C_<8IMV0)_FRf=HyaQJ@HgPz-8} z3YUr1a6y7y#Pq)(28qr7D8z&7ZjU5k?P@l%4wQrI9JsW$;S|f2QsN!Z6e;3qyt3}W z0#;4k%mQWR_{Rn>czeJ%0I?W%t&7YMU-GZ6r9C%nQl&ca*>Tt*-7q@H zg*SMfdL94;84>cC5A4JN)__`igRKC*w4m|6d=B65%1F{YPTXKxdqd zH#F$vS~Qi`=a=f$5_s)%OD7iT3cgB)L>`7L=d^R6gha?UJUNMGrm2Vl{I_hq>2(J2 zP#J}isasOqK`O(dJ7yeDjsOKKghCuV63M*BLmL{L8>iGt7Yie%M2;aA`-8mpIPnN* zhr$SWJat72{owaZM1kPXppk4b`pWIm?R3bn#;z-L?Vo-}&{RYFGL$dEN<18Dbh>!i z!4gdg|B%40UorJHMuZM)79gL3(*+YV%&6>YUzC`HG5t|RNp^aOLt8$IIx~^Z=#UX! z)vS$JI6vh2qn82IB6^~tm6UgEj0o$3mJ*jdRlOw?Ba=m=?xtU3F0nY!J$p0ks$bpx z7tt$7b?6%}6*2CSjs1PO2EHk{q2YLA4qYsU^}rK2R8aQc`mp^jnD3S25!yd|Rz)Zx zrE!vI!Yu_y0V8iEc2yG4N!1) zxP)m+y2C-py>}o8D{2}8v9i=%1EbEdUt;DyooDHDagqN&DiwhG^FdE2_t1vY7K8qu z-@uSL{Vo?$qO$urX@*@hr>6~rU&+AzCSfwOhkkgt5B<*$VcnbZF+wHs33%$rY*x8} zz+%lpSX@UKmOG{nVNZFePj6pO)ThuPELa+^`m*R6e!b*PN4*( zFB^DqRd z|Md7Snp_?uwgGX?|8;-AXD?qha#-o~BtYaR2^ZO-B9`xq>5o>$QX zwY+63S+Z08IvSggD45?51G9<=k8}rv8kw#YVg;GoY7D`vR#&J z>)kgts|T2|7MK#plmh*_?``KjNj{0C3%2>-QDqo7GkZo^ye;={Oztyo6%*mus~j(9 zw8s&oQ(LqwcwdQLZ!-21F!+R(v4ErE-0 zCR&TIdY9aO(V0Z3o`jJI9C2$sZaG`K>G-R5PWSzIL7(Z%%W7wcd8`CQ52EACL)?Bl z`@pn9>Vy^265<~$0F6R`RL0PZ&V~#2x~4HTd^~CXG@@xHNW8gcYd}3!zDkCf?&K*f zUMD(4{Kp_D3)YQIF2cG0b)_<&?H+w;s&jg;-OuLq%Z9J(n@zO%ZNWY-j*dLm9w5`R zASel6D0&V-;d_P{mQQ(upV+vVq<|;B50v^*z2BM2h!(7bXpK-xhdQ_ume?4nv^eBA zVzNsFzWwB=7uAqCiNV<$CiEMPpIr_%o1Iya_v!WA=Vi@#N#L3o`lz${4AnD%_U4^R16VGO>O3h zTuj#dKEXNU3@EN1PeK4fnrh|O6f|a@hu63ej~yP71-OJh({KkLU9c;bvg_8#uAJr0*}FMFQ&&9J#W z3cgG4RG^&G*8c30_t(9hzhIU&lZ1$k$Dv6MBvveUS`61I=E}WF#FYsU4?*jrA2*g` z4!2y&JdysDoxNHdIBS#BNIf2fDh5=NMiU({5u_m(x5%ur%{H6`1Kz|D7+J?(zglx> zUa;dvLxDzHS6v1_>`nr}R+W-X zUWjBAz3B5+%MRqM5x2&-6!s{D;MqkNQdN^EkaQ2Q**mLbtHM+6X(n?I8f-# ze|YBJsawcr-#%t@`p8^j$EU)Y*JXcp#%b}W&8w8kX57yBZet#l98HcQb+jhs!l~@i zNR|C2WlK$Cu#M00Bgi@g=xObo3_uo|g|W^`d5OH&51eYuIy~`7Xz680bo{~$p3FI* zdIQ6XqL+b_Fr@GZ2)m`^`n|}Vu1s8F;8rX0!)iy-7z4D7`=H6w9$my8El&36?o}=n z7*BY^Y3^^|&j;^J_oMC4);0x#@1vK_L5ZPTL)7`^kjhW5P<{J2w=SWk1@X>6av8+{ z5uD|Yj)tk_M?@q7&BEUq1JV)kNf^`=LU17~q0QCdV?Zs#a>k{?U&L@!n12f-)YTwX z$P|?SJC%SK%nAbmn(-A&sHvGq#k!CyOAhvrMPaC0jPs4t1*jN%*URuqpEQ$+2Z3G6 zLRVA3fZuYKO-uJH)=9L-Qc8s$h)teNk`@_8pVL^eVQXmVjup|!L^6|Ax=KDi)-R$w zyH*zVColih6@;(Q?A-@LT2ZF|**j}zlVJ&YVS7bf1odychy!OInndYGeqWbD)rKJe z8^Gfh?(xUi`3}m{@N*Enbio3okn=e$J_uClU6TajB86=SCdCV!H=J7k=RlwzRn??Y zut1syP!aj%tZ8j-y=LJJigBFn-NQyaMg;^)b;}8hhHi!d^_Rab!yxEYONBL=Dyr4t zB7`IZTrf1_8mkq534Rakrop8$&YX##mBn}7KvRnI0r!vnTR!$a2pCk_)Z8xYYY^#T znfrYFrE48}6Ruu)?He~TVmb^`jF1jy_9&1sZxzuhZeJ|x6FBH!_>Qw})Re z0Y)1|z>B{h9@t0+j2^0Ru2ZS<25>coAA56hQlG|C;ZC|L)(T5TVB)Ey;4n>Ku$YFp z@J6pBWnLcf)?O(mWBTz0lN&mEqK>)Gmlmqzjog4YYA`hPIueX26?=}<6E#sdBLO%i zPbBDO8I&{8NDNDLj7zr+?alGIevgy`PPsj3NM~iLD`2unl+YZl-Lte0l2Ke$D;BO%7)bibZj$jNsY-E>Lfkfo+%45%!TtK54w|;!uXCnX0!9B z%~TqCDp3ts+fRy;!AmfQMh6D~KAdROLSZhXp#>NX8bz#a6@>ld?s5k{A~Fonp)+7U zNGzQhRGotOzoo}oxu8XM!yo-=io+D9xg;TC>J>~7pYQE!=6wlg3%(97Innv;(5%@r6p@r>ZoI`B)osZ=u6- z{ENVZ1!6ou=@w>!lL5Uc0`O7HaV?b{K1QL&uDXMGV{gcaf<|CK+liBGxlX9HgN_J^ z-+++oN5vx?2D|Z-y8};rvjgIz*4TDVO}(<_FT`h~EGODoFJ>_|-2vw?)rq?N&Kd6h zZxR_>3(xYQnCNq}{+#5}l7Fe${Pr2N;FUF_F_Qoz8*!3!>u|~S=^W@q+kfPZYchJ3 zWHbKPR(7-ylz;a#u!JKwtoze78ROeh!oUm_b0zGvcev?kDh;a*7*BS)klX<}6jI`v zt41M$*h!p=hTOuPk(D%#y3k{;7VU_YjiQ=DYU<>cA?%smlkp)UHA*6#Xi=0^%$1ek z{dYcAhJrCn6pW2^)}rJOf&4>Tw0MZSvCnlU)KimMwxN z4q_Ng3C!lVx>7-nW^Gq5Z{J98H|V!&H?rvF_FkTaFZD%MMDZcibyZ0qa4MVl){!@j zyu#zNM1?#; zQV|Ka4PQPPY1S6OM4>EMjpbsVIjkkVbhdaE2O}I-exz)I$zlJfrKdMwR9=aeOtDHq zlIp=Zib|plACHua?`MpDV($ON;Tbw%`cFf?kJ(cYCisK=XNT`sntnRY`Rbf+?72DB z(2>+JSGM_H!pl(+b(Q(c*32=NYLw+OX4@!#udyX0MVc4Mle_WkJ*m1C7kE22g6H<) zuQ}UhE-nr;>kH%Hoq#n$jWMDFi?@$$MbxSPoD5{aLtC0c+Y#B-qD#2SNdp8JAa#fs zS^fH+jdk>{KX1t?%0?+OlbAYn$1dq?=UvCFF^urjH5% z7D69yRA@>Oy1Y!$NH?M#M5X6*ND3IAm)!l82m_Q#3}%($@;+jd&(<_HbN$4Z-HpGM zlX_u0g%EykcngWQ=Vtr+{z|knaUmIp-Rhsl1=7rt2IqIRO(4?m8i!v+uGb|}LSdP< zZr5qzT|Req5^CaLy^n%iY|`;@<(*sbUVX_)yFBV~hRoAd@Z6oRyKN)-WRiFJqF*=r z?H>LAZATE-pP8NXRJ(T?$c*_%np8LM4j*4vYg-g=YEU;Vr2|Q3Cj2FXJ!XCgHPv0f z1A9G%I%(G%sk53_Ww1{@RE@kiphPH@ zxfh12re@e8rd{8e`Tj!!P0V%BOES@%RU46e3rg*Y~`A0J(|UEhiiNx{;n(=}yXdJX`M2u)6Rc=vA%5(fi@Gtsu)C6fZ_ zTz)&s@FJDd^Z#5Fr9tPQC!|BLz0w!+*;E~+Qd6nN#Pa`aoF>E?Y1cqC5*joYRKjJ> zKtX}E(ZRP7(bk>IPo_=keK9E1!=!jlr$j$vTARQ52)~$WEf4*||z0Uck_Psnqd;E)le~&48LU%FadKmGa z`001mS61MMG^OxO1&r^1bDAueG0l-#X7^RW+uHh{5k#`tpKGMJagN1;B+v8SLF5wTiY0EU`c0U&PIK=xqyWZIQlE(H4-!&In*Ds{1vj`|jD8-5}Z zVHc*F8Kl#qVom(<&63qIQ85jCRVk@eblaFWc0t>q#=zAxLV}QcU^!}4auJn<#a-+2diX3gYme@AqytBm}K#ITr|6to|O%69`EAoa5z` zTphsYef{8nO^92-bl+#a-2eHpr*OOd0aDX*;dM*vQgxf&C3Bu8(lOl4yNJlxbfn&`KgX#muZA z3KgpWfX6~mjbfq}8$_ajeys~asX%l;I#2zC=*K@@Eh9PBQ4qhpc{Do4*@6aOtRekQ zc0Iv@27WPK!|0}c`~w?ou*lk52G`z#s=eCppwET==;6;r0O|>4SO&W-WC*<=nX03T z@{1>9WaQKQU|W@dz2+O4bV}o8Svn*6=N2VY7fV)9&I}5d9QO{o5kyeaGjmFq6uIXG zv@s}2s)=-~2yZ|OXj-~SrKK!XVYTuNc zny!wrR&jXQzxBY&8ZVk>jK@Z?*A2we*-iXCkbE# zGSMh%8sB7pqibIz!Z| zdw%@x?Xx?Hr{MDme?dNX`+hp+@wzsp`uLND@MyEdKMG@t>g%D;xYU;cf37dCN1rI^ z^6pmVIREtK?d$C;ylsB>(p&x}f~cOig@xl^Vfro7&^Ne%Y~F{O0a>1bvj2Q6@K@ua z#qRZO2RsJ#%9D|DmEtcl)+dlJYmq+-32JfyG`YTcA%vK+MoNvswrI!j68%S#+YdBu zaFt5Y&>Y)?CM;RNLG(;iNl`q)Y*Cj~!WfpC9GpZD=1NuzX~-n5E~w5YTwW+4iD3bZ ziGlYXGYSaEBw(^;vb}bR@UHH8^T)aXLrQ4a)Jj16F06B~Lon5pQ_f~Z?7dfH2*vx! z9@!1v)-$IVYC1Zl;!IXtmAX~O{|8w?roNEqLetyrkrf$5 zR-mjz2#FL5$I6MNC5frZn0hlnDnXv77!B4;0x7huu+~tP1x8mywUDwfc)sHFYu5Tu~9sJpXK_cbNn|4eaaKpiQgac6VJ|~cNY23aYHq25TEi+ zIy<1TLU8c@IzRLe4l=h|@zU>?eCi)vo*telQ>MJLv7nTsIn!oyZ0lDh^cTn_HV?RfX?_m=dTYo)=IQ1vNjUvX(SXaI=go%kce>;dn@zWQ1Wz zQ1jWpv`?!Mk>)vmEV^EeFT~10^qA0{v27w|9J&LkITg%G96}I)BNTCC7|zxy!*r= z`&;XbBZUnuU)T~vROad3+*mSScby^TU$4oS(pJUiNcWeE4wIPa^Ua^ubsO{vs)+2OM;q* zbS(3Wb5IK6p{A-zTwfB_0-}0=H96xX#&Hx;RAYNE#vq8ikhyM`{{B8uqt2njYuvcD z#dw%9JKsUMCF6KZJSwQwTlh7PFlthW3Y}M=1@)jtmgYD(pan$=Ras0HBNpYPtuf0; zb%s?)OB*Qd&~49g^X4Afl{n%pa59vCj?8Fd1P2yl zW@gWG;p`*4{|9q^>)#pMV8@N)S zwPCb>i_M)rjdqvC#Ra6bC^ZR0FVY0J)}S1Z!E^CF4X+hL~LB?zNQ&72Lnc5Ra*cOE56OOD;K!mXV>DpS#HHOLc#6S=fH8S!|G zu_dnSP_IWsb)OrXH*lPgnf4-BSX^2_Nsa3(P?Bz^&Cz3b&}>}d>ZNt^QgiI+QHmnt z>ZMI?ZtXL-ILpHF47s19vyw8+2~(WkFdGE!`k83dA00w_WU}(ku^Dgy+?mHW94|n6OqHkGpC5Q z9L#er&7k1kxfrXy{@;wpLoS@Vg!DCMGlx$90?xzV#_;SJnw=RONAuF@4IX^YeVlvp zY1Hv!4A!rMaGCQ9o;tU~WADG0?K7{z+6mH&7nwhC7kaSH>C0o@^MQNOS^2fn#*BIP ztLNw*Ji^LM6Okq8c8g12`3kegPttK?&c1q?l{@ZcaW>+WFFeUm4>|hYkI~&f#dGU9 zt+@l7=vrcb0hPzp+YOSPEna#3Dktv0n+va9;lW29;^u2-*f#<9-Eo-htLIqnSDd)- z9(Jx=;PmIe%*qG9hofQ4r5(+E_aEWLh0~0QIDYsLx=i@oul_pSd+%YiJ>Xp*d>_O0 zE#^8k27CKt$sRX$Doz|fN`LD-mp2UW`M`bbZ(QfrUcu30OYGd(0=G+M=5v^6ukD zFlZjR_b{!{qp~rs?<2ejzuDm0#y+ifNTV)qUxlrZD1;EWo`<6xq$qJ57v+F9hO#Vi z9FUGAFLGQ@;V^JdSA{n8hL)jC#~UA8RyKnX$Wh10^K~j~*guHo5xhWiDS`XLi2H{A!nB5_9?T zHHL$PM#BeFf!5#)LN~xv=FLoIgg^=hsnlfP=NKFh%EBO}AkPcxQIk?@j4=d}k4Pn( z*Y=S@P?ROP6cCpL2>htdt-V_;)?7M4NW8zt%8jM;R{K*g`#YZCncs;y^<>QQN|VojI%RR8WNWkH)Z96~e753F z-{aSxzQFxQAK({$^zYFgZgKX>=Xs*P#nb67FMl!OhyUu2@a$Jl@yyeoCmxP4KX?G= z>kYuIA?{yi+_{7AzPH7vpZF5Bs^F!s{ticH?xBD4CR_W8`Fg^apIOHaH1B@byNF+T zh5g|!m)CD{^iYk@e*RSs=^Na%md0L1tBiU6>9gE_?-EZvx5fKTE?|WMQ=yGz_sSNn zxfOPvf0pXeN7%o9omo3z+`fasi(e(0pP?G}dFA{Tg%ljDlVus+g#hYLc2A~-eAN-@4Azjd7m=dCm&~w`Z-t5Tq7weZuRzP zwpS-P$Ihf+<q~@i*y|0D@t9ltJ*v{-(DFQPpl}_Z z@py>UhQO~;3w_F}qOz9C7y>^)YQcCoqP(%e(wfW4kwdf^Ena@{S@wE;j;yV5*WEsI zb1|o1IY*v{bUQ5?^*XgEV$>UAwZIR2k~GD0J){aS!k~@D4LpRJ*kXs{5pBo8^Ih^$ zfzg^ux?H}oMOI1bVU4ON(5|BHM}z_fgMEax99~*tZGMh;G-4EwNQxLwLA%vJ8;j@r z=q%>qEcOBxOzeJgWqHL)X(j+d86X>mn`iC zk31RhXeZ|7(BY1c%<%Ug{5bi=^Yos-guI#a;X||7tD4_laCz*Z4{+Y#6#IIa)x{MC*AgzT_qp%K4l@`nppC|8k0Zy9(7f<68}=Z{mH(IGf$s;a ziGn%{D~DKW-K5>=aOvfXY+T;r%U?aq;SZf;=juzGdR0TR%vz&O((2G|8(w?rHB!%^ zZrilmbL7|d$SgFJORe6Z(dp1^Ho0^?CL6^QTS@5Cp7;5!FFwW64}65XPk21}#jmpP zz;}|JKFm^z-}s-; zGyf@%_ENx&b7OwsKl__}^rPSZ7MrL~nKEU{Hy0@;O?Ttbh*7V=C`(xP8Du$~R)e|a z7N#nZlyur52+PLi0OdGz>MpHTh!P%InqahMZ+D-l7J{BM?HMt#Lpa{;=I2r|8pLR$ zDXRi0Q+Bp)lBA0JA9#>^PTofl73gxvc(+dw7PziUlIBG9I_b#rPk!+i(NZxp(_m(% z&4JYeY;W%pv=%sX@hXljY1ZqcNzTIZB3>Bs>V+HZ?e#gjHcz`7;5i;Qwt6_yr7Cii znp|ZqGC}A|Jl`YBN{luP`vaWqTQoWyW)94;xOafFFTTuNXP%{{C2EZ^rOesc*rq5m z{HRT-3l>)v*}k<)lEwI;Ke1B``UF8hJkIe$4=Ds;t&SDRB)hq&Ft)(=1IDA2{xE0$ z&UN& zy*&Qj`xw1+iRZukdH&!_PjG9m$MHs!Cf=LJr(o*5B(M7)q)4!^KD%GmEUCc#GR}jaTxAg!8SWseUo4M`F}wp zsPk=)f0%6ZGyL+;zsQmA{sHRS=a_HQx%SM9tUP)*#~*5Pvp>W(Ti}Jf|GPf=<|4!K z&K{$xq&Z(>@5)u$YbSZ=VUK_FGrz#2MpPa~!1rF$oA40-I;->?Sl83U>NA>suql$}BSLBT&rDRlIUK z=fuf6y*NQE97Ns@9+$e$xpNsolyUv)fCmq^kWyon!dgu{8gS;^HLO#|EBk!<#nUuY zoz85CI5yAemxctc#B~%_NR)hQN2nQNIC=6UFTC)=pUcnxEjN$0_ICWUz!*Ih&r_yM zd52?BN)m=4sY(!{#PiK38EqS?AN!c}ctw<7Z0NhF&Q z1W}EsUc>bzMNtqnLdvS3UUNv3lsq+<3aVTqrDV82;OL>lJoWr}{@Krciuq4}iKBO| zaPs7xtjsl7UY@7DRFRDPT)n=}Z~xBcS-*Od)dL4f2PNzKThyY6bY%HwKmAM0cIzBl zndP3thp2l#7;vReGi-7z?=jn*rP*qb#0kx23sWk#Zrq|MbHb>>*7k_*Obe|owOSo1 zElH7KvKXC>aA(_`c;G&U`vcZDZnCtz%FKKVBbN!4A|8%$eFvc({Lp7{d5+O=i0}EN zX+czrCJ}l-QREl}N(zLMAO()&proQ!kJ#Jpfsj1*o`?8|PTV9!Ht@-%JKhDQK_AzE>XTRpL2HXtARU(%KKCp1+=uw^<9DE|ilWrGt_xUnRf2SIl^`$l z#OqZ~RyvpEe_GKdgkWc9hq<{qthJy_ zg5b8dR|up8*H_es;Poq4!IV65-(ij) zSY~~F9T_+*EwA9H1~0yNjt3vQi-+EI7}rxI<1sro`Juwji}e^_`X9~ z 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 Date: Tue, 3 Feb 2015 18:30:56 +0200 Subject: [PATCH 013/314] Re-set volume explicitly to the current level whenever changing tracks. Workaround for issue #886. --- mopidy/audio/actor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 0d90394d..9059cf89 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -320,7 +320,9 @@ class Audio(pykka.ThreadingActor): :param uri: the URI to play :type uri: string """ + current_volume = self.get_volume() self._playbin.set_property('uri', uri) + self.set_volume(current_volume) def set_appsrc( self, caps, need_data=None, enough_data=None, seek_data=None): From c698e0793149362701b8d9809664665f946ab5ed Mon Sep 17 00:00:00 2001 From: Lixxia Date: Tue, 3 Feb 2015 20:56:37 -0500 Subject: [PATCH 014/314] changed documentation to make installing from source clearer. --- docs/contributing.rst | 5 ++--- docs/installation/source.rst | 7 +++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index c94ef6ad..a4433951 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -13,9 +13,8 @@ Getting started #. Make sure you have a `GitHub account `_. -#. `Submit `_ a ticket for your - issue, assuming one does not already exist. Clearly describe the issue - including steps to reproduce when it is a bug. +#. If a ticket does not already exist `Submit `_ a ticket for your + issue. Make sure to clearly describe the issue and if it is a bug: include steps to reproduce. #. Fork the repository on GitHub. diff --git a/docs/installation/source.rst b/docs/installation/source.rst index c2c4161a..4ac9c802 100644 --- a/docs/installation/source.rst +++ b/docs/installation/source.rst @@ -5,8 +5,10 @@ Install from source ******************* If you are on Linux, but can't install :ref:`from the APT archive -` or :ref:`from AUR `, you can install Mopidy -from source by hand. +` or :ref:`from AUR `, you can install Mopidy using the python package installer. + +If you are looking to contribute or wish to install from source using ``git`` please follow the directions +:ref:`here `. #. First of all, you need Python 2.7. Check if you have Python and what version by running:: @@ -69,6 +71,7 @@ from source by hand. sudo pip install -U mopidy + This will use pip to install the source files for the latest stable release. To upgrade Mopidy to future releases, just rerun this command. Alternatively, if you want to track Mopidy development closer, you may From 090518b96dd35c4bbe8284a701b4279bf394e7a6 Mon Sep 17 00:00:00 2001 From: jcass Date: Wed, 4 Feb 2015 07:46:31 +0200 Subject: [PATCH 015/314] Add comment for Mac OS X volume reset workaround. --- mopidy/audio/actor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 9059cf89..8f374257 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -320,6 +320,9 @@ class Audio(pykka.ThreadingActor): :param uri: the URI to play :type uri: string """ + # Note: Hack to workaround issue on Mac OS X where volume level + # does not persist between track changes. + # https://github.com/mopidy/mopidy/issues/886 current_volume = self.get_volume() self._playbin.set_property('uri', uri) self.set_volume(current_volume) From a693993905b66a63601cf5765da1788829b0f798 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 7 Feb 2015 17:09:33 +0100 Subject: [PATCH 016/314] flake8: Fix new warnings after flake8 upgrade --- mopidy/audio/actor.py | 4 ++-- mopidy/audio/playlists.py | 2 +- mopidy/local/__init__.py | 2 +- mopidy/local/json.py | 2 +- mopidy/local/translator.py | 2 +- setup.cfg | 4 ++++ tests/__init__.py | 2 +- tests/utils/test_jsonrpc.py | 2 +- 8 files changed, 12 insertions(+), 8 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index ccb802a4..d72b364b 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -477,8 +477,8 @@ class Audio(pykka.ThreadingActor): playbin.set_property('flags', PLAYBIN_FLAGS) # TODO: turn into config values... - playbin.set_property('buffer-size', 2*1024*1024) - playbin.set_property('buffer-duration', 2*gst.SECOND) + playbin.set_property('buffer-size', 2 * 1024 * 1024) + playbin.set_property('buffer-duration', 2 * gst.SECOND) self._signals.connect(playbin, 'source-setup', self._on_source_setup) self._signals.connect(playbin, 'about-to-finish', diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 5a362191..61bcb7a1 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -78,7 +78,7 @@ def parse_pls(data): if section.lower() != 'playlist': continue for i in range(cp.getint(section, 'numberofentries')): - yield cp.get(section, 'file%d' % (i+1)) + yield cp.get(section, 'file%d' % (i + 1)) def parse_xspf(data): diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 73d07f75..62228e91 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -27,7 +27,7 @@ class Extension(ext.Extension): schema['playlists_dir'] = config.Path() schema['tag_cache_file'] = config.Deprecated() schema['scan_timeout'] = config.Integer( - minimum=1000, maximum=1000*60*60) + minimum=1000, maximum=1000 * 60 * 60) schema['scan_flush_threshold'] = config.Integer(minimum=0) schema['scan_follow_symlinks'] = config.Boolean() schema['excluded_file_extensions'] = config.List(optional=True) diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 70dc68c4..38e1bf6c 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -75,7 +75,7 @@ class _BrowseCache(object): parent_uri = None child = None for i in reversed(range(len(parts))): - directory = '/'.join(parts[:i+1]) + directory = '/'.join(parts[:i + 1]) uri = translator.path_to_local_directory_uri(directory) # First dir we process is our parent diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 3cbe2066..ab9fc28f 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -59,7 +59,7 @@ def m3u_extinf_to_track(line): return Track() (runtime, title) = m.groups() if int(runtime) > 0: - return Track(name=title, length=1000*int(runtime)) + return Track(name=title, length=1000 * int(runtime)) else: return Track(name=title) diff --git a/setup.cfg b/setup.cfg index 80ab9645..0d6c1486 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,10 @@ [flake8] application-import-names = mopidy,tests exclude = .git,.tox,build,js +# Ignored flake8 warnings: +# - E402 module level import not at top of file +# - E731 do not assign a lambda expression, use a def +ignore = E402,E731 [wheel] universal = 1 diff --git a/tests/__init__.py b/tests/__init__.py index 82759578..4283e604 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -22,7 +22,7 @@ class IsA(object): try: return isinstance(rhs, self.klass) except TypeError: - return type(rhs) == type(self.klass) + return type(rhs) == type(self.klass) # flake8: noqa def __ne__(self, rhs): return not self.__eq__(rhs) diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index a74000b2..d236469e 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -614,7 +614,7 @@ class JsonRpcInspectorTest(JsonRpcTestBase): 'core.library': core.LibraryController, 'core.playback': core.PlaybackController, 'core.playlists': core.PlaylistsController, - 'core.tracklist': core.TracklistController, + 'core.tracklist': core.TracklistController, }) methods = inspector.describe() From 4bf7a568d1ce258aaf626f7d1e385a7107ab11df Mon Sep 17 00:00:00 2001 From: Lasse Bigum Date: Sat, 7 Feb 2015 16:24:51 +0100 Subject: [PATCH 017/314] Check that config is readable Implement a check on file permissions for the config files that are loaded and print debug if mopidy fails to load it due to missing file file permissions --- mopidy/config/__init__.py | 5 +++++ tests/config/test_config.py | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index db451cef..885ea3a6 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -148,6 +148,11 @@ def _load_file(parser, filename): logger.debug( 'Loading config from %s failed; it does not exist', filename) return + if not os.access(filename, os.R_OK): + logger.warning( + 'Loading config from %s failed; read permission missing', + filename) + return try: logger.info('Loading config from %s', filename) diff --git a/tests/config/test_config.py b/tests/config/test_config.py index b893c5df..8ee91d0d 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -15,6 +15,18 @@ class LoadConfigTest(unittest.TestCase): def test_load_nothing(self): self.assertEqual({}, config._load([], [], [])) + def test_load_missing_file(self): + file0 = path_to_data_dir('file0.conf') + result = config._load([file0], [], []) + self.assertEqual({}, result) + + @mock.patch('os.access') + def test_load_nonreadable_file(self, access_mock): + access_mock.return_value = False + file1 = path_to_data_dir('file1.conf') + result = config._load([file1], [], []) + self.assertEqual({}, result) + def test_load_single_default(self): default = b'[foo]\nbar = baz' expected = {'foo': {'bar': 'baz'}} From 3dde71ed5ea486408b2f64ff98f3191f0882fdd0 Mon Sep 17 00:00:00 2001 From: kingosticks Date: Sun, 8 Feb 2015 23:05:56 +0000 Subject: [PATCH 018/314] Extract uri mapping from MpdContext --- mopidy/mpd/dispatcher.py | 135 ++++++++++++++++++++++++--------------- 1 file changed, 84 insertions(+), 51 deletions(-) diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index 5d9cecd9..78536a64 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -227,8 +227,7 @@ class MpdContext(object): #: The subsytems that we want to be notified about in idle mode. subscriptions = None - _invalid_browse_chars = re.compile(r'[\n\r]') - _invalid_playlist_chars = re.compile(r'[/]') + _mapping = None def __init__(self, dispatcher, session=None, config=None, core=None): self.dispatcher = dispatcher @@ -238,58 +237,19 @@ class MpdContext(object): self.core = core self.events = set() self.subscriptions = set() - self._uri_from_name = {} - self._name_from_uri = {} - self.refresh_playlists_mapping() - - def create_unique_name(self, name, uri): - stripped_name = self._invalid_browse_chars.sub(' ', name) - name = stripped_name - i = 2 - while name in self._uri_from_name: - if self._uri_from_name[name] == uri: - return name - name = '%s [%d]' % (stripped_name, i) - i += 1 - return name - - def insert_name_uri_mapping(self, name, uri): - name = self.create_unique_name(name, uri) - self._uri_from_name[name] = uri - self._name_from_uri[uri] = name - return name - - def refresh_playlists_mapping(self): - """ - Maintain map between playlists and unique playlist names to be used by - MPD - """ - if self.core is not None: - for playlist in self.core.playlists.playlists.get(): - if not playlist.name: - continue - # TODO: add scheme to name perhaps 'foo (spotify)' etc. - name = self._invalid_playlist_chars.sub('|', playlist.name) - self.insert_name_uri_mapping(name, playlist.uri) + self._mapping = MpdUriMapper(core) def lookup_playlist_from_name(self, name): """ Helper function to retrieve a playlist from its unique MPD name. """ - if not self._uri_from_name: - self.refresh_playlists_mapping() - if name not in self._uri_from_name: - return None - uri = self._uri_from_name[name] - return self.core.playlists.lookup(uri).get() + return self._mapping.playlist_from_name(name) def lookup_playlist_name_from_uri(self, uri): """ Helper function to retrieve the unique MPD playlist name from its uri. """ - if uri not in self._name_from_uri: - self.refresh_playlists_mapping() - return self._name_from_uri[uri] + return self._mapping.playlist_name_from_uri(uri) def browse(self, path, recursive=True, lookup=True): """ @@ -313,8 +273,8 @@ class MpdContext(object): path_parts = re.findall(r'[^/]+', path or '') root_path = '/'.join([''] + path_parts) - if root_path not in self._uri_from_name: - uri = None + uri = self._mapping.uri_from_name(root_path) + if uri is None: for part in path_parts: for ref in self.core.library.browse(uri).get(): if ref.type != ref.TRACK and ref.name == part: @@ -322,10 +282,7 @@ class MpdContext(object): break else: raise exceptions.MpdNoExistError('Not found') - root_path = self.insert_name_uri_mapping(root_path, uri) - - else: - uri = self._uri_from_name[root_path] + root_path = self._mapping.insert(root_path, uri) if recursive: yield (root_path, None) @@ -335,7 +292,7 @@ class MpdContext(object): base_path, future = path_and_futures.pop() for ref in future.get(): path = '/'.join([base_path, ref.name.replace('/', '')]) - path = self.insert_name_uri_mapping(path, ref.uri) + path = self._mapping.insert(path, ref.uri) if ref.type == ref.TRACK: if lookup: @@ -347,3 +304,79 @@ class MpdContext(object): if recursive: path_and_futures.append( (path, self.core.library.browse(ref.uri))) + +class MpdUriMapper(object): + """ + Maintains the mappings between uniquified MPD names and URIs. + """ + + #: The Mopidy core API. An instance of :class:`mopidy.core.Core`. + core = None + + _invalid_browse_chars = re.compile(r'[\n\r]') + _invalid_playlist_chars = re.compile(r'[/]') + + def __init__(self, core=None): + self.core = core + self._uri_from_name = {} + self._name_from_uri = {} + self.refresh_playlists_mapping() + + def _create_unique_name(self, name, uri): + stripped_name = self._invalid_browse_chars.sub(' ', name) + name = stripped_name + i = 2 + while name in self._uri_from_name: + if self._uri_from_name[name] == uri: + return name + name = '%s [%d]' % (stripped_name, i) + i += 1 + return name + + def insert(self, name, uri): + """ + Create a unique and MPD compatible name that maps to the given uri. + """ + name = self._create_unique_name(name, uri) + self._uri_from_name[name] = uri + self._name_from_uri[uri] = name + return name + + def uri_from_name(self, name): + """ + Return the uri for the given MPD name. + """ + if name in self._uri_from_name: + return self._uri_from_name[name] + + def refresh_playlists_mapping(self): + """ + Maintain map between playlists and unique playlist names to be used by + MPD + """ + if self.core is not None: + for playlist in self.core.playlists.playlists.get(): + if not playlist.name: + continue + # TODO: add scheme to name perhaps 'foo (spotify)' etc. + name = self._invalid_playlist_chars.sub('|', playlist.name) + self.insert(name, playlist.uri) + + def playlist_from_name(self, name): + """ + Helper function to retrieve a playlist from its unique MPD name. + """ + if not self._uri_from_name: + self.refresh_playlists_mapping() + if name not in self._uri_from_name: + return None + uri = self._uri_from_name[name] + return self.core.playlists.lookup(uri).get() + + def playlist_name_from_uri(self, uri): + """ + Helper function to retrieve the unique MPD playlist name from its uri. + """ + if uri not in self._name_from_uri: + self.refresh_playlists_mapping() + return self._name_from_uri[uri] \ No newline at end of file From 655b7badf4963558a6c519c5620886374147bac7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 00:07:35 +0100 Subject: [PATCH 019/314] listener: Speed up event emitting and improve error reporting --- mopidy/listener.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/mopidy/listener.py b/mopidy/listener.py index 41f8e8e0..286466a5 100644 --- a/mopidy/listener.py +++ b/mopidy/listener.py @@ -17,7 +17,21 @@ def send(cls, event, **kwargs): listeners = pykka.ActorRegistry.get_by_class(cls) logger.debug('Sending %s to %s: %s', event, cls.__name__, kwargs) for listener in listeners: - listener.proxy().on_event(event, **kwargs) + # Save time by calling methods on Pykka actor without creating a + # throwaway actor proxy. + # + # Because we use `.tell()` there is no return channel for any errors, + # so Pykka logs them immediately. The alternative would be to use + # `.ask()` and `.get()` the returned futures to block for the listeners + # to react and return their exceptions to us. Since emitting events in + # practise is making calls upwards in the stack, blocking here would + # quickly deadlock. + listener.tell({ + 'command': 'pykka_call', + 'attr_path': ('on_event',), + 'args': (event,), + 'kwargs': kwargs, + }) class Listener(object): From dfe27a09181f8db95e32d17b370674237c9833f0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 00:09:15 +0100 Subject: [PATCH 020/314] audio: Fix event handler's argument name Every emit of this event caused an invisible exception in every audio listener (e.g. core). The exception was made visible by the change in the previous commit. --- mopidy/audio/listener.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py index 9472227f..280d4f86 100644 --- a/mopidy/audio/listener.py +++ b/mopidy/audio/listener.py @@ -37,7 +37,7 @@ class AudioListener(listener.Listener): """ pass - def position_changed(self, position_changed): + def position_changed(self, position): """ Called whenever the position of the stream changes. From d14d64cb7d3668a287f4b9dc3f7dff0d066561e4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 00:07:35 +0100 Subject: [PATCH 021/314] listener: Speed up event emitting and improve error reporting (cherry picked from commit 655b7badf4963558a6c519c5620886374147bac7) --- mopidy/listener.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/mopidy/listener.py b/mopidy/listener.py index c8ecfa53..c32960c7 100644 --- a/mopidy/listener.py +++ b/mopidy/listener.py @@ -17,7 +17,21 @@ def send(cls, event, **kwargs): listeners = pykka.ActorRegistry.get_by_class(cls) logger.debug('Sending %s to %s: %s', event, cls.__name__, kwargs) for listener in listeners: - listener.proxy().on_event(event, **kwargs) + # Save time by calling methods on Pykka actor without creating a + # throwaway actor proxy. + # + # Because we use `.tell()` there is no return channel for any errors, + # so Pykka logs them immediately. The alternative would be to use + # `.ask()` and `.get()` the returned futures to block for the listeners + # to react and return their exceptions to us. Since emitting events in + # practise is making calls upwards in the stack, blocking here would + # quickly deadlock. + listener.tell({ + 'command': 'pykka_call', + 'attr_path': ('on_event',), + 'args': (event,), + 'kwargs': kwargs, + }) class Listener(object): From faa5e01e0a7b19b5b6db1feb87c041d6c770bc90 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 00:37:41 +0100 Subject: [PATCH 022/314] docs: Update changelog --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 40d5cedc..c7594aa3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,6 +12,8 @@ Bug fix release. - Audio: Support UTF-8 in M3U playlists. (Fixes: :issue:`853`) +- Events: Speed up event emitting. + v0.19.5 (2014-12-23) ==================== From f77b73a260e8043fdb39b8564acf104fe9b16d96 Mon Sep 17 00:00:00 2001 From: kingosticks Date: Sun, 8 Feb 2015 23:42:12 +0000 Subject: [PATCH 023/314] Share a global MPDUriMappper between all MPD sessions --- mopidy/mpd/actor.py | 4 +++- mopidy/mpd/dispatcher.py | 24 +++++++++++++----------- mopidy/mpd/session.py | 4 ++-- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index c8123c32..cd9b9145 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -6,7 +6,7 @@ import pykka from mopidy import exceptions, zeroconf from mopidy.core import CoreListener -from mopidy.mpd import session +from mopidy.mpd import dispatcher, session from mopidy.utils import encoding, network, process logger = logging.getLogger(__name__) @@ -18,6 +18,7 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): self.hostname = network.format_hostname(config['mpd']['hostname']) self.port = config['mpd']['port'] + self.uri_map = dispatcher.MpdUriMapper(core) self.zeroconf_name = config['mpd']['zeroconf'] self.zeroconf_service = None @@ -29,6 +30,7 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): protocol_kwargs={ 'config': config, 'core': core, + 'uri_map': self.uri_map, }, max_connections=config['mpd']['max_connections'], timeout=config['mpd']['connection_timeout']) diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index 78536a64..a591127c 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -21,7 +21,7 @@ class MpdDispatcher(object): _noidle = re.compile(r'^noidle$') - def __init__(self, session=None, config=None, core=None): + def __init__(self, session=None, config=None, core=None, uri_map=None): self.config = config self.authenticated = False self.command_list_receiving = False @@ -29,7 +29,7 @@ class MpdDispatcher(object): self.command_list = [] self.command_list_index = None self.context = MpdContext( - self, session=session, config=config, core=core) + self, session=session, config=config, core=core, uri_map=uri_map) def handle_request(self, request, current_command_list_index=None): """Dispatch incoming requests to the correct handler.""" @@ -227,9 +227,10 @@ class MpdContext(object): #: The subsytems that we want to be notified about in idle mode. subscriptions = None - _mapping = None + _uri_map = None - def __init__(self, dispatcher, session=None, config=None, core=None): + def __init__(self, dispatcher, session=None, config=None, core=None, + uri_map=None): self.dispatcher = dispatcher self.session = session if config is not None: @@ -237,19 +238,19 @@ class MpdContext(object): self.core = core self.events = set() self.subscriptions = set() - self._mapping = MpdUriMapper(core) + self._uri_map = uri_map def lookup_playlist_from_name(self, name): """ Helper function to retrieve a playlist from its unique MPD name. """ - return self._mapping.playlist_from_name(name) + return self._uri_map.playlist_from_name(name) def lookup_playlist_name_from_uri(self, uri): """ Helper function to retrieve the unique MPD playlist name from its uri. """ - return self._mapping.playlist_name_from_uri(uri) + return self._uri_map.playlist_name_from_uri(uri) def browse(self, path, recursive=True, lookup=True): """ @@ -273,7 +274,7 @@ class MpdContext(object): path_parts = re.findall(r'[^/]+', path or '') root_path = '/'.join([''] + path_parts) - uri = self._mapping.uri_from_name(root_path) + uri = self._uri_map.uri_from_name(root_path) if uri is None: for part in path_parts: for ref in self.core.library.browse(uri).get(): @@ -282,7 +283,7 @@ class MpdContext(object): break else: raise exceptions.MpdNoExistError('Not found') - root_path = self._mapping.insert(root_path, uri) + root_path = self._uri_map.insert(root_path, uri) if recursive: yield (root_path, None) @@ -292,7 +293,7 @@ class MpdContext(object): base_path, future = path_and_futures.pop() for ref in future.get(): path = '/'.join([base_path, ref.name.replace('/', '')]) - path = self._mapping.insert(path, ref.uri) + path = self._uri_map.insert(path, ref.uri) if ref.type == ref.TRACK: if lookup: @@ -305,6 +306,7 @@ class MpdContext(object): path_and_futures.append( (path, self.core.library.browse(ref.uri))) + class MpdUriMapper(object): """ Maintains the mappings between uniquified MPD names and URIs. @@ -379,4 +381,4 @@ class MpdUriMapper(object): """ if uri not in self._name_from_uri: self.refresh_playlists_mapping() - return self._name_from_uri[uri] \ No newline at end of file + return self._name_from_uri[uri] diff --git a/mopidy/mpd/session.py b/mopidy/mpd/session.py index 0e606c8f..9f7fabeb 100644 --- a/mopidy/mpd/session.py +++ b/mopidy/mpd/session.py @@ -18,10 +18,10 @@ class MpdSession(network.LineProtocol): encoding = protocol.ENCODING delimiter = r'\r?\n' - def __init__(self, connection, config=None, core=None): + def __init__(self, connection, config=None, core=None, uri_map=None): super(MpdSession, self).__init__(connection) self.dispatcher = dispatcher.MpdDispatcher( - session=self, config=config, core=core) + session=self, config=config, core=core, uri_map=uri_map) def on_start(self): logger.info('New MPD connection from [%s]:%s', self.host, self.port) From 317d02de3e7a181b5248bcb57045478c41d35a3e Mon Sep 17 00:00:00 2001 From: kingosticks Date: Sun, 8 Feb 2015 23:43:22 +0000 Subject: [PATCH 024/314] Pass MPDUrimapper into mpd test setup --- tests/mpd/protocol/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index 8c744a78..f895316b 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -8,7 +8,7 @@ import pykka from mopidy import core from mopidy.backend import dummy -from mopidy.mpd import session +from mopidy.mpd import dispatcher, session class MockConnection(mock.Mock): @@ -35,9 +35,11 @@ class BaseTestCase(unittest.TestCase): self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() + self.uri_map = dispatcher.MpdUriMapper(self.core) self.connection = MockConnection() self.session = session.MpdSession( - self.connection, config=self.get_config(), core=self.core) + self.connection, config=self.get_config(), core=self.core, + uri_map=self.uri_map) self.dispatcher = self.session.dispatcher self.context = self.dispatcher.context From 037a88aece4f80d86bbcf4d5a4aa905671860215 Mon Sep 17 00:00:00 2001 From: kingosticks Date: Sun, 8 Feb 2015 23:47:28 +0000 Subject: [PATCH 025/314] First playlist mapping refresh deferred until needed --- mopidy/mpd/dispatcher.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index a591127c..a0950625 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -322,7 +322,6 @@ class MpdUriMapper(object): self.core = core self._uri_from_name = {} self._name_from_uri = {} - self.refresh_playlists_mapping() def _create_unique_name(self, name, uri): stripped_name = self._invalid_browse_chars.sub(' ', name) From efd48d864f6f175a7941b15ee0211f7362729656 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 7 Feb 2015 17:09:33 +0100 Subject: [PATCH 026/314] flake8: Fix new warnings after flake8 upgrade (cherry picked from commit a693993905b66a63601cf5765da1788829b0f798) Conflicts: mopidy/audio/actor.py mopidy/audio/playlists.py --- mopidy/audio/actor.py | 4 ++-- mopidy/audio/playlists.py | 2 +- mopidy/local/__init__.py | 2 +- mopidy/local/json.py | 2 +- mopidy/local/translator.py | 2 +- setup.cfg | 4 ++++ tests/__init__.py | 2 +- tests/utils/test_jsonrpc.py | 2 +- 8 files changed, 12 insertions(+), 8 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 0d90394d..d2701784 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -109,8 +109,8 @@ class Audio(pykka.ThreadingActor): playbin = gst.element_factory_make('playbin2') playbin.set_property('flags', PLAYBIN_FLAGS) - playbin.set_property('buffer-size', 2*1024*1024) - playbin.set_property('buffer-duration', 2*gst.SECOND) + playbin.set_property('buffer-size', 2 * 1024 * 1024) + playbin.set_property('buffer-duration', 2 * gst.SECOND) self._connect(playbin, 'about-to-finish', self._on_about_to_finish) self._connect(playbin, 'notify::source', self._on_new_source) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index ec5fd63a..8f8232b2 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -77,7 +77,7 @@ def parse_pls(data): if section.lower() != 'playlist': continue for i in xrange(cp.getint(section, 'numberofentries')): - yield cp.get(section, 'file%d' % (i+1)) + yield cp.get(section, 'file%d' % (i + 1)) def parse_xspf(data): diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 104c43af..9b485f19 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -27,7 +27,7 @@ class Extension(ext.Extension): schema['playlists_dir'] = config.Path() schema['tag_cache_file'] = config.Deprecated() schema['scan_timeout'] = config.Integer( - minimum=1000, maximum=1000*60*60) + minimum=1000, maximum=1000 * 60 * 60) schema['scan_flush_threshold'] = config.Integer(minimum=0) schema['excluded_file_extensions'] = config.List(optional=True) return schema diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 5ae04592..b3a2ff39 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -75,7 +75,7 @@ class _BrowseCache(object): parent_uri = None child = None for i in reversed(range(len(parts))): - directory = '/'.join(parts[:i+1]) + directory = '/'.join(parts[:i + 1]) uri = translator.path_to_local_directory_uri(directory) # First dir we process is our parent diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 33b67775..3c1d38ae 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -49,7 +49,7 @@ def m3u_extinf_to_track(line): return Track() (runtime, title) = m.groups() if int(runtime) > 0: - return Track(name=title, length=1000*int(runtime)) + return Track(name=title, length=1000 * int(runtime)) else: return Track(name=title) diff --git a/setup.cfg b/setup.cfg index 80ab9645..0d6c1486 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,10 @@ [flake8] application-import-names = mopidy,tests exclude = .git,.tox,build,js +# Ignored flake8 warnings: +# - E402 module level import not at top of file +# - E731 do not assign a lambda expression, use a def +ignore = E402,E731 [wheel] universal = 1 diff --git a/tests/__init__.py b/tests/__init__.py index a384669e..327ca5a8 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -20,7 +20,7 @@ class IsA(object): try: return isinstance(rhs, self.klass) except TypeError: - return type(rhs) == type(self.klass) + return type(rhs) == type(self.klass) # flake8: noqa def __ne__(self, rhs): return not self.__eq__(rhs) diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index e6f94fb3..c8d37d04 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -614,7 +614,7 @@ class JsonRpcInspectorTest(JsonRpcTestBase): 'core.library': core.LibraryController, 'core.playback': core.PlaybackController, 'core.playlists': core.PlaylistsController, - 'core.tracklist': core.TracklistController, + 'core.tracklist': core.TracklistController, }) methods = inspector.describe() From 0de13994c234b3a6cd0be14ac01364814d5b66ef Mon Sep 17 00:00:00 2001 From: kingosticks Date: Mon, 9 Feb 2015 12:07:17 +0000 Subject: [PATCH 027/314] Moved MPDUriMapper to own file and updated changelog --- docs/changelog.rst | 3 ++ mopidy/mpd/actor.py | 4 +- mopidy/mpd/dispatcher.py | 76 -------------------------------- mopidy/mpd/uri_mapper.py | 79 ++++++++++++++++++++++++++++++++++ tests/mpd/protocol/__init__.py | 4 +- 5 files changed, 86 insertions(+), 80 deletions(-) create mode 100644 mopidy/mpd/uri_mapper.py diff --git a/docs/changelog.rst b/docs/changelog.rst index 5be97bd9..47bbe0b3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -46,6 +46,9 @@ v0.20.0 (UNRELEASED) - Enable browsing of artist references, in addition to albums and playlists. (PR: :issue:`884`) + +- Share a single mapping between names and URIs across all MPD sessions. (Fixes: + :issue:`934`, PR: :issue:`968`) **Audio** diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index cd9b9145..c9ffff02 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -6,7 +6,7 @@ import pykka from mopidy import exceptions, zeroconf from mopidy.core import CoreListener -from mopidy.mpd import dispatcher, session +from mopidy.mpd import session, uri_mapper from mopidy.utils import encoding, network, process logger = logging.getLogger(__name__) @@ -18,7 +18,7 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): self.hostname = network.format_hostname(config['mpd']['hostname']) self.port = config['mpd']['port'] - self.uri_map = dispatcher.MpdUriMapper(core) + self.uri_map = uri_mapper.MpdUriMapper(core) self.zeroconf_name = config['mpd']['zeroconf'] self.zeroconf_service = None diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index a0950625..b1b2db77 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -305,79 +305,3 @@ class MpdContext(object): if recursive: path_and_futures.append( (path, self.core.library.browse(ref.uri))) - - -class MpdUriMapper(object): - """ - Maintains the mappings between uniquified MPD names and URIs. - """ - - #: The Mopidy core API. An instance of :class:`mopidy.core.Core`. - core = None - - _invalid_browse_chars = re.compile(r'[\n\r]') - _invalid_playlist_chars = re.compile(r'[/]') - - def __init__(self, core=None): - self.core = core - self._uri_from_name = {} - self._name_from_uri = {} - - def _create_unique_name(self, name, uri): - stripped_name = self._invalid_browse_chars.sub(' ', name) - name = stripped_name - i = 2 - while name in self._uri_from_name: - if self._uri_from_name[name] == uri: - return name - name = '%s [%d]' % (stripped_name, i) - i += 1 - return name - - def insert(self, name, uri): - """ - Create a unique and MPD compatible name that maps to the given uri. - """ - name = self._create_unique_name(name, uri) - self._uri_from_name[name] = uri - self._name_from_uri[uri] = name - return name - - def uri_from_name(self, name): - """ - Return the uri for the given MPD name. - """ - if name in self._uri_from_name: - return self._uri_from_name[name] - - def refresh_playlists_mapping(self): - """ - Maintain map between playlists and unique playlist names to be used by - MPD - """ - if self.core is not None: - for playlist in self.core.playlists.playlists.get(): - if not playlist.name: - continue - # TODO: add scheme to name perhaps 'foo (spotify)' etc. - name = self._invalid_playlist_chars.sub('|', playlist.name) - self.insert(name, playlist.uri) - - def playlist_from_name(self, name): - """ - Helper function to retrieve a playlist from its unique MPD name. - """ - if not self._uri_from_name: - self.refresh_playlists_mapping() - if name not in self._uri_from_name: - return None - uri = self._uri_from_name[name] - return self.core.playlists.lookup(uri).get() - - def playlist_name_from_uri(self, uri): - """ - Helper function to retrieve the unique MPD playlist name from its uri. - """ - if uri not in self._name_from_uri: - self.refresh_playlists_mapping() - return self._name_from_uri[uri] diff --git a/mopidy/mpd/uri_mapper.py b/mopidy/mpd/uri_mapper.py new file mode 100644 index 00000000..99413493 --- /dev/null +++ b/mopidy/mpd/uri_mapper.py @@ -0,0 +1,79 @@ +from __future__ import absolute_import, unicode_literals + +import re + + +class MpdUriMapper(object): + """ + Maintains the mappings between uniquified MPD names and URIs. + """ + + #: The Mopidy core API. An instance of :class:`mopidy.core.Core`. + core = None + + _invalid_browse_chars = re.compile(r'[\n\r]') + _invalid_playlist_chars = re.compile(r'[/]') + + def __init__(self, core=None): + self.core = core + self._uri_from_name = {} + self._name_from_uri = {} + + def _create_unique_name(self, name, uri): + stripped_name = self._invalid_browse_chars.sub(' ', name) + name = stripped_name + i = 2 + while name in self._uri_from_name: + if self._uri_from_name[name] == uri: + return name + name = '%s [%d]' % (stripped_name, i) + i += 1 + return name + + def insert(self, name, uri): + """ + Create a unique and MPD compatible name that maps to the given uri. + """ + name = self._create_unique_name(name, uri) + self._uri_from_name[name] = uri + self._name_from_uri[uri] = name + return name + + def uri_from_name(self, name): + """ + Return the uri for the given MPD name. + """ + if name in self._uri_from_name: + return self._uri_from_name[name] + + def refresh_playlists_mapping(self): + """ + Maintain map between playlists and unique playlist names to be used by + MPD + """ + if self.core is not None: + for playlist in self.core.playlists.playlists.get(): + if not playlist.name: + continue + # TODO: add scheme to name perhaps 'foo (spotify)' etc. + name = self._invalid_playlist_chars.sub('|', playlist.name) + self.insert(name, playlist.uri) + + def playlist_from_name(self, name): + """ + Helper function to retrieve a playlist from its unique MPD name. + """ + if not self._uri_from_name: + self.refresh_playlists_mapping() + if name not in self._uri_from_name: + return None + uri = self._uri_from_name[name] + return self.core.playlists.lookup(uri).get() + + def playlist_name_from_uri(self, uri): + """ + Helper function to retrieve the unique MPD playlist name from its uri. + """ + if uri not in self._name_from_uri: + self.refresh_playlists_mapping() + return self._name_from_uri[uri] diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index f895316b..8c7b60f1 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -8,7 +8,7 @@ import pykka from mopidy import core from mopidy.backend import dummy -from mopidy.mpd import dispatcher, session +from mopidy.mpd import session, uri_mapper class MockConnection(mock.Mock): @@ -35,7 +35,7 @@ class BaseTestCase(unittest.TestCase): self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() - self.uri_map = dispatcher.MpdUriMapper(self.core) + self.uri_map = uri_mapper.MpdUriMapper(self.core) self.connection = MockConnection() self.session = session.MpdSession( self.connection, config=self.get_config(), core=self.core, From 0ec92121465077eb84423785311007e4c72ee3cc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 13:13:51 +0100 Subject: [PATCH 028/314] docs: Break lines and tweak changes from PR#959 --- docs/contributing.rst | 6 ++++-- docs/installation/source.rst | 12 +++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index a4433951..165fee49 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -13,8 +13,10 @@ Getting started #. Make sure you have a `GitHub account `_. -#. If a ticket does not already exist `Submit `_ a ticket for your - issue. Make sure to clearly describe the issue and if it is a bug: include steps to reproduce. +#. If a ticket does not already exist `submit a ticket + `_ for your issue. + Make sure to clearly describe the issue, and if it is a bug: include steps + to reproduce. #. Fork the repository on GitHub. diff --git a/docs/installation/source.rst b/docs/installation/source.rst index 4ac9c802..2c4147f1 100644 --- a/docs/installation/source.rst +++ b/docs/installation/source.rst @@ -5,10 +5,11 @@ Install from source ******************* If you are on Linux, but can't install :ref:`from the APT archive -` or :ref:`from AUR `, you can install Mopidy using the python package installer. +` or :ref:`from AUR `, you can install Mopidy +from PyPI using the ``pip`` installer. -If you are looking to contribute or wish to install from source using ``git`` please follow the directions -:ref:`here `. +If you are looking to contribute or wish to install from source using ``git`` +please follow the directions :ref:`here `. #. First of all, you need Python 2.7. Check if you have Python and what version by running:: @@ -71,8 +72,9 @@ If you are looking to contribute or wish to install from source using ``git`` pl sudo pip install -U mopidy - This will use pip to install the source files for the latest stable release. - To upgrade Mopidy to future releases, just rerun this command. + This will use ``pip`` to install the latest release of `Mopidy from PyPI + `_. To upgrade Mopidy to future + releases, just rerun this command. Alternatively, if you want to track Mopidy development closer, you may install a snapshot of Mopidy's ``develop`` Git branch using pip:: From 75955822b70884c130d3b04bcbe8689650f0b53e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 13:16:17 +0100 Subject: [PATCH 029/314] docs: Remove ext steps from source install docs Maintaining multiple copies of instructions for how to install specific extensions doesn't scale well. --- docs/installation/source.rst | 36 +++--------------------------------- 1 file changed, 3 insertions(+), 33 deletions(-) diff --git a/docs/installation/source.rst b/docs/installation/source.rst index 2c4147f1..0b4fc5aa 100644 --- a/docs/installation/source.rst +++ b/docs/installation/source.rst @@ -81,39 +81,9 @@ please follow the directions :ref:`here `. sudo pip install --allow-unverified=mopidy mopidy==dev -#. Optional: If you want Spotify support in Mopidy, you'll need to install - libspotify and the Mopidy-Spotify extension. - - #. Download and install the latest version of libspotify for your OS and CPU - architecture from `Spotify - `_. - - For libspotify 12.1.51 for 64-bit Linux the process is as follows:: - - wget https://developer.spotify.com/download/libspotify/libspotify-12.1.51-Linux-x86_64-release.tar.gz - tar zxfv libspotify-12.1.51-Linux-x86_64-release.tar.gz - cd libspotify-12.1.51-Linux-x86_64-release/ - sudo make install prefix=/usr/local - - Remember to adjust the above example for the latest libspotify version - supported by pyspotify, your OS, and your CPU architecture. - - #. If you're on Fedora, you must add a configuration file so libspotify.so - can be found:: - - echo /usr/local/lib | sudo tee /etc/ld.so.conf.d/libspotify.conf - sudo ldconfig - - #. Then install the latest release of Mopidy-Spotify using pip:: - - sudo pip install -U mopidy-spotify - -#. Optional: If you want to scrobble your played tracks to Last.fm, you need - to install Mopidy-Scrobbler:: - - sudo pip install -U mopidy-scrobbler - -#. For a full list of available Mopidy extensions, see :ref:`ext`. +#. Optional: For Spotify support, Last.fm scrobbling, or many other extra + features, install the required Mopidy extensions. For a full list of + available Mopidy extensions, see :ref:`ext`. #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. From 888af617732654dc9e6cbfd3fc6677dae5da184a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 13:19:05 +0100 Subject: [PATCH 030/314] docs: Update authors --- .mailmap | 1 + AUTHORS | 3 +++ 2 files changed, 4 insertions(+) diff --git a/.mailmap b/.mailmap index 45935be6..8b8fd865 100644 --- a/.mailmap +++ b/.mailmap @@ -17,3 +17,4 @@ Luke Giuliani Colin Montgomerie Ignasi Fosch Christopher Schirner +Laura Barber diff --git a/AUTHORS b/AUTHORS index cd347da8..45817e75 100644 --- a/AUTHORS +++ b/AUTHORS @@ -47,3 +47,6 @@ - Lukas Vogel - Thomas Amland - Deni Bertovic +- Ali Ukani +- Dirk Groenen +- Laura Barber From 4c7ad57e73bc58632994538d080b7d42addf86ce Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 13:31:27 +0100 Subject: [PATCH 031/314] mpd: Capitalize abbrevations in docstrings --- mopidy/mpd/uri_mapper.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/mpd/uri_mapper.py b/mopidy/mpd/uri_mapper.py index 99413493..082f1311 100644 --- a/mopidy/mpd/uri_mapper.py +++ b/mopidy/mpd/uri_mapper.py @@ -32,7 +32,7 @@ class MpdUriMapper(object): def insert(self, name, uri): """ - Create a unique and MPD compatible name that maps to the given uri. + Create a unique and MPD compatible name that maps to the given URI. """ name = self._create_unique_name(name, uri) self._uri_from_name[name] = uri @@ -49,7 +49,7 @@ class MpdUriMapper(object): def refresh_playlists_mapping(self): """ Maintain map between playlists and unique playlist names to be used by - MPD + MPD. """ if self.core is not None: for playlist in self.core.playlists.playlists.get(): @@ -72,7 +72,7 @@ class MpdUriMapper(object): def playlist_name_from_uri(self, uri): """ - Helper function to retrieve the unique MPD playlist name from its uri. + Helper function to retrieve the unique MPD playlist name from its URI. """ if uri not in self._name_from_uri: self.refresh_playlists_mapping() From b7ed2b8681dd9e9c7b5a49a16226a6b37b7e09d5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 10 Feb 2015 23:12:03 +0100 Subject: [PATCH 032/314] core: Fix variable naming, style --- mopidy/core/actor.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index ff60f190..d53e4d38 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -107,31 +107,29 @@ class Core( CoreListener.send('mute_changed', mute=mute) def tags_changed(self, tags): - # Validity checks if not self.audio: return - if self.playback.current_tl_track is None: + + current_tl_track = self.playback.current_tl_track + if current_tl_track is None: return tags = self.audio.get_current_tags().get() if not tags: return - # Request available metadata and set a track - mt_track = convert_tags_to_track(tags) + current_track = current_tl_track.track + tags_track = convert_tags_to_track(tags) - # Merge current_tl_track with metadata in current_metadata_track - c_track = self.playback.current_tl_track.track - track_kwargs = {k: v for k, v in c_track.__dict__.items() if v} - for k, v in mt_track.__dict__.items(): - if v: - track_kwargs[k] = v + track_kwargs = {k: v for k, v in current_track.__dict__.items() if v} + track_kwargs.update( + {k: v for k, v in tags_track.__dict__.items() if v}) self.playback.current_metadata_track = TlTrack(**{ - 'tlid': self.playback.current_tl_track.tlid, + 'tlid': current_tl_track.tlid, 'track': Track(**track_kwargs)}) - # Send event to frontends + # TODO Move this into playback.current_metadata_track setter? CoreListener.send('current_metadata_changed') From 1b6db5695d6271c047d0cba1b291f7fc98c2fff4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 10 Feb 2015 23:43:16 +0100 Subject: [PATCH 033/314] core: current_metadata_track is TlTrack, not Track --- mopidy/core/playback.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 2bc2fbe6..3b359b28 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -52,6 +52,17 @@ class PlaybackController(object): Read-only. Extracted from :attr:`current_tl_track` for convenience. """ + def get_current_metadata_track(self): + return self.current_metadata_track + + current_metadata_track = None + """ + A :class:`mopidy.models.TlTrack` with updated metadata for the currently + playing track. + + :class:`None` if no track is currently playing. + """ + def get_state(self): return self._state @@ -126,15 +137,6 @@ class PlaybackController(object): mute = property(get_mute, set_mute) """Mute state as a :class:`True` if muted, :class:`False` otherwise""" - def get_current_metadata_track(self): - return self.current_metadata_track - - current_metadata_track = None - """ - The currently playing metadata :class:`mopidy.models.Track`, - or :class:`None`. - """ - # Methods # TODO: remove this. From 352de135cd12dfccf8376cfaf53078c9611d821b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 01:11:56 +0100 Subject: [PATCH 034/314] core: Deprecate properties (fixes #952) --- docs/changelog.rst | 4 ++ mopidy/core/actor.py | 12 +++- mopidy/core/playback.py | 123 +++++++++++++++++++++++++++++---------- mopidy/core/playlists.py | 10 +++- mopidy/core/tracklist.py | 120 +++++++++++++++++++++++++++++--------- 5 files changed, 203 insertions(+), 66 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 47bbe0b3..1a9eb33d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,6 +10,10 @@ v0.20.0 (UNRELEASED) **Core API** +- Deprecate all properties in the core API. The previously undocumented getter + and setter methods are now the official API. This aligns the Python API with + the WebSocket/JavaScript API. (Fixes: :issue:`952`) + - Added :class:`mopidy.core.HistoryController` which keeps track of what tracks have been played. (Fixes: :issue:`423`, PR: :issue:`803`) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index d53e4d38..370c9e0d 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -62,19 +62,27 @@ class Core( self.audio = audio def get_uri_schemes(self): + """Get list of URI schemes we can handle""" futures = [b.uri_schemes for b in self.backends] results = pykka.get_all(futures) uri_schemes = itertools.chain(*results) return sorted(uri_schemes) uri_schemes = property(get_uri_schemes) - """List of URI schemes we can handle""" + """ + .. deprecated:: 0.20 + Use :meth:`get_uri_schemes` instead. + """ def get_version(self): + """Get version of the Mopidy core API""" return versioning.get_version() version = property(get_version) - """Version of the Mopidy core API""" + """ + .. deprecated:: 0.20 + Use :meth:`get_version` instead. + """ def reached_end_of_stream(self): self.playback.on_end_of_track() diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 3b359b28..72a9207c 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -19,6 +19,8 @@ class PlaybackController(object): self.backends = backends self.core = core + self._current_tl_track = None + self._current_metadata_track = None self._state = PlaybackState.STOPPED self._volume = None self._mute = False @@ -34,39 +36,80 @@ class PlaybackController(object): # Properties def get_current_tl_track(self): - return self.current_tl_track + """Get the currently playing or selected track. - current_tl_track = None + Returns a :class:`mopidy.models.TlTrack` or :class:`None`. + """ + return self._current_tl_track + + def set_current_tl_track(self, value): + """Set the currently playing or selected track. + + *Internal:* This is only for use by Mopidy's test suite. + """ + self._current_tl_track = value + + current_tl_track = property(get_current_tl_track, set_current_tl_track) """ - The currently playing or selected :class:`mopidy.models.TlTrack`, or - :class:`None`. + .. deprecated:: 0.20 + Use :meth:`get_current_tl_track` instead. """ def get_current_track(self): - return self.current_tl_track and self.current_tl_track.track + """ + Get the currently playing or selected track. + + Extracted from :meth:`get_current_tl_track` for convenience. + + Returns a :class:`mopidy.models.Track` or :class:`None`. + """ + tl_track = self.get_current_tl_track() + if tl_track is not None: + return tl_track.track current_track = property(get_current_track) """ - The currently playing or selected :class:`mopidy.models.Track`. - - Read-only. Extracted from :attr:`current_tl_track` for convenience. + .. deprecated:: 0.20 + Use :meth:`get_current_track` instead. """ def get_current_metadata_track(self): - return self.current_metadata_track + """ + Get a :class:`mopidy.models.TlTrack` with updated metadata for the + currently playing track. - current_metadata_track = None + Returns :class:`None` if no track is currently playing. + """ + return self._current_metadata_track + + current_metadata_track = property(get_current_metadata_track) """ - A :class:`mopidy.models.TlTrack` with updated metadata for the currently - playing track. - - :class:`None` if no track is currently playing. + .. deprecated:: 0.20 + Use :meth:`get_current_metadata_track` instead. """ def get_state(self): + """Get The playback state.""" + return self._state def set_state(self, new_state): + """Set the playback state. + + Must be :attr:`PLAYING`, :attr:`PAUSED`, or :attr:`STOPPED`. + + Possible states and transitions: + + .. digraph:: state_transitions + + "STOPPED" -> "PLAYING" [ label="play" ] + "STOPPED" -> "PAUSED" [ label="pause" ] + "PLAYING" -> "STOPPED" [ label="stop" ] + "PLAYING" -> "PAUSED" [ label="pause" ] + "PLAYING" -> "PLAYING" [ label="play" ] + "PAUSED" -> "PLAYING" [ label="resume" ] + "PAUSED" -> "STOPPED" [ label="stop" ] + """ (old_state, self._state) = (self.state, new_state) logger.debug('Changing state: %s -> %s', old_state, new_state) @@ -74,23 +117,12 @@ class PlaybackController(object): state = property(get_state, set_state) """ - The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or - :attr:`STOPPED`. - - Possible states and transitions: - - .. digraph:: state_transitions - - "STOPPED" -> "PLAYING" [ label="play" ] - "STOPPED" -> "PAUSED" [ label="pause" ] - "PLAYING" -> "STOPPED" [ label="stop" ] - "PLAYING" -> "PAUSED" [ label="pause" ] - "PLAYING" -> "PLAYING" [ label="play" ] - "PAUSED" -> "PLAYING" [ label="resume" ] - "PAUSED" -> "STOPPED" [ label="stop" ] + .. deprecated:: 0.20 + Use :meth:`get_state` and :meth:`set_state` instead. """ def get_time_position(self): + """Get time position in milliseconds.""" backend = self._get_backend() if backend: return backend.playback.get_time_position().get() @@ -98,9 +130,18 @@ class PlaybackController(object): return 0 time_position = property(get_time_position) - """Time position in milliseconds.""" + """ + .. deprecated:: 0.20 + Use :meth:`get_time_position` instead. + """ def get_volume(self): + """Get the volume. + + Integer in range [0..100] or :class:`None` if unknown. + + The volume scale is linear. + """ if self.mixer: return self.mixer.get_volume().get() else: @@ -108,6 +149,12 @@ class PlaybackController(object): return self._volume def set_volume(self, volume): + """Set the volume. + + The volume is defined as an integer in range [0..100]. + + The volume scale is linear. + """ if self.mixer: self.mixer.set_volume(volume) else: @@ -115,11 +162,16 @@ class PlaybackController(object): self._volume = volume volume = property(get_volume, set_volume) - """Volume as int in range [0..100] or :class:`None` if unknown. The volume - scale is linear. + """ + .. deprecated:: 0.20 + Use :meth:`get_volume` and :meth:`set_volume` instead. """ def get_mute(self): + """Get mute state. + + :class:`True` if muted, :class:`False` otherwise. + """ if self.mixer: return self.mixer.get_mute().get() else: @@ -127,6 +179,10 @@ class PlaybackController(object): return self._mute def set_mute(self, value): + """Set mute state. + + :class:`True` to mute, :class:`False` to unmute. + """ value = bool(value) if self.mixer: self.mixer.set_mute(value) @@ -135,7 +191,10 @@ class PlaybackController(object): self._mute = value mute = property(get_mute, set_mute) - """Mute state as a :class:`True` if muted, :class:`False` otherwise""" + """ + .. deprecated:: 0.20 + Use :meth:`get_mute` and :meth:`set_mute` instead. + """ # Methods diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index c896bfa7..16b29b85 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -15,6 +15,11 @@ class PlaylistsController(object): self.backends = backends self.core = core + """ + Get the available playlists. + + Returns a list of :class:`mopidy.models.Playlist`. + """ def get_playlists(self, include_tracks=True): futures = [b.playlists.playlists for b in self.backends.with_playlists.values()] @@ -26,9 +31,8 @@ class PlaylistsController(object): playlists = property(get_playlists) """ - The available playlists. - - Read-only. List of :class:`mopidy.models.Playlist`. + .. deprecated:: 0.20 + Use :meth:`get_playlists` instead. """ def create(self, name, uri_scheme=None): diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index f9560a13..5f7ddba1 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -26,32 +26,42 @@ class TracklistController(object): # Properties def get_tl_tracks(self): + """Get tracklist as list of :class:`mopidy.models.TlTrack`.""" return self._tl_tracks[:] tl_tracks = property(get_tl_tracks) """ - List of :class:`mopidy.models.TlTrack`. - - Read-only. + .. deprecated:: 0.20 + Use :meth:`get_tl_tracks` instead. """ def get_tracks(self): + """Get tracklist as list of :class:`mopidy.models.Track`.""" return [tl_track.track for tl_track in self._tl_tracks] tracks = property(get_tracks) """ - List of :class:`mopidy.models.Track` in the tracklist. - - Read-only. + .. deprecated:: 0.20 + Use :meth:`get_tracks` instead. """ def get_length(self): + """Get length of the tracklist.""" return len(self._tl_tracks) length = property(get_length) - """Length of the tracklist.""" + """ + .. deprecated:: 0.20 + Use :meth:`get_length` instead. + """ def get_version(self): + """ + Get the tracklist version. + + Integer which is increased every time the tracklist is changed. Is not + reset before Mopidy is restarted. + """ return self._version def _increase_version(self): @@ -61,32 +71,57 @@ class TracklistController(object): version = property(get_version) """ - The tracklist version. - - Read-only. Integer which is increased every time the tracklist is changed. - Is not reset before Mopidy is restarted. + .. deprecated:: 0.20 + Use :meth:`get_version` instead. """ def get_consume(self): + """Get consume mode. + + :class:`True` + Tracks are removed from the tracklist when they have been played. + :class:`False` + Tracks are not removed from the tracklist. + """ return getattr(self, '_consume', False) def set_consume(self, value): + """Set consume mode. + + :class:`True` + Tracks are removed from the tracklist when they have been played. + :class:`False` + Tracks are not removed from the tracklist. + """ 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 tracklist when they have been played. - :class:`False` - Tracks are not removed from the tracklist. + .. deprecated:: 0.20 + Use :meth:`get_consume` and :meth:`set_consume` instead. """ def get_random(self): + """Get random mode. + + :class:`True` + Tracks are selected at random from the tracklist. + :class:`False` + Tracks are played in the order of the tracklist. + """ return getattr(self, '_random', False) def set_random(self, value): + """Set random mode. + + :class:`True` + Tracks are selected at random from the tracklist. + :class:`False` + Tracks are played in the order of the tracklist. + """ + if self.get_random() != value: self._trigger_options_changed() if value: @@ -96,44 +131,71 @@ class TracklistController(object): random = property(get_random, set_random) """ - :class:`True` - Tracks are selected at random from the tracklist. - :class:`False` - Tracks are played in the order of the tracklist. + .. deprecated:: 0.20 + Use :meth:`get_random` and :meth:`set_random` instead. """ def get_repeat(self): + """ + Get repeat mode. + + :class:`True` + The tracklist is played repeatedly. + :class:`False` + The tracklist is played once. + """ return getattr(self, '_repeat', False) def set_repeat(self, value): + """ + Set repeat mode. + + To repeat a single track, set both ``repeat`` and ``single``. + + :class:`True` + The tracklist is played repeatedly. + :class:`False` + The tracklist is played once. + """ + if self.get_repeat() != value: self._trigger_options_changed() return setattr(self, '_repeat', value) repeat = property(get_repeat, set_repeat) """ - :class:`True` - The tracklist is played repeatedly. To repeat a single track, select - both :attr:`repeat` and :attr:`single`. - :class:`False` - The tracklist is played once. + .. deprecated:: 0.20 + Use :meth:`get_repeat` and :meth:`set_repeat` instead. """ def get_single(self): + """ + Get single mode. + + :class:`True` + Playback is stopped after current song, unless in ``repeat`` mode. + :class:`False` + Playback continues after current song. + """ return getattr(self, '_single', False) def set_single(self, value): + """ + Set single mode. + + :class:`True` + Playback is stopped after current song, unless in ``repeat`` mode. + :class:`False` + Playback continues after current song. + """ 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. + .. deprecated:: 0.20 + Use :meth:`get_single` and :meth:`set_single` instead. """ # Methods From 5827e45c340018111ba52e47ad748a343bd44321 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 02:16:00 +0100 Subject: [PATCH 035/314] core: Use getters/setters internally in core This fixes all the easy-to-track warnings. --- mopidy/core/actor.py | 4 +-- mopidy/core/playback.py | 72 +++++++++++++++++++++------------------- mopidy/core/tracklist.py | 36 ++++++++++---------- 3 files changed, 58 insertions(+), 54 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 370c9e0d..4eabd0ad 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -118,7 +118,7 @@ class Core( if not self.audio: return - current_tl_track = self.playback.current_tl_track + current_tl_track = self.playback.get_current_tl_track() if current_tl_track is None: return @@ -133,7 +133,7 @@ class Core( track_kwargs.update( {k: v for k, v in tags_track.__dict__.items() if v}) - self.playback.current_metadata_track = TlTrack(**{ + self.playback._current_metadata_track = TlTrack(**{ 'tlid': current_tl_track.tlid, 'track': Track(**track_kwargs)}) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 72a9207c..62e83abe 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -27,9 +27,10 @@ class PlaybackController(object): def _get_backend(self): # TODO: take in track instead - if self.current_tl_track is None: + track = self.get_current_track() + if track is None: return None - uri = self.current_tl_track.track.uri + uri = track.uri uri_scheme = urlparse.urlparse(uri).scheme return self.backends.with_playback.get(uri_scheme, None) @@ -110,7 +111,7 @@ class PlaybackController(object): "PAUSED" -> "PLAYING" [ label="resume" ] "PAUSED" -> "STOPPED" [ label="stop" ] """ - (old_state, self._state) = (self.state, new_state) + (old_state, self._state) = (self.get_state(), new_state) logger.debug('Changing state: %s -> %s', old_state, new_state) self._trigger_playback_state_changed(old_state, new_state) @@ -209,9 +210,9 @@ class PlaybackController(object): track (default), -1 for previous track. **INTERNAL** :type on_error_step: int, -1 or 1 """ - old_state = self.state + old_state = self.get_state() self.stop() - self.current_tl_track = tl_track + self.set_current_tl_track(tl_track) if old_state == PlaybackState.PLAYING: self.play(on_error_step=on_error_step) elif old_state == PlaybackState.PAUSED: @@ -224,17 +225,17 @@ class PlaybackController(object): Used by event handler in :class:`mopidy.core.Core`. """ - if self.state == PlaybackState.STOPPED: + if self.get_state() == PlaybackState.STOPPED: return - original_tl_track = self.current_tl_track + original_tl_track = self.get_current_tl_track() next_tl_track = self.core.tracklist.eot_track(original_tl_track) if next_tl_track: self.change_track(next_tl_track) else: self.stop() - self.current_tl_track = None + self.set_current_tl_track(None) self.core.tracklist.mark_played(original_tl_track) @@ -244,9 +245,10 @@ class PlaybackController(object): Used by :class:`mopidy.core.TracklistController`. """ - if self.current_tl_track not in self.core.tracklist.tl_tracks: + tracklist = self.core.tracklist.get_tl_tracks() + if self.get_current_tl_track() not in tracklist: self.stop() - self.current_tl_track = None + self.set_current_tl_track(None) def next(self): """ @@ -255,7 +257,7 @@ class PlaybackController(object): The current playback state will be kept. If it was playing, playing will continue. If it was paused, it will still be paused, etc. """ - original_tl_track = self.current_tl_track + original_tl_track = self.get_current_tl_track() next_tl_track = self.core.tracklist.next_track(original_tl_track) if next_tl_track: @@ -265,7 +267,7 @@ class PlaybackController(object): self.change_track(next_tl_track) else: self.stop() - self.current_tl_track = None + self.set_current_tl_track(None) self.core.tracklist.mark_played(original_tl_track) @@ -276,7 +278,7 @@ class PlaybackController(object): # TODO: switch to: # backend.track(pause) # wait for state change? - self.state = PlaybackState.PAUSED + self.set_state(PlaybackState.PAUSED) self._trigger_track_playback_paused() def play(self, tl_track=None, on_error_step=1): @@ -294,11 +296,11 @@ class PlaybackController(object): assert on_error_step in (-1, 1) if tl_track is None: - if self.state == PlaybackState.PAUSED: + if self.get_state() == PlaybackState.PAUSED: return self.resume() - if self.current_tl_track is not None: - tl_track = self.current_tl_track + if self.get_current_tl_track() is not None: + tl_track = self.get_current_tl_track() else: if on_error_step == 1: tl_track = self.core.tracklist.next_track(tl_track) @@ -308,17 +310,17 @@ class PlaybackController(object): if tl_track is None: return - assert tl_track in self.core.tracklist.tl_tracks + assert tl_track in self.core.tracklist.get_tl_tracks() # TODO: switch to: # backend.play(track) # wait for state change? - if self.state == PlaybackState.PLAYING: + if self.get_state() == PlaybackState.PLAYING: self.stop() - self.current_tl_track = tl_track - self.state = PlaybackState.PLAYING + self.set_current_tl_track(tl_track) + self.set_state(PlaybackState.PLAYING) backend = self._get_backend() success = backend and backend.playback.play(tl_track.track).get() @@ -342,7 +344,7 @@ class PlaybackController(object): 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.current_tl_track + tl_track = self.get_current_tl_track() # TODO: switch to: # self.play(....) # wait for state change? @@ -351,11 +353,11 @@ class PlaybackController(object): def resume(self): """If paused, resume playing the current track.""" - if self.state != PlaybackState.PAUSED: + if self.get_state() != PlaybackState.PAUSED: return backend = self._get_backend() if backend and backend.playback.resume().get(): - self.state = PlaybackState.PLAYING + self.set_state(PlaybackState.PLAYING) # TODO: trigger via gst messages self._trigger_track_playback_resumed() # TODO: switch to: @@ -373,9 +375,9 @@ class PlaybackController(object): if not self.core.tracklist.tracks: return False - if self.state == PlaybackState.STOPPED: + if self.get_state() == PlaybackState.STOPPED: self.play() - elif self.state == PlaybackState.PAUSED: + elif self.get_state() == PlaybackState.PAUSED: self.resume() if time_position < 0: @@ -395,11 +397,11 @@ class PlaybackController(object): def stop(self): """Stop playing.""" - if self.state != PlaybackState.STOPPED: + if self.get_state() != PlaybackState.STOPPED: backend = self._get_backend() - time_position_before_stop = self.time_position + time_position_before_stop = self.get_time_position() if not backend or backend.playback.stop().get(): - self.state = PlaybackState.STOPPED + self.set_state(PlaybackState.STOPPED) self._trigger_track_playback_ended(time_position_before_stop) def _trigger_track_playback_paused(self): @@ -408,7 +410,8 @@ class PlaybackController(object): return listener.CoreListener.send( 'track_playback_paused', - tl_track=self.current_tl_track, time_position=self.time_position) + tl_track=self.get_current_tl_track(), + time_position=self.get_time_position()) def _trigger_track_playback_resumed(self): logger.debug('Triggering track playback resumed event') @@ -416,23 +419,24 @@ class PlaybackController(object): return listener.CoreListener.send( 'track_playback_resumed', - tl_track=self.current_tl_track, time_position=self.time_position) + tl_track=self.get_current_tl_track(), + time_position=self.get_time_position()) def _trigger_track_playback_started(self): logger.debug('Triggering track playback started event') - if self.current_tl_track is None: + if self.get_current_tl_track() is None: return listener.CoreListener.send( 'track_playback_started', - tl_track=self.current_tl_track) + tl_track=self.get_current_tl_track()) def _trigger_track_playback_ended(self, time_position_before_stop): logger.debug('Triggering track playback ended event') - if self.current_tl_track is None: + if self.get_current_tl_track() is None: return listener.CoreListener.send( 'track_playback_ended', - tl_track=self.current_tl_track, + tl_track=self.get_current_tl_track(), time_position=time_position_before_stop) def _trigger_playback_state_changed(self, old_state, new_state): diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 5f7ddba1..a9d05570 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -125,7 +125,7 @@ class TracklistController(object): if self.get_random() != value: self._trigger_options_changed() if value: - self._shuffled = self.tl_tracks + self._shuffled = self.get_tl_tracks() random.shuffle(self._shuffled) return setattr(self, '_random', value) @@ -223,9 +223,9 @@ class TracklistController(object): :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ - if self.single and self.repeat: + if self.get_single() and self.get_repeat(): return tl_track - elif self.single: + elif self.get_single(): return None # Current difference between next and EOT handling is that EOT needs to @@ -248,30 +248,30 @@ class TracklistController(object): :rtype: :class:`mopidy.models.TlTrack` or :class:`None` """ - if not self.tl_tracks: + if not self.get_tl_tracks(): return None - if self.random and not self._shuffled: - if self.repeat or not tl_track: + if self.get_random() and not self._shuffled: + if self.get_repeat() or not tl_track: logger.debug('Shuffling tracks') - self._shuffled = self.tl_tracks + self._shuffled = self.get_tl_tracks() random.shuffle(self._shuffled) - if self.random: + if self.get_random(): try: return self._shuffled[0] except IndexError: return None if tl_track is None: - return self.tl_tracks[0] + return self.get_tl_tracks()[0] next_index = self.index(tl_track) + 1 - if self.repeat: - next_index %= len(self.tl_tracks) + if self.get_repeat(): + next_index %= len(self.get_tl_tracks()) try: - return self.tl_tracks[next_index] + return self.get_tl_tracks()[next_index] except IndexError: return None @@ -288,7 +288,7 @@ class TracklistController(object): :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: + if self.get_repeat() or self.get_consume() or self.get_random(): return tl_track position = self.index(tl_track) @@ -296,7 +296,7 @@ class TracklistController(object): if position in (None, 0): return None - return self.tl_tracks[position - 1] + return self.get_tl_tracks()[position - 1] def add(self, tracks=None, at_position=None, uri=None): """ @@ -500,13 +500,13 @@ class TracklistController(object): def mark_playing(self, tl_track): """Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**""" - if self.random and tl_track in self._shuffled: + if self.get_random() and tl_track in self._shuffled: self._shuffled.remove(tl_track) def mark_unplayable(self, tl_track): """Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**""" logger.warning('Track is not playable: %s', tl_track.track.uri) - if self.random and tl_track in self._shuffled: + if self.get_random() and tl_track in self._shuffled: self._shuffled.remove(tl_track) def mark_played(self, tl_track): @@ -517,8 +517,8 @@ class TracklistController(object): return False def _trigger_tracklist_changed(self): - if self.random: - self._shuffled = self.tl_tracks + if self.get_random(): + self._shuffled = self.get_tl_tracks() random.shuffle(self._shuffled) else: self._shuffled = [] From 8f8fa4d414e80d9039cef157eb61311b22e13666 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 9 Feb 2015 01:57:13 +0100 Subject: [PATCH 036/314] core: Emit deprecation warnings The warnings appear as warning level log messages if running Python on the mopidy/ directory like this: python -W all mopidy -v or: python -W all mopidy -o loglevels/py.warnings=warning We don't suppress warnings when Pykka is the caller in general, but just when Pykka is looking at all properties to create its actor proxies. When a deprecated property is used from another Pykka actor, only the stack for the current actor thread is available for inspection, so the warning cannot show where the actual call site in the other actor thread is. Though, if the warnings are made exceptions with: python -W error mopidy then the stack traces will include the frames from all involved actor threads, showing where the original call site is. --- mopidy/core/actor.py | 5 +++-- mopidy/core/playback.py | 16 +++++++++------- mopidy/core/playlists.py | 5 +++-- mopidy/core/tracklist.py | 17 +++++++++-------- mopidy/utils/deprecation.py | 34 ++++++++++++++++++++++++++++++++++ 5 files changed, 58 insertions(+), 19 deletions(-) create mode 100644 mopidy/utils/deprecation.py diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 4eabd0ad..cc1cdd9d 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -16,6 +16,7 @@ from mopidy.core.playlists import PlaylistsController from mopidy.core.tracklist import TracklistController from mopidy.models import TlTrack, Track from mopidy.utils import versioning +from mopidy.utils.deprecation import deprecated_property class Core( @@ -68,7 +69,7 @@ class Core( uri_schemes = itertools.chain(*results) return sorted(uri_schemes) - uri_schemes = property(get_uri_schemes) + uri_schemes = deprecated_property(get_uri_schemes) """ .. deprecated:: 0.20 Use :meth:`get_uri_schemes` instead. @@ -78,7 +79,7 @@ class Core( """Get version of the Mopidy core API""" return versioning.get_version() - version = property(get_version) + version = deprecated_property(get_version) """ .. deprecated:: 0.20 Use :meth:`get_version` instead. diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 62e83abe..fc273965 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -5,6 +5,7 @@ import urlparse from mopidy.audio import PlaybackState from mopidy.core import listener +from mopidy.utils.deprecation import deprecated_property logger = logging.getLogger(__name__) @@ -50,7 +51,8 @@ class PlaybackController(object): """ self._current_tl_track = value - current_tl_track = property(get_current_tl_track, set_current_tl_track) + current_tl_track = deprecated_property( + get_current_tl_track, set_current_tl_track) """ .. deprecated:: 0.20 Use :meth:`get_current_tl_track` instead. @@ -68,7 +70,7 @@ class PlaybackController(object): if tl_track is not None: return tl_track.track - current_track = property(get_current_track) + current_track = deprecated_property(get_current_track) """ .. deprecated:: 0.20 Use :meth:`get_current_track` instead. @@ -83,7 +85,7 @@ class PlaybackController(object): """ return self._current_metadata_track - current_metadata_track = property(get_current_metadata_track) + current_metadata_track = deprecated_property(get_current_metadata_track) """ .. deprecated:: 0.20 Use :meth:`get_current_metadata_track` instead. @@ -116,7 +118,7 @@ class PlaybackController(object): self._trigger_playback_state_changed(old_state, new_state) - state = property(get_state, set_state) + state = deprecated_property(get_state, set_state) """ .. deprecated:: 0.20 Use :meth:`get_state` and :meth:`set_state` instead. @@ -130,7 +132,7 @@ class PlaybackController(object): else: return 0 - time_position = property(get_time_position) + time_position = deprecated_property(get_time_position) """ .. deprecated:: 0.20 Use :meth:`get_time_position` instead. @@ -162,7 +164,7 @@ class PlaybackController(object): # For testing self._volume = volume - volume = property(get_volume, set_volume) + volume = deprecated_property(get_volume, set_volume) """ .. deprecated:: 0.20 Use :meth:`get_volume` and :meth:`set_volume` instead. @@ -191,7 +193,7 @@ class PlaybackController(object): # For testing self._mute = value - mute = property(get_mute, set_mute) + mute = deprecated_property(get_mute, set_mute) """ .. deprecated:: 0.20 Use :meth:`get_mute` and :meth:`set_mute` instead. diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 16b29b85..3d368c29 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -5,7 +5,8 @@ import urlparse import pykka -from . import listener +from mopidy.core import listener +from mopidy.utils.deprecation import deprecated_property class PlaylistsController(object): @@ -29,7 +30,7 @@ class PlaylistsController(object): playlists = [p.copy(tracks=[]) for p in playlists] return playlists - playlists = property(get_playlists) + playlists = deprecated_property(get_playlists) """ .. deprecated:: 0.20 Use :meth:`get_playlists` instead. diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index a9d05570..c54e6784 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -7,6 +7,7 @@ import random from mopidy import compat from mopidy.core import listener from mopidy.models import TlTrack +from mopidy.utils.deprecation import deprecated_property logger = logging.getLogger(__name__) @@ -29,7 +30,7 @@ class TracklistController(object): """Get tracklist as list of :class:`mopidy.models.TlTrack`.""" return self._tl_tracks[:] - tl_tracks = property(get_tl_tracks) + tl_tracks = deprecated_property(get_tl_tracks) """ .. deprecated:: 0.20 Use :meth:`get_tl_tracks` instead. @@ -39,7 +40,7 @@ class TracklistController(object): """Get tracklist as list of :class:`mopidy.models.Track`.""" return [tl_track.track for tl_track in self._tl_tracks] - tracks = property(get_tracks) + tracks = deprecated_property(get_tracks) """ .. deprecated:: 0.20 Use :meth:`get_tracks` instead. @@ -49,7 +50,7 @@ class TracklistController(object): """Get length of the tracklist.""" return len(self._tl_tracks) - length = property(get_length) + length = deprecated_property(get_length) """ .. deprecated:: 0.20 Use :meth:`get_length` instead. @@ -69,7 +70,7 @@ class TracklistController(object): self.core.playback.on_tracklist_change() self._trigger_tracklist_changed() - version = property(get_version) + version = deprecated_property(get_version) """ .. deprecated:: 0.20 Use :meth:`get_version` instead. @@ -97,7 +98,7 @@ class TracklistController(object): self._trigger_options_changed() return setattr(self, '_consume', value) - consume = property(get_consume, set_consume) + consume = deprecated_property(get_consume, set_consume) """ .. deprecated:: 0.20 Use :meth:`get_consume` and :meth:`set_consume` instead. @@ -129,7 +130,7 @@ class TracklistController(object): random.shuffle(self._shuffled) return setattr(self, '_random', value) - random = property(get_random, set_random) + random = deprecated_property(get_random, set_random) """ .. deprecated:: 0.20 Use :meth:`get_random` and :meth:`set_random` instead. @@ -162,7 +163,7 @@ class TracklistController(object): self._trigger_options_changed() return setattr(self, '_repeat', value) - repeat = property(get_repeat, set_repeat) + repeat = deprecated_property(get_repeat, set_repeat) """ .. deprecated:: 0.20 Use :meth:`get_repeat` and :meth:`set_repeat` instead. @@ -192,7 +193,7 @@ class TracklistController(object): self._trigger_options_changed() return setattr(self, '_single', value) - single = property(get_single, set_single) + single = deprecated_property(get_single, set_single) """ .. deprecated:: 0.20 Use :meth:`get_single` and :meth:`set_single` instead. diff --git a/mopidy/utils/deprecation.py b/mopidy/utils/deprecation.py new file mode 100644 index 00000000..1b744702 --- /dev/null +++ b/mopidy/utils/deprecation.py @@ -0,0 +1,34 @@ +from __future__ import unicode_literals + +import inspect +import warnings + + +def _is_pykka_proxy_creation(): + stack = inspect.stack() + try: + calling_frame = stack[3] + except IndexError: + return False + else: + filename = calling_frame[1] + funcname = calling_frame[3] + return 'pykka' in filename and funcname == '_get_attributes' + + +def deprecated_property( + getter=None, setter=None, message='Property is deprecated'): + + def deprecated_getter(*args): + if not _is_pykka_proxy_creation(): + warnings.warn(message, DeprecationWarning, stacklevel=2) + return getter(*args) + + def deprecated_setter(*args): + if not _is_pykka_proxy_creation(): + warnings.warn(message, DeprecationWarning, stacklevel=2) + return setter(*args) + + new_getter = getter and deprecated_getter + new_setter = setter and deprecated_setter + return property(new_getter, new_setter) From 42115c56f783bc284582c7f7af4940816a167024 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 11 Feb 2015 00:48:59 +0100 Subject: [PATCH 037/314] core: Add mixer controller (fixes: #962) Deprecate volume and mute methods on playback controller. --- docs/api/core.rst | 8 ++++ docs/changelog.rst | 4 ++ mopidy/core/__init__.py | 1 + mopidy/core/actor.py | 16 +++---- mopidy/core/mixer.py | 64 ++++++++++++++++++++++++++++ mopidy/core/playback.py | 84 +++++++++++++++++-------------------- mopidy/http/handlers.py | 2 + tests/core/test_mixer.py | 28 +++++++++++++ tests/core/test_playback.py | 18 -------- 9 files changed, 153 insertions(+), 72 deletions(-) create mode 100644 mopidy/core/mixer.py create mode 100644 tests/core/test_mixer.py diff --git a/docs/api/core.rst b/docs/api/core.rst index 21ff79f5..27ab2f57 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -64,6 +64,14 @@ Manages the music library, e.g. searching for tracks to be added to a playlist. :members: +Mixer controller +================ + +Manages volume and muting. + +.. autoclass:: mopidy.core.MixerController + :members: + Core listener ============= diff --git a/docs/changelog.rst b/docs/changelog.rst index 1a9eb33d..4f72ca30 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -17,6 +17,10 @@ v0.20.0 (UNRELEASED) - Added :class:`mopidy.core.HistoryController` which keeps track of what tracks have been played. (Fixes: :issue:`423`, PR: :issue:`803`) +- Added :class:`mopidy.core.MixerController` which keeps track of volume and + mute. The old methods on :class:`mopidy.core.PlaybackController` for volume + and mute management has been deprecated. (Fixes: :issue:`962`) + - Removed ``clear_current_track`` keyword argument to :meth:`mopidy.core.Playback.stop`. It was a leaky internal abstraction, which was never intended to be used externally. diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py index 7fa7e299..720f9c38 100644 --- a/mopidy/core/__init__.py +++ b/mopidy/core/__init__.py @@ -5,6 +5,7 @@ from .actor import Core from .history import HistoryController from .library import LibraryController from .listener import CoreListener +from .mixer import MixerController from .playback import PlaybackController, PlaybackState from .playlists import PlaylistsController from .tracklist import TracklistController diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index cc1cdd9d..2f31c681 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -11,6 +11,7 @@ from mopidy.audio.utils import convert_tags_to_track from mopidy.core.history import HistoryController from mopidy.core.library import LibraryController from mopidy.core.listener import CoreListener +from mopidy.core.mixer import MixerController from mopidy.core.playback import PlaybackController from mopidy.core.playlists import PlaylistsController from mopidy.core.tracklist import TracklistController @@ -31,6 +32,10 @@ class Core( """The playback history controller. An instance of :class:`mopidy.core.HistoryController`.""" + mixer = None + """The mixer controller. An instance of + :class:`mopidy.core.MixerController`.""" + playback = None """The playback controller. An instance of :class:`mopidy.core.PlaybackController`.""" @@ -49,15 +54,10 @@ class Core( self.backends = Backends(backends) self.library = LibraryController(backends=self.backends, core=self) - self.history = HistoryController() - - self.playback = PlaybackController( - mixer=mixer, backends=self.backends, core=self) - - self.playlists = PlaylistsController( - backends=self.backends, core=self) - + self.mixer = MixerController(mixer=mixer) + self.playback = PlaybackController(backends=self.backends, core=self) + self.playlists = PlaylistsController(backends=self.backends, core=self) self.tracklist = TracklistController(core=self) self.audio = audio diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py new file mode 100644 index 00000000..e6856f17 --- /dev/null +++ b/mopidy/core/mixer.py @@ -0,0 +1,64 @@ +from __future__ import absolute_import, unicode_literals + +import logging + + +logger = logging.getLogger(__name__) + + +class MixerController(object): + pykka_traversable = True + + def __init__(self, mixer): + self._mixer = mixer + self._volume = None + self._mute = False + + def get_volume(self): + """Get the volume. + + Integer in range [0..100] or :class:`None` if unknown. + + The volume scale is linear. + """ + if self._mixer: + return self._mixer.get_volume().get() + else: + # For testing + return self._volume + + def set_volume(self, volume): + """Set the volume. + + The volume is defined as an integer in range [0..100]. + + The volume scale is linear. + """ + if self._mixer: + self._mixer.set_volume(volume) + else: + # For testing + self._volume = volume + + def get_mute(self): + """Get mute state. + + :class:`True` if muted, :class:`False` otherwise. + """ + if self._mixer: + return self._mixer.get_mute().get() + else: + # For testing + return self._mute + + def set_mute(self, mute): + """Set mute state. + + :class:`True` to mute, :class:`False` to unmute. + """ + mute = bool(mute) + if self._mixer: + self._mixer.set_mute(mute) + else: + # For testing + self._mute = mute diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index fc273965..d4cdce0d 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals import logging import urlparse +import warnings from mopidy.audio import PlaybackState from mopidy.core import listener @@ -11,20 +12,16 @@ from mopidy.utils.deprecation import deprecated_property logger = logging.getLogger(__name__) -# TODO: split mixing out from playback? class PlaybackController(object): pykka_traversable = True - def __init__(self, mixer, backends, core): - self.mixer = mixer + def __init__(self, backends, core): self.backends = backends self.core = core self._current_tl_track = None self._current_metadata_track = None self._state = PlaybackState.STOPPED - self._volume = None - self._mute = False def _get_backend(self): # TODO: take in track instead @@ -139,64 +136,59 @@ class PlaybackController(object): """ def get_volume(self): - """Get the volume. - - Integer in range [0..100] or :class:`None` if unknown. - - The volume scale is linear. """ - if self.mixer: - return self.mixer.get_volume().get() - else: - # For testing - return self._volume + ... deprecated:: 0.20 + Use :meth:`core.mixer.get_volume() + ` instead. + """ + warnings.warn( + 'playback.get_volume() is deprecated', DeprecationWarning) + return self.core.mixer.get_volume() def set_volume(self, volume): - """Set the volume. - - The volume is defined as an integer in range [0..100]. - - The volume scale is linear. """ - if self.mixer: - self.mixer.set_volume(volume) - else: - # For testing - self._volume = volume + ... deprecated:: 0.20 + Use :meth:`core.mixer.set_volume() + ` instead. + """ + warnings.warn( + 'playback.set_volume() is deprecated', DeprecationWarning) + return self.core.mixer.set_volume(volume) volume = deprecated_property(get_volume, set_volume) """ .. deprecated:: 0.20 - Use :meth:`get_volume` and :meth:`set_volume` instead. + Use :meth:`core.mixer.get_volume() + ` and + :meth:`core.mixer.set_volume() + ` instead. """ def get_mute(self): - """Get mute state. - - :class:`True` if muted, :class:`False` otherwise. """ - if self.mixer: - return self.mixer.get_mute().get() - else: - # For testing - return self._mute - - def set_mute(self, value): - """Set mute state. - - :class:`True` to mute, :class:`False` to unmute. + ... deprecated:: 0.20 + Use :meth:`core.mixer.get_mute() + ` instead. """ - value = bool(value) - if self.mixer: - self.mixer.set_mute(value) - else: - # For testing - self._mute = value + warnings.warn('playback.get_mute() is deprecated', DeprecationWarning) + return self.core.mixer.get_mute() + + def set_mute(self, mute): + """ + ... deprecated:: 0.20 + Use :meth:`core.mixer.set_mute() + ` instead. + """ + warnings.warn('playback.set_mute() is deprecated', DeprecationWarning) + return self.core.mixer.set_mute(mute) mute = deprecated_property(get_mute, set_mute) """ .. deprecated:: 0.20 - Use :meth:`get_mute` and :meth:`set_mute` instead. + Use :meth:`core.mixer.get_mute() + ` and + :meth:`core.mixer.set_mute() + ` instead. """ # Methods diff --git a/mopidy/http/handlers.py b/mopidy/http/handlers.py index 721e419c..52bd8217 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -43,6 +43,7 @@ def make_jsonrpc_wrapper(core_actor): 'core.get_version': core.Core.get_version, 'core.history': core.HistoryController, 'core.library': core.LibraryController, + 'core.mixer': core.MixerController, 'core.playback': core.PlaybackController, 'core.playlists': core.PlaylistsController, 'core.tracklist': core.TracklistController, @@ -54,6 +55,7 @@ def make_jsonrpc_wrapper(core_actor): 'core.get_version': core_actor.get_version, 'core.history': core_actor.history, 'core.library': core_actor.library, + 'core.mixer': core_actor.mixer, 'core.playback': core_actor.playback, 'core.playlists': core_actor.playlists, 'core.tracklist': core_actor.tracklist, diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py new file mode 100644 index 00000000..e3fa6be6 --- /dev/null +++ b/tests/core/test_mixer.py @@ -0,0 +1,28 @@ +from __future__ import absolute_import, unicode_literals + +import unittest + +from mopidy import core + + +class CoreMixerTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.core = core.Core(mixer=None, backends=[]) + + def test_volume(self): + self.assertEqual(self.core.mixer.get_volume(), None) + + self.core.mixer.set_volume(30) + + self.assertEqual(self.core.mixer.get_volume(), 30) + + self.core.mixer.set_volume(70) + + self.assertEqual(self.core.mixer.get_volume(), 70) + + def test_mute(self): + self.assertEqual(self.core.mixer.get_mute(), False) + + self.core.mixer.set_mute(True) + + self.assertEqual(self.core.mixer.get_mute(), True) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index b9d19966..40741e23 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -426,21 +426,3 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback2.get_time_position.called) # TODO Test on_tracklist_change - - def test_volume(self): - self.assertEqual(self.core.playback.volume, None) - - self.core.playback.volume = 30 - - self.assertEqual(self.core.playback.volume, 30) - - self.core.playback.volume = 70 - - self.assertEqual(self.core.playback.volume, 70) - - def test_mute(self): - self.assertEqual(self.core.playback.mute, False) - - self.core.playback.mute = True - - self.assertEqual(self.core.playback.mute, True) From 91bcdddf56584058b513d96b033b73e0c636f3bd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 11 Feb 2015 01:10:16 +0100 Subject: [PATCH 038/314] tests: Use core.mixer for volume/mute --- tests/utils/test_jsonrpc.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index d236469e..6e309c7c 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -49,7 +49,7 @@ class JsonRpcTestBase(unittest.TestCase): 'hello': lambda: 'Hello, world!', 'calc': Calculator(), 'core': self.core, - 'core.playback': self.core.playback, + 'core.mixer': self.core.mixer, 'core.tracklist': self.core.tracklist, 'get_uri_schemes': self.core.get_uri_schemes, }, @@ -188,7 +188,7 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase): def test_call_method_on_actor_member(self): request = { 'jsonrpc': '2.0', - 'method': 'core.playback.get_volume', + 'method': 'core.mixer.get_volume', 'id': 1, } response = self.jrw.handle_data(request) @@ -215,26 +215,26 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase): def test_call_method_with_positional_params(self): request = { 'jsonrpc': '2.0', - 'method': 'core.playback.set_volume', + 'method': 'core.mixer.set_volume', 'params': [37], 'id': 1, } response = self.jrw.handle_data(request) self.assertEqual(response['result'], None) - self.assertEqual(self.core.playback.get_volume().get(), 37) + self.assertEqual(self.core.mixer.get_volume().get(), 37) def test_call_methods_with_named_params(self): request = { 'jsonrpc': '2.0', - 'method': 'core.playback.set_volume', + 'method': 'core.mixer.set_volume', 'params': {'volume': 37}, 'id': 1, } response = self.jrw.handle_data(request) self.assertEqual(response['result'], None) - self.assertEqual(self.core.playback.get_volume().get(), 37) + self.assertEqual(self.core.mixer.get_volume().get(), 37) class JsonRpcSingleNotificationTest(JsonRpcTestBase): @@ -248,17 +248,17 @@ class JsonRpcSingleNotificationTest(JsonRpcTestBase): self.assertIsNone(response) def test_notification_makes_an_observable_change(self): - self.assertEqual(self.core.playback.get_volume().get(), None) + self.assertEqual(self.core.mixer.get_volume().get(), None) request = { 'jsonrpc': '2.0', - 'method': 'core.playback.set_volume', + 'method': 'core.mixer.set_volume', 'params': [37], } response = self.jrw.handle_data(request) self.assertIsNone(response) - self.assertEqual(self.core.playback.get_volume().get(), 37) + self.assertEqual(self.core.mixer.get_volume().get(), 37) def test_notification_unknown_method_returns_nothing(self): request = { @@ -526,7 +526,7 @@ class JsonRpcBatchErrorTest(JsonRpcTestBase): def test_batch_of_both_successfull_and_failing_requests(self): request = [ # Call with positional params - {'jsonrpc': '2.0', 'method': 'core.playback.set_volume', + {'jsonrpc': '2.0', 'method': 'core.mixer.set_volume', 'params': [47], 'id': '1'}, # Notification {'jsonrpc': '2.0', 'method': 'core.tracklist.set_consume', From e1fa76a48e17ef951c64b1bd89119f020efe217a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 12 Feb 2015 10:31:03 +0100 Subject: [PATCH 039/314] mpd: Use core.mixer for volume/mute --- mopidy/mpd/protocol/audio_output.py | 6 +++--- mopidy/mpd/protocol/playback.py | 2 +- mopidy/mpd/protocol/status.py | 4 ++-- tests/mpd/protocol/test_audio_output.py | 12 ++++++------ tests/mpd/protocol/test_playback.py | 14 +++++++------- tests/mpd/test_status.py | 2 +- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/mopidy/mpd/protocol/audio_output.py b/mopidy/mpd/protocol/audio_output.py index 4a5310f5..0152f852 100644 --- a/mopidy/mpd/protocol/audio_output.py +++ b/mopidy/mpd/protocol/audio_output.py @@ -13,7 +13,7 @@ def disableoutput(context, outputid): Turns an output off. """ if outputid == 0: - context.core.playback.set_mute(False) + context.core.mixer.set_mute(False) else: raise exceptions.MpdNoExistError('No such audio output') @@ -28,7 +28,7 @@ def enableoutput(context, outputid): Turns an output on. """ if outputid == 0: - context.core.playback.set_mute(True) + context.core.mixer.set_mute(True) else: raise exceptions.MpdNoExistError('No such audio output') @@ -55,7 +55,7 @@ def outputs(context): Shows information about all outputs. """ - muted = 1 if context.core.playback.get_mute().get() else 0 + muted = 1 if context.core.mixer.get_mute().get() else 0 return [ ('outputid', 0), ('outputname', 'Mute'), diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index 07102492..f7856a03 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -397,7 +397,7 @@ def setvol(context, volume): - issues ``setvol 50`` without quotes around the argument. """ # NOTE: we use INT as clients can pass in +N etc. - context.core.playback.volume = min(max(0, volume), 100) + context.core.mixer.set_volume(min(max(0, volume), 100)) @protocol.commands.add('single', state=protocol.BOOL) diff --git a/mopidy/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py index eabb9317..d33e0afa 100644 --- a/mopidy/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -175,7 +175,7 @@ def status(context): futures = { 'tracklist.length': context.core.tracklist.length, 'tracklist.version': context.core.tracklist.version, - 'playback.volume': context.core.playback.volume, + 'mixer.volume': context.core.mixer.get_volume(), 'tracklist.consume': context.core.tracklist.consume, 'tracklist.random': context.core.tracklist.random, 'tracklist.repeat': context.core.tracklist.repeat, @@ -289,7 +289,7 @@ def _status_time_total(futures): def _status_volume(futures): - volume = futures['playback.volume'].get() + volume = futures['mixer.volume'].get() if volume is not None: return volume else: diff --git a/tests/mpd/protocol/test_audio_output.py b/tests/mpd/protocol/test_audio_output.py index 137ac029..a86f24f0 100644 --- a/tests/mpd/protocol/test_audio_output.py +++ b/tests/mpd/protocol/test_audio_output.py @@ -5,12 +5,12 @@ from tests.mpd import protocol class AudioOutputHandlerTest(protocol.BaseTestCase): def test_enableoutput(self): - self.core.playback.mute = False + self.core.mixer.set_mute(False) self.send_request('enableoutput "0"') self.assertInResponse('OK') - self.assertEqual(self.core.playback.mute.get(), True) + self.assertEqual(self.core.mixer.get_mute().get(), True) def test_enableoutput_unknown_outputid(self): self.send_request('enableoutput "7"') @@ -18,12 +18,12 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): self.assertInResponse('ACK [50@0] {enableoutput} No such audio output') def test_disableoutput(self): - self.core.playback.mute = True + self.core.mixer.set_mute(True) self.send_request('disableoutput "0"') self.assertInResponse('OK') - self.assertEqual(self.core.playback.mute.get(), False) + self.assertEqual(self.core.mixer.get_mute().get(), False) def test_disableoutput_unknown_outputid(self): self.send_request('disableoutput "7"') @@ -32,7 +32,7 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): 'ACK [50@0] {disableoutput} No such audio output') def test_outputs_when_unmuted(self): - self.core.playback.mute = False + self.core.mixer.set_mute(False) self.send_request('outputs') @@ -42,7 +42,7 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_outputs_when_muted(self): - self.core.playback.mute = True + self.core.mixer.set_mute(True) self.send_request('outputs') diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index 1cd62bba..ea9c59ce 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -80,37 +80,37 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_setvol_below_min(self): self.send_request('setvol "-10"') - self.assertEqual(0, self.core.playback.volume.get()) + self.assertEqual(0, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_min(self): self.send_request('setvol "0"') - self.assertEqual(0, self.core.playback.volume.get()) + self.assertEqual(0, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_middle(self): self.send_request('setvol "50"') - self.assertEqual(50, self.core.playback.volume.get()) + self.assertEqual(50, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_max(self): self.send_request('setvol "100"') - self.assertEqual(100, self.core.playback.volume.get()) + self.assertEqual(100, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_above_max(self): self.send_request('setvol "110"') - self.assertEqual(100, self.core.playback.volume.get()) + self.assertEqual(100, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_plus_is_ignored(self): self.send_request('setvol "+10"') - self.assertEqual(10, self.core.playback.volume.get()) + self.assertEqual(10, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_setvol_without_quotes(self): self.send_request('setvol 50') - self.assertEqual(50, self.core.playback.volume.get()) + self.assertEqual(50, self.core.mixer.get_volume().get()) self.assertInResponse('OK') def test_single_off(self): diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index 1015615c..75c10c94 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -52,7 +52,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['volume']), -1) def test_status_method_contains_volume(self): - self.core.playback.volume = 17 + self.core.mixer.set_volume(17) result = dict(status.status(self.context)) self.assertIn('volume', result) self.assertEqual(int(result['volume']), 17) From df67d708db5c6ff39b7b3d1cc02e7d5deb259c18 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 12 Feb 2015 17:52:52 +0100 Subject: [PATCH 040/314] config: Add support for 'all' loglevel Equal to logging.NOTSET or 0 in the logging module. --- docs/changelog.rst | 8 +++++++- mopidy/config/types.py | 5 +++-- tests/config/test_types.py | 13 ++++++++----- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1a9eb33d..5581bfec 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,6 +26,12 @@ v0.20.0 (UNRELEASED) - Make the ``mopidy`` command print a friendly error message if the :mod:`gobject` Python module cannot be imported. (Fixes: :issue:`836`) +**Configuration** + +- Add support for the log level value ``all`` to the loglevels configurations. + This can be used to show absolutely all log records, including those at + custom levels below ``DEBUG``. + **Local backend** - Add cover URL to all scanned files with MusicBrainz album IDs. (Fixes: @@ -50,7 +56,7 @@ v0.20.0 (UNRELEASED) - Enable browsing of artist references, in addition to albums and playlists. (PR: :issue:`884`) - + - Share a single mapping between names and URIs across all MPD sessions. (Fixes: :issue:`934`, PR: :issue:`968`) diff --git a/mopidy/config/types.py b/mopidy/config/types.py index bed03fa2..785ec55a 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -200,8 +200,8 @@ class List(ConfigValue): class LogLevel(ConfigValue): """Log level value. - Expects one of ``critical``, ``error``, ``warning``, ``info``, ``debug`` - with any casing. + Expects one of ``critical``, ``error``, ``warning``, ``info``, ``debug``, + or ``all``, with any casing. """ levels = { b'critical': logging.CRITICAL, @@ -209,6 +209,7 @@ class LogLevel(ConfigValue): b'warning': logging.WARNING, b'info': logging.INFO, b'debug': logging.DEBUG, + b'all': logging.NOTSET, } def deserialize(self, value): diff --git a/tests/config/test_types.py b/tests/config/test_types.py index 939d028b..365fa9e0 100644 --- a/tests/config/test_types.py +++ b/tests/config/test_types.py @@ -281,11 +281,14 @@ class ListTest(unittest.TestCase): class LogLevelTest(unittest.TestCase): - levels = {'critical': logging.CRITICAL, - 'error': logging.ERROR, - 'warning': logging.WARNING, - 'info': logging.INFO, - 'debug': logging.DEBUG} + levels = { + 'critical': logging.CRITICAL, + 'error': logging.ERROR, + 'warning': logging.WARNING, + 'info': logging.INFO, + 'debug': logging.DEBUG, + 'all': logging.NOTSET, + } def test_deserialize_conversion_success(self): value = types.LogLevel() From 79dbc652e0084b358c50bcbbedda3da13a75de56 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 12 Feb 2015 18:03:13 +0100 Subject: [PATCH 041/314] log: Define TRACE log level with name and color --- docs/changelog.rst | 5 +++++ mopidy/utils/log.py | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5581bfec..413e2ca9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -32,6 +32,11 @@ v0.20.0 (UNRELEASED) This can be used to show absolutely all log records, including those at custom levels below ``DEBUG``. +**Logging** + +- Add custom log level ``TRACE`` (numerical level 5), which can be used by + Mopidy and extensions to log at an even more detailed level than ``DEBUG``. + **Local backend** - Add cover URL to all scanned files with MusicBrainz album IDs. (Fixes: diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 396c05b9..79ec723c 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -14,6 +14,9 @@ LOG_LEVELS = { 3: dict(root=logging.DEBUG, mopidy=logging.DEBUG), } +# Custom log level which has even lower priority than DEBUG +TRACE_LOG_LEVEL = 5 + class DelayedHandler(logging.Handler): def __init__(self): @@ -42,6 +45,8 @@ def bootstrap_delayed_logging(): def setup_logging(config, verbosity_level, save_debug_log): + logging.addLevelName(TRACE_LOG_LEVEL, 'TRACE') + logging.captureWarnings(True) if config['logging']['config_file']: @@ -137,6 +142,7 @@ class ColorizingStreamHandler(logging.StreamHandler): # Map logging levels to (background, foreground, bold/intense) level_map = { + TRACE_LOG_LEVEL: (None, 'blue', False), logging.DEBUG: (None, 'blue', False), logging.INFO: (None, 'white', False), logging.WARNING: (None, 'yellow', False), From ece54b68d1010b3e0da3b2dd7666a3358e1c6d11 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 12 Feb 2015 19:29:14 +0100 Subject: [PATCH 042/314] log: Support -vvvv to not filter logs at all --- docs/changelog.rst | 4 ++++ mopidy/utils/log.py | 1 + 2 files changed, 5 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 413e2ca9..89090218 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,6 +26,10 @@ v0.20.0 (UNRELEASED) - Make the ``mopidy`` command print a friendly error message if the :mod:`gobject` Python module cannot be imported. (Fixes: :issue:`836`) +- Add support for repeating the :cmdoption:`-v ` argument four times + to set the log level for all loggers to the lowest possible value, including + log records at levels lover than ``DEBUG`` too. + **Configuration** - Add support for the log level value ``all`` to the loglevels configurations. diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 79ec723c..3c7ee599 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -12,6 +12,7 @@ LOG_LEVELS = { 1: dict(root=logging.WARNING, mopidy=logging.DEBUG), 2: dict(root=logging.INFO, mopidy=logging.DEBUG), 3: dict(root=logging.DEBUG, mopidy=logging.DEBUG), + 4: dict(root=logging.NOTSET, mopidy=logging.NOTSET), } # Custom log level which has even lower priority than DEBUG From 12cc2ed35c1935129f828f9a0a5ebc552e8d1b9d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 31 Dec 2014 18:18:01 +0100 Subject: [PATCH 043/314] local: Call library add with tags and duration if asked to --- mopidy/local/__init__.py | 15 +++++++++++++-- mopidy/local/commands.py | 6 ++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 62228e91..2ec8b79e 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -70,6 +70,10 @@ class Library(object): #: Name of the local library implementation, must be overriden. name = None + #: Feature marker to indicate that you want add calls to be called with + #: optional arguments tags and duration. + add_supports_tags_and_duration = False + def __init__(self, config): self._config = config @@ -135,12 +139,19 @@ class Library(object): """ raise NotImplementedError - def add(self, track): + def add(self, track, tags=None, duration=None): """ - Add the given track to library. + Add the given track to library. Optional args will only be added if + `add_supports_tags_and_duration` has been set. :param track: Track to add to the library :type track: :class:`~mopidy.models.Track` + :param tags: All the tags the scanner found for the media. See + :module:`mopidy.audio.utils` for details about the tags. + :type tags: dictionary of tag keys with a list of values. + :param duration: Duration of media in milliseconds or :class:`None` if + unknown + :type duration: :class:`int` or :class:`None` """ raise NotImplementedError diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index d49ab8f8..a9920ec8 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -139,8 +139,10 @@ class ScanCommand(commands.Command): track = utils.convert_tags_to_track(tags).copy( uri=uri, length=duration, last_modified=mtime) track = translator.add_musicbrainz_coverart_to_track(track) - # TODO: add tags to call if library supports it. - library.add(track) + if library.add_supports_tags_and_duration: + library.add(track, tags=tags, duration=duration) + else: + library.add(track) logger.debug('Added %s', track.uri) except exceptions.ScannerError as error: logger.warning('Failed %s: %s', uri, error) From 663cdf929d89055c8e7d56e38d67c446dc418b3a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Feb 2015 21:13:50 +0100 Subject: [PATCH 044/314] docs: Add tags/duration local addition to changelog --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 89090218..640fca97 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -51,6 +51,9 @@ v0.20.0 (UNRELEASED) just like the other ``lookup()`` methods in Mopidy. For now, returning a single track will continue to work. (PR: :issue:`840`) +- Add support for giving local libraries direct access to tags and duration. + (Fixes: :issue:`967`) + **File scanner** - Improve error logging for scan code (Fixes: :issue:`856`, PR: :issue:`874`) From 05b66ba4a360542d5561996fd4427c67f45d5ebc Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Feb 2015 22:02:59 +0100 Subject: [PATCH 045/314] models: Add basic image model --- mopidy/models.py | 17 +++++++++++++++++ tests/test_models.py | 35 +++++++++++++++++++++++++++-------- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 758b6c6d..daadf7b8 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -212,6 +212,23 @@ class Ref(ImmutableObject): return cls(**kwargs) +class Image(ImmutableObject): + """ + :param string uri: URI of the image + :param int width: Optional width of image or :class:`None` + :param int height: Optional height of image or :class:`None` + """ + + #: The image URI. Read-only. + uri = None + + #: Optional width of the image or :class:`None`. Read-only. + width = None + + #: Optional height of the image or :class:`None`. Read-only. + height = None + + class Artist(ImmutableObject): """ :param uri: artist URI diff --git a/tests/test_models.py b/tests/test_models.py index ed1586da..af8e0f82 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -4,8 +4,8 @@ import json import unittest from mopidy.models import ( - Album, Artist, ModelJSONEncoder, Playlist, Ref, SearchResult, TlTrack, - Track, model_json_decoder) + Album, Artist, Image, ModelJSONEncoder, Playlist, Ref, SearchResult, + TlTrack, Track, model_json_decoder) class GenericCopyTest(unittest.TestCase): @@ -74,7 +74,7 @@ class RefTest(unittest.TestCase): def test_invalid_kwarg(self): with self.assertRaises(TypeError): - SearchResult(foo='baz') + Ref(foo='baz') def test_repr_without_results(self): self.assertEquals( @@ -123,11 +123,30 @@ class RefTest(unittest.TestCase): self.assertEqual(ref.name, 'bar') self.assertEqual(ref.type, Ref.PLAYLIST) - def test_track_constructor(self): - ref = Ref.track(uri='foo', name='bar') - self.assertEqual(ref.uri, 'foo') - self.assertEqual(ref.name, 'bar') - self.assertEqual(ref.type, Ref.TRACK) + +class ImageTest(unittest.TestCase): + def test_uri(self): + uri = 'an_uri' + image = Image(uri=uri) + self.assertEqual(image.uri, uri) + with self.assertRaises(AttributeError): + image.uri = None + + def test_width(self): + image = Image(width=100) + self.assertEqual(image.width, 100) + with self.assertRaises(AttributeError): + image.width = None + + def test_height(self): + image = Image(height=100) + self.assertEqual(image.height, 100) + with self.assertRaises(AttributeError): + image.height = None + + def test_invalid_kwarg(self): + with self.assertRaises(TypeError): + Image(foo='baz') class ArtistTest(unittest.TestCase): From 34ada2784af5e2957df54f0d9874eef7301cb778 Mon Sep 17 00:00:00 2001 From: Lasse Bigum Date: Sun, 8 Feb 2015 23:56:38 +0100 Subject: [PATCH 046/314] flake8: fix PEP8 warnings about lambda Fix the 'lambda to def' warnings --- mopidy/audio/scan.py | 4 +- mopidy/core/actor.py | 4 +- mopidy/local/search.py | 159 ++++++++++++++++++++++--------------- mopidy/models.py | 4 +- setup.cfg | 3 +- tests/mpd/test_commands.py | 63 +++++++++++---- 6 files changed, 154 insertions(+), 83 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 2cf8f493..931a2e3a 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -25,7 +25,9 @@ class Scanner(object): sink = gst.element_factory_make('fakesink') audio_caps = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') - pad_added = lambda src, pad: pad.link(sink.get_pad('sink')) + + def pad_added(src, pad): + return pad.link(sink.get_pad('sink')) self._uribin = gst.element_factory_make('uridecodebin') self._uribin.set_property('caps', audio_caps) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 75c06f69..5fc7fea1 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -113,7 +113,9 @@ class Backends(list): self.with_playlists = collections.OrderedDict() backends_by_scheme = {} - name = lambda b: b.actor_ref.actor_class.__name__ + + def name(b): + return b.actor_ref.actor_class.__name__ for b in backends: has_library = b.has_library().get() diff --git a/mopidy/local/search.py b/mopidy/local/search.py index bc46c33e..18dad82c 100644 --- a/mopidy/local/search.py +++ b/mopidy/local/search.py @@ -21,37 +21,52 @@ def find_exact(tracks, query=None, uris=None): else: q = value.strip() - uri_filter = lambda t: q == t.uri - track_name_filter = lambda t: q == t.name - album_filter = lambda t: q == getattr( - getattr(t, 'album', None), 'name', None) - 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', [])]) - composer_filter = lambda t: any([ - q == a.name - for a in getattr(t, 'composers', [])]) - performer_filter = lambda t: any([ - q == a.name - for a in getattr(t, 'performers', [])]) - track_no_filter = lambda t: q == t.track_no - genre_filter = lambda t: t.genre and q == t.genre - date_filter = lambda t: q == t.date - comment_filter = lambda t: q == t.comment - any_filter = lambda t: ( - uri_filter(t) or - track_name_filter(t) or - album_filter(t) or - artist_filter(t) or - albumartist_filter(t) or - composer_filter(t) or - performer_filter(t) or - track_no_filter(t) or - genre_filter(t) or - date_filter(t) or - comment_filter(t)) + def uri_filter(t): + return q == t.uri + + def track_name_filter(t): + return q == t.name + + def album_filter(t): + return q == getattr(getattr(t, 'album', None), 'name', None) + + def artist_filter(t): + return filter(lambda a: q == a.name, t.artists) + + def albumartist_filter(t): + return any([q == a.name for a in getattr(t.album, + 'artists', [])]) + + def composer_filter(t): + return any([q == a.name for a in getattr(t, 'composers', [])]) + + def performer_filter(t): + return any([q == a.name for a in getattr(t, 'performers', [])]) + + def track_no_filter(t): + return q == t.track_no + + def genre_filter(t): + return (t.genre and q == t.genre) + + def date_filter(t): + return q == t.date + + def comment_filter(t): + return q == t.comment + + def any_filter(t): + return (uri_filter(t) or + track_name_filter(t) or + album_filter(t) or + artist_filter(t) or + albumartist_filter(t) or + composer_filter(t) or + performer_filter(t) or + track_no_filter(t) or + genre_filter(t) or + date_filter(t) or + comment_filter(t)) if field == 'uri': tracks = filter(uri_filter, tracks) @@ -102,38 +117,56 @@ def search(tracks, query=None, uris=None): else: q = value.strip().lower() - uri_filter = lambda t: bool(t.uri and q in t.uri.lower()) - track_name_filter = lambda t: bool(t.name and q in t.name.lower()) - album_filter = lambda t: bool( - t.album and t.album.name and q in t.album.name.lower()) - artist_filter = lambda t: bool(filter( - lambda a: bool(a.name and q in a.name.lower()), t.artists)) - albumartist_filter = lambda t: any([ - a.name and q in a.name.lower() - for a in getattr(t.album, 'artists', [])]) - composer_filter = lambda t: any([ - a.name and q in a.name.lower() - for a in getattr(t, 'composers', [])]) - performer_filter = lambda t: any([ - a.name and q in a.name.lower() - for a in getattr(t, 'performers', [])]) - track_no_filter = lambda t: q == t.track_no - genre_filter = lambda t: bool(t.genre and q in t.genre.lower()) - date_filter = lambda t: bool(t.date and t.date.startswith(q)) - comment_filter = lambda t: bool( - t.comment and q in t.comment.lower()) - any_filter = lambda t: ( - uri_filter(t) or - track_name_filter(t) or - album_filter(t) or - artist_filter(t) or - albumartist_filter(t) or - composer_filter(t) or - performer_filter(t) or - track_no_filter(t) or - genre_filter(t) or - date_filter(t) or - comment_filter(t)) + def uri_filter(t): + return bool(t.uri and q in t.uri.lower()) + + def track_name_filter(t): + return bool(t.name and q in t.name.lower()) + + def album_filter(t): + return bool(t.album and t.album.name + and q in t.album.name.lower()) + + def artist_filter(t): + return bool(filter(lambda a: + bool(a.name and q in a.name.lower()), t.artists)) + + def albumartist_filter(t): + return any([a.name and q in a.name.lower() + for a in getattr(t.album, 'artists', [])]) + + def composer_filter(t): + return any([a.name and q in a.name.lower() + for a in getattr(t, 'composers', [])]) + + def performer_filter(t): + return any([a.name and q in a.name.lower() + for a in getattr(t, 'performers', [])]) + + def track_no_filter(t): + return q == t.track_no + + def genre_filter(t): + return bool(t.genre and q in t.genre.lower()) + + def date_filter(t): + return bool(t.date and t.date.startswith(q)) + + def comment_filter(t): + return bool(t.comment and q in t.comment.lower()) + + def any_filter(t): + return (uri_filter(t) or + track_name_filter(t) or + album_filter(t) or + artist_filter(t) or + albumartist_filter(t) or + composer_filter(t) or + performer_filter(t) or + track_no_filter(t) or + genre_filter(t) or + date_filter(t) or + comment_filter(t)) if field == 'uri': tracks = filter(uri_filter, tracks) diff --git a/mopidy/models.py b/mopidy/models.py index 758b6c6d..1818edfd 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -367,7 +367,9 @@ class Track(ImmutableObject): last_modified = None def __init__(self, *args, **kwargs): - get = lambda key: frozenset(kwargs.pop(key, None) or []) + def get(key): + return frozenset(kwargs.pop(key, None) or []) + self.__dict__['artists'] = get('artists') self.__dict__['composers'] = get('composers') self.__dict__['performers'] = get('performers') diff --git a/setup.cfg b/setup.cfg index 0d6c1486..834ca945 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,8 +3,7 @@ application-import-names = mopidy,tests exclude = .git,.tox,build,js # Ignored flake8 warnings: # - E402 module level import not at top of file -# - E731 do not assign a lambda expression, use a def -ignore = E402,E731 +ignore = E402 [wheel] universal = 1 diff --git a/tests/mpd/test_commands.py b/tests/mpd/test_commands.py index e0903e9f..a281d10e 100644 --- a/tests/mpd/test_commands.py +++ b/tests/mpd/test_commands.py @@ -64,7 +64,8 @@ class TestCommands(unittest.TestCase): pass def test_register_second_command_to_same_name_fails(self): - func = lambda context: True + def func(context): + pass self.commands.add('foo')(func) with self.assertRaises(Exception): @@ -88,7 +89,10 @@ class TestCommands(unittest.TestCase): def test_function_has_required_and_optional_args_succeeds(self): sentinel = object() - func = lambda context, required, optional=None: sentinel + + def func(context, required, optional=None): + return sentinel + self.commands.add('bar')(func) self.assertEqual(sentinel, self.commands.call(['bar', 'arg'])) self.assertEqual(sentinel, self.commands.call(['bar', 'arg', 'arg'])) @@ -111,12 +115,16 @@ class TestCommands(unittest.TestCase): def test_function_has_required_and_varargs_fails(self): with self.assertRaises(TypeError): - func = lambda context, required, *args: True + def func(context, required, *args): + pass + self.commands.add('test')(func) def test_function_has_optional_and_varargs_fails(self): with self.assertRaises(TypeError): - func = lambda context, optional=None, *args: True + def func(context, optional=None, *args): + pass + self.commands.add('test')(func) def test_function_hash_keywordargs_fails(self): @@ -158,7 +166,9 @@ class TestCommands(unittest.TestCase): self.assertEqual('test', self.commands.call(['foo', 'test'])) def test_call_passes_required_and_optional_argument(self): - func = lambda context, required, optional=None: (required, optional) + def func(context, required, optional=None): + return (required, optional) + self.commands.add('foo')(func) self.assertEqual(('arg', None), self.commands.call(['foo', 'arg'])) self.assertEqual( @@ -182,20 +192,29 @@ class TestCommands(unittest.TestCase): def test_validator_gets_applied_to_required_arg(self): sentinel = object() - func = lambda context, required: required + + def func(context, required): + return required + self.commands.add('test', required=lambda v: sentinel)(func) self.assertEqual(sentinel, self.commands.call(['test', 'foo'])) def test_validator_gets_applied_to_optional_arg(self): sentinel = object() - func = lambda context, optional=None: optional + + def func(context, optional=None): + return optional + self.commands.add('foo', optional=lambda v: sentinel)(func) self.assertEqual(sentinel, self.commands.call(['foo', '123'])) def test_validator_skips_optional_default(self): sentinel = object() - func = lambda context, optional=sentinel: optional + + def func(context, optional=sentinel): + return optional + self.commands.add('foo', optional=lambda v: None)(func) self.assertEqual(sentinel, self.commands.call(['foo'])) @@ -203,28 +222,38 @@ class TestCommands(unittest.TestCase): def test_validator_applied_to_non_existent_arg_fails(self): self.commands.add('foo')(lambda context, arg: arg) with self.assertRaises(TypeError): - func = lambda context, wrong_arg: wrong_arg + def func(context, wrong_arg): + return wrong_arg + self.commands.add('bar', arg=lambda v: v)(func) def test_validator_called_context_fails(self): return # TODO: how to handle this with self.assertRaises(TypeError): - func = lambda context: True + def func(context): + pass + self.commands.add('bar', context=lambda v: v)(func) def test_validator_value_error_is_converted(self): def validdate(value): raise ValueError - func = lambda context, arg: True + def func(context, arg): + pass + self.commands.add('bar', arg=validdate)(func) with self.assertRaises(exceptions.MpdArgError): self.commands.call(['bar', 'test']) def test_auth_required_gets_stored(self): - func1 = lambda context: context - func2 = lambda context: context + def func1(context): + pass + + def func2(context): + pass + self.commands.add('foo')(func1) self.commands.add('bar', auth_required=False)(func2) @@ -232,8 +261,12 @@ class TestCommands(unittest.TestCase): self.assertFalse(self.commands.handlers['bar'].auth_required) def test_list_command_gets_stored(self): - func1 = lambda context: context - func2 = lambda context: context + def func1(context): + pass + + def func2(context): + pass + self.commands.add('foo')(func1) self.commands.add('bar', list_command=False)(func2) From c0b0e3657a24dd9306cc14c61cbe294025b6af91 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Feb 2015 22:38:42 +0100 Subject: [PATCH 047/314] core: Add core.library.get_images --- mopidy/backend/__init__.py | 8 +++++++ mopidy/core/library.py | 16 +++++++++++++ tests/core/test_library.py | 46 +++++++++++++++++++++++++++++++++++++- 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/mopidy/backend/__init__.py b/mopidy/backend/__init__.py index 45268f9f..3dc3a28c 100644 --- a/mopidy/backend/__init__.py +++ b/mopidy/backend/__init__.py @@ -92,6 +92,14 @@ class LibraryProvider(object): """ return [] + def get_images(self, uris): + """ + See :meth:`mopidy.core.LibraryController.get_images`. + + *MAY be implemented by subclass.* + """ + return {} + # TODO: replace with search(query, exact=True, ...) def find_exact(self, query=None, uris=None): """ diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 2ada23d4..bdff2ccd 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -72,6 +72,22 @@ class LibraryController(object): return [] return backend.library.browse(uri).get() + def get_images(self, uris): + """Lookup the images for the given URIs + + :param list uris: list of uris to find images for + :rtype: dict mapping uris to :class:`mopidy.models.Image` instances + """ + futures = [ + backend.library.get_images(backend_uris) + for (backend, backend_uris) + in self._get_backends_to_uris(uris).items() if backend_uris] + + images = {} + for result in pykka.get_all(futures): + images.update(result) + return images + def find_exact(self, query=None, uris=None, **kwargs): """ Search the library for tracks where ``field`` is ``values``. diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 9bd3b244..cece44e1 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -5,7 +5,7 @@ import unittest import mock from mopidy import backend, core -from mopidy.models import Ref, SearchResult, Track +from mopidy.models import Image, Ref, SearchResult, Track class CoreLibraryTest(unittest.TestCase): @@ -14,6 +14,8 @@ class CoreLibraryTest(unittest.TestCase): self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] self.library1 = mock.Mock(spec=backend.LibraryProvider) + self.library1.get_images().get.return_value = {} + self.library1.get_images.reset_mock() self.library1.root_directory.get.return_value = dummy1_root self.backend1.library = self.library1 @@ -21,6 +23,8 @@ class CoreLibraryTest(unittest.TestCase): self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2', 'du2'] self.library2 = mock.Mock(spec=backend.LibraryProvider) + self.library2.get_images().get.return_value = {} + self.library2.get_images.reset_mock() self.library2.root_directory.get.return_value = dummy2_root self.backend2.library = self.library2 @@ -33,6 +37,46 @@ class CoreLibraryTest(unittest.TestCase): self.core = core.Core(mixer=None, backends=[ self.backend1, self.backend2, self.backend3]) + def test_get_images_returns_empty_dict_for_no_uris(self): + self.assertEqual({}, self.core.library.get_images([])) + + def test_get_images_returns_empty_dict_for_unknown_uri(self): + self.assertEqual({}, self.core.library.get_images(['dummy4:bar'])) + + def test_get_images_returns_empty_dict_for_library_less_uri(self): + self.assertEqual({}, self.core.library.get_images(['dummy3:foo'])) + + def test_get_images_maps_uri_to_backend(self): + self.core.library.get_images(['dummy1:track']) + self.library1.get_images.assert_called_once_with(['dummy1:track']) + self.library2.get_images.assert_not_called() + + def test_get_images_maps_uri_to_backends(self): + self.core.library.get_images(['dummy1:track', 'dummy2:track']) + self.library1.get_images.assert_called_once_with(['dummy1:track']) + self.library2.get_images.assert_called_once_with(['dummy2:track']) + + def test_get_images_returns_images(self): + self.library1.get_images().get.return_value = { + 'dummy1:track': Image(uri='uri')} + self.library1.get_images.reset_mock() + + result = self.core.library.get_images(['dummy1:track']) + self.assertEqual({'dummy1:track': Image(uri='uri')}, result) + + def test_get_images_merges_results(self): + self.library1.get_images().get.return_value = { + 'dummy1:track': Image(uri='uri1')} + self.library1.get_images.reset_mock() + self.library2.get_images().get.return_value = { + 'dummy2:track': Image(uri='uri2')} + self.library2.get_images.reset_mock() + + result = self.core.library.get_images(['dummy1:track', 'dummy2:track']) + expected = {'dummy1:track': Image(uri='uri1'), + 'dummy2:track': Image(uri='uri2')} + self.assertEqual(expected, result) + def test_browse_root_returns_dir_ref_for_each_lib_with_root_dir_name(self): result = self.core.library.browse(None) From a3133afe6bf8fc21d3930f4124159cf68058da80 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Feb 2015 22:41:21 +0100 Subject: [PATCH 048/314] docs: Add changelog entry for core.library.get_images --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 89090218..b979944e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -21,6 +21,9 @@ v0.20.0 (UNRELEASED) :meth:`mopidy.core.Playback.stop`. It was a leaky internal abstraction, which was never intended to be used externally. +- Add :meth:`mopidy.core.LibraryController.get_images` for looking up images + for any URI backends know about. (Fixes :issue:`973`) + **Commands** - Make the ``mopidy`` command print a friendly error message if the From 38158b4430e03c62e512224a3ba8da9bd199b3cb Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Feb 2015 22:48:05 +0100 Subject: [PATCH 049/314] local: Minor docstring review corrections --- mopidy/local/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 2ec8b79e..31ec6426 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -70,8 +70,8 @@ class Library(object): #: Name of the local library implementation, must be overriden. name = None - #: Feature marker to indicate that you want add calls to be called with - #: optional arguments tags and duration. + #: Feature marker to indicate that you want :meth:`add()` calls to be + #: called with optional arguments tags and duration. add_supports_tags_and_duration = False def __init__(self, config): @@ -142,7 +142,7 @@ class Library(object): def add(self, track, tags=None, duration=None): """ Add the given track to library. Optional args will only be added if - `add_supports_tags_and_duration` has been set. + :attr:`add_supports_tags_and_duration` has been set. :param track: Track to add to the library :type track: :class:`~mopidy.models.Track` From b1b6fb78082361725da81945d8eb3de65a1d18d1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Feb 2015 23:08:27 +0100 Subject: [PATCH 050/314] tests: Re-add incorrectly removed test case --- tests/test_models.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_models.py b/tests/test_models.py index af8e0f82..e7aec877 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -123,6 +123,12 @@ class RefTest(unittest.TestCase): self.assertEqual(ref.name, 'bar') self.assertEqual(ref.type, Ref.PLAYLIST) + def test_track_constructor(self): + ref = Ref.track(uri='foo', name='bar') + self.assertEqual(ref.uri, 'foo') + self.assertEqual(ref.name, 'bar') + self.assertEqual(ref.type, Ref.TRACK) + class ImageTest(unittest.TestCase): def test_uri(self): From 533948f8f8d803a0c741d2ede7405b11adeca755 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Feb 2015 23:10:20 +0100 Subject: [PATCH 051/314] core: Make sure we return list of images in get_images tests --- tests/core/test_library.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/core/test_library.py b/tests/core/test_library.py index cece44e1..fb1f9228 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -58,23 +58,23 @@ class CoreLibraryTest(unittest.TestCase): def test_get_images_returns_images(self): self.library1.get_images().get.return_value = { - 'dummy1:track': Image(uri='uri')} + 'dummy1:track': [Image(uri='uri')]} self.library1.get_images.reset_mock() result = self.core.library.get_images(['dummy1:track']) - self.assertEqual({'dummy1:track': Image(uri='uri')}, result) + self.assertEqual({'dummy1:track': [Image(uri='uri')]}, result) def test_get_images_merges_results(self): self.library1.get_images().get.return_value = { - 'dummy1:track': Image(uri='uri1')} + 'dummy1:track': [Image(uri='uri1')]} self.library1.get_images.reset_mock() self.library2.get_images().get.return_value = { - 'dummy2:track': Image(uri='uri2')} + 'dummy2:track': [Image(uri='uri2')]} self.library2.get_images.reset_mock() result = self.core.library.get_images(['dummy1:track', 'dummy2:track']) - expected = {'dummy1:track': Image(uri='uri1'), - 'dummy2:track': Image(uri='uri2')} + expected = {'dummy1:track': [Image(uri='uri1')], + 'dummy2:track': [Image(uri='uri2')]} self.assertEqual(expected, result) def test_browse_root_returns_dir_ref_for_each_lib_with_root_dir_name(self): From 5a6eb78137f2c18f673c46a87fa08eaa5d255129 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 12 Feb 2015 23:11:29 +0100 Subject: [PATCH 052/314] docs: Update changelog for PR#958 --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 89090218..dcd17af7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -110,6 +110,9 @@ v0.20.0 (UNRELEASED) make sense for a server such as Mopidy. Currently the only way to find out if it is in use and will be missed is to go ahead and remove it. +- Add workaround for volume not persisting across tracks on OS X. + (Issue: :issue:`886`, PR: :issue:`958`) + **Stream backend** - Add basic tests for the stream library provider. From 77128e4b689f538b2fd290db403af807c3cbbc02 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 7 Feb 2015 17:09:33 +0100 Subject: [PATCH 053/314] flake8: Fix new warnings after flake8 upgrade (cherry picked from commit a693993905b66a63601cf5765da1788829b0f798) Conflicts: mopidy/audio/actor.py mopidy/audio/playlists.py --- mopidy/audio/actor.py | 4 ++-- mopidy/audio/playlists.py | 4 ++-- mopidy/local/__init__.py | 2 +- mopidy/local/json.py | 2 +- mopidy/local/translator.py | 2 +- setup.cfg | 4 ++++ tests/__init__.py | 2 +- tests/utils/test_jsonrpc.py | 2 +- 8 files changed, 13 insertions(+), 9 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 0d90394d..d2701784 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -109,8 +109,8 @@ class Audio(pykka.ThreadingActor): playbin = gst.element_factory_make('playbin2') playbin.set_property('flags', PLAYBIN_FLAGS) - playbin.set_property('buffer-size', 2*1024*1024) - playbin.set_property('buffer-duration', 2*gst.SECOND) + playbin.set_property('buffer-size', 2 * 1024 * 1024) + playbin.set_property('buffer-duration', 2 * gst.SECOND) self._connect(playbin, 'about-to-finish', self._on_about_to_finish) self._connect(playbin, 'notify::source', self._on_new_source) diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 35e0800d..1f161773 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -76,8 +76,8 @@ def parse_pls(data): 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)) + for i in range(cp.getint(section, 'numberofentries')): + yield cp.get(section, 'file%d' % (i + 1)) def parse_xspf(data): diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 104c43af..9b485f19 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -27,7 +27,7 @@ class Extension(ext.Extension): schema['playlists_dir'] = config.Path() schema['tag_cache_file'] = config.Deprecated() schema['scan_timeout'] = config.Integer( - minimum=1000, maximum=1000*60*60) + minimum=1000, maximum=1000 * 60 * 60) schema['scan_flush_threshold'] = config.Integer(minimum=0) schema['excluded_file_extensions'] = config.List(optional=True) return schema diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 5ae04592..b3a2ff39 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -75,7 +75,7 @@ class _BrowseCache(object): parent_uri = None child = None for i in reversed(range(len(parts))): - directory = '/'.join(parts[:i+1]) + directory = '/'.join(parts[:i + 1]) uri = translator.path_to_local_directory_uri(directory) # First dir we process is our parent diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 33b67775..3c1d38ae 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -49,7 +49,7 @@ def m3u_extinf_to_track(line): return Track() (runtime, title) = m.groups() if int(runtime) > 0: - return Track(name=title, length=1000*int(runtime)) + return Track(name=title, length=1000 * int(runtime)) else: return Track(name=title) diff --git a/setup.cfg b/setup.cfg index 80ab9645..0d6c1486 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,10 @@ [flake8] application-import-names = mopidy,tests exclude = .git,.tox,build,js +# Ignored flake8 warnings: +# - E402 module level import not at top of file +# - E731 do not assign a lambda expression, use a def +ignore = E402,E731 [wheel] universal = 1 diff --git a/tests/__init__.py b/tests/__init__.py index a384669e..327ca5a8 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -20,7 +20,7 @@ class IsA(object): try: return isinstance(rhs, self.klass) except TypeError: - return type(rhs) == type(self.klass) + return type(rhs) == type(self.klass) # flake8: noqa def __ne__(self, rhs): return not self.__eq__(rhs) diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index e6f94fb3..c8d37d04 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -614,7 +614,7 @@ class JsonRpcInspectorTest(JsonRpcTestBase): 'core.library': core.LibraryController, 'core.playback': core.PlaybackController, 'core.playlists': core.PlaylistsController, - 'core.tracklist': core.TracklistController, + 'core.tracklist': core.TracklistController, }) methods = inspector.describe() From 8e4b01912741b7ac548b823b2f897854e96fb612 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 12 Feb 2015 23:33:01 +0100 Subject: [PATCH 054/314] audio: Update #886 workaround to work with new audio APIs --- mopidy/audio/actor.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 4b47d539..133c8424 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -546,12 +546,16 @@ class Audio(pykka.ThreadingActor): # XXX: Hack to workaround issue on Mac OS X where volume level # does not persist between track changes. mopidy/mopidy#886 - current_volume = self.get_volume() + if self.mixer is not None: + current_volume = self.mixer.get_volume() + else: + current_volume = None self._tags = {} # TODO: add test for this somehow self._playbin.set_property('uri', uri) - self.set_volume(current_volume) + if self.mixer is not None and current_volume is not None: + self.mixer.set_volume(current_volume) def set_appsrc( self, caps, need_data=None, enough_data=None, seek_data=None): From b7c71b84d5968333495c3997304fad53ceb96179 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Feb 2015 23:51:20 +0100 Subject: [PATCH 055/314] core: Update get_images documenatation --- mopidy/core/library.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index bdff2ccd..b3832d5f 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -75,8 +75,15 @@ class LibraryController(object): def get_images(self, uris): """Lookup the images for the given URIs - :param list uris: list of uris to find images for - :rtype: dict mapping uris to :class:`mopidy.models.Image` instances + Backends can use this to return image URIs for any URI they know about + be it tracks, albums, playlists... The lookup result is a dictionary + mapping the provided URIs to lists of images. + + Unknown URIs or URIs the corresponding backend couldn't find anything + for will simply return an empty list for that URI. + + :param list uris: list of URIsto find images for + :rtype: {uri: [:class:`mopidy.models.Image`]} """ futures = [ backend.library.get_images(backend_uris) From 07f2d46ccfdfbf1ca51f91c6dc9a1f13495c453d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 12 Feb 2015 23:49:47 +0100 Subject: [PATCH 056/314] softwaremixer: Update comment after audio refactoring --- mopidy/softwaremixer/mixer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/softwaremixer/mixer.py b/mopidy/softwaremixer/mixer.py index dadbbec8..85441c57 100644 --- a/mopidy/softwaremixer/mixer.py +++ b/mopidy/softwaremixer/mixer.py @@ -28,9 +28,9 @@ class SoftwareMixer(pykka.ThreadingActor, mixer.Mixer): self._audio_mixer = mixer_ref # The Mopidy startup procedure will set the initial volume of a - # mixer, but this happens before the audio actor is injected into the - # software mixer and has no effect. Thus, we need to set the initial - # volume again. + # mixer, but this happens before the audio actor's mixer is injected + # into the software mixer actor and has no effect. Thus, we need to set + # the initial volume again. if self._initial_volume is not None: self.set_volume(self._initial_volume) if self._initial_mute is not None: From 3c4683c3199a283b0bc2e3cf1b753bb0931eb1a1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 12 Feb 2015 23:50:07 +0100 Subject: [PATCH 057/314] softwaremixer: Remove comment What mixer is used is already logged by the code that starts the mixer. Some mixers have additional information about what controls, etc. are used, but in this case the only thing we add is that GStreamer is used. --- mopidy/softwaremixer/mixer.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/mopidy/softwaremixer/mixer.py b/mopidy/softwaremixer/mixer.py index 85441c57..d94a0be2 100644 --- a/mopidy/softwaremixer/mixer.py +++ b/mopidy/softwaremixer/mixer.py @@ -21,9 +21,6 @@ class SoftwareMixer(pykka.ThreadingActor, mixer.Mixer): self._initial_volume = None self._initial_mute = None - # TODO: shouldn't this be logged by thing that choose us? - logger.info('Mixing using GStreamer software mixing') - def setup(self, mixer_ref): self._audio_mixer = mixer_ref From ddd872cdeafaa809a71a7323286bfadd5b2550ca Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 13 Feb 2015 00:06:57 +0100 Subject: [PATCH 058/314] core: Always return an answer for all URIs in get_images Also make sure that results are tuples instead of lists so we don't accidentally give out mutable state. --- mopidy/core/library.py | 13 +++++++------ tests/core/test_library.py | 20 ++++++++++++-------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index b3832d5f..822836a6 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -82,18 +82,19 @@ class LibraryController(object): Unknown URIs or URIs the corresponding backend couldn't find anything for will simply return an empty list for that URI. - :param list uris: list of URIsto find images for - :rtype: {uri: [:class:`mopidy.models.Image`]} + :param list uris: list of URIs to find images for + :rtype: {uri: tuple of :class:`mopidy.models.Image`} """ futures = [ backend.library.get_images(backend_uris) for (backend, backend_uris) in self._get_backends_to_uris(uris).items() if backend_uris] - images = {} - for result in pykka.get_all(futures): - images.update(result) - return images + results = {uri: tuple() for uri in uris} + for r in pykka.get_all(futures): + for uri, images in r.items(): + results[uri] += tuple(images) + return results def find_exact(self, query=None, uris=None, **kwargs): """ diff --git a/tests/core/test_library.py b/tests/core/test_library.py index fb1f9228..ccf1b349 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -40,11 +40,13 @@ class CoreLibraryTest(unittest.TestCase): def test_get_images_returns_empty_dict_for_no_uris(self): self.assertEqual({}, self.core.library.get_images([])) - def test_get_images_returns_empty_dict_for_unknown_uri(self): - self.assertEqual({}, self.core.library.get_images(['dummy4:bar'])) + def test_get_images_returns_empty_result_for_unknown_uri(self): + result = self.core.library.get_images(['dummy4:track']) + self.assertEqual({'dummy4:track': tuple()}, result) - def test_get_images_returns_empty_dict_for_library_less_uri(self): - self.assertEqual({}, self.core.library.get_images(['dummy3:foo'])) + def test_get_images_returns_empty_result_for_library_less_uri(self): + result = self.core.library.get_images(['dummy3:track']) + self.assertEqual({'dummy3:track': tuple()}, result) def test_get_images_maps_uri_to_backend(self): self.core.library.get_images(['dummy1:track']) @@ -62,7 +64,7 @@ class CoreLibraryTest(unittest.TestCase): self.library1.get_images.reset_mock() result = self.core.library.get_images(['dummy1:track']) - self.assertEqual({'dummy1:track': [Image(uri='uri')]}, result) + self.assertEqual({'dummy1:track': (Image(uri='uri'),)}, result) def test_get_images_merges_results(self): self.library1.get_images().get.return_value = { @@ -72,9 +74,11 @@ class CoreLibraryTest(unittest.TestCase): 'dummy2:track': [Image(uri='uri2')]} self.library2.get_images.reset_mock() - result = self.core.library.get_images(['dummy1:track', 'dummy2:track']) - expected = {'dummy1:track': [Image(uri='uri1')], - 'dummy2:track': [Image(uri='uri2')]} + result = self.core.library.get_images( + ['dummy1:track', 'dummy2:track', 'dummy3:track', 'dummy4:track']) + expected = {'dummy1:track': (Image(uri='uri1'),), + 'dummy2:track': (Image(uri='uri2'),), + 'dummy3:track': tuple(), 'dummy4:track': tuple()} self.assertEqual(expected, result) def test_browse_root_returns_dir_ref_for_each_lib_with_root_dir_name(self): From e4f2796e81e3c8ae6c86b1320653a9172afa5c55 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 00:41:22 +0100 Subject: [PATCH 059/314] docs: Add model addition to changelog --- docs/changelog.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 86566e99..4206d455 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,6 +8,11 @@ This changelog is used to track all major changes to Mopidy. v0.20.0 (UNRELEASED) ==================== +**Models** + +- Add :class:`mopidy.models.Image` model to be returned by + :meth:`mopidy.core.LibraryController.get_images`. (Part of :issue:`973`) + **Core API** - Deprecate all properties in the core API. The previously undocumented getter From 96572eacdfe9267b5a57369a61529bf06c762c30 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 13 Feb 2015 00:51:52 +0100 Subject: [PATCH 060/314] audio: Add proxy support to scanner --- mopidy/audio/actor.py | 17 +---------------- mopidy/audio/scan.py | 7 ++++++- mopidy/audio/utils.py | 26 ++++++++++++++++++++++++-- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 133c8424..6ef48a6b 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -244,20 +244,6 @@ class SoftwareMixer(object): self._mixer.trigger_mute_changed(self._last_mute) -def setup_proxy(element, config): - # TODO: reuse in scanner code - if not config.get('hostname'): - return - - proxy = "%s://%s:%d" % (config.get('scheme', 'http'), - config.get('hostname'), - config.get('port', 80)) - - element.set_property('proxy', proxy) - element.set_property('proxy-id', config.get('username')) - element.set_property('proxy-pw', config.get('password')) - - class _Handler(object): def __init__(self, audio): self._audio = audio @@ -531,8 +517,7 @@ class Audio(pykka.ThreadingActor): else: self._appsrc.reset() - if hasattr(source.props, 'proxy'): - setup_proxy(source, self._config['proxy']) + utils.setup_proxy(source, self._config['proxy']) def set_uri(self, uri): """ diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 931a2e3a..38b86437 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -16,10 +16,11 @@ class Scanner(object): Helper to get tags and other relevant info from URIs. :param timeout: timeout for scanning a URI in ms + :param proxy_config: dictionary containing proxy config strings. :type event: int """ - def __init__(self, timeout=1000): + def __init__(self, timeout=1000, proxy_config=None): self._timeout_ms = timeout sink = gst.element_factory_make('fakesink') @@ -29,9 +30,13 @@ class Scanner(object): def pad_added(src, pad): return pad.link(sink.get_pad('sink')) + def source_setup(element, source): + utils.setup_proxy(source, proxy_config or {}) + self._uribin = gst.element_factory_make('uridecodebin') self._uribin.set_property('caps', audio_caps) self._uribin.connect('pad-added', pad_added) + self._uribin.connect('source-setup', source_setup) self._pipe = gst.element_factory_make('pipeline') self._pipe.add(self._uribin) diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index 8581fd61..1a8bf6a7 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -82,7 +82,8 @@ def _artists(tags, artist_name, artist_id=None): def convert_tags_to_track(tags): """Convert our normalized tags to a track. - :param :class:`dict` tags: dictionary of tag keys with a list of values + :param tags: dictionary of tag keys with a list of values + :type tags: :class:`dict` :rtype: :class:`mopidy.models.Track` """ album_kwargs = {} @@ -130,6 +131,26 @@ def convert_tags_to_track(tags): return Track(**track_kwargs) +def setup_proxy(element, config): + """Configure a GStreamer element with proxy settings. + + :param element: element to setup proxy in. + :type element: :class:`gst.GstElement` + :param config: proxy settings to use. + :type config: :class:`dict` + """ + if not hasattr(element.props, 'proxy') or not config.get('hostname'): + return + + proxy = "%s://%s:%d" % (config.get('scheme', 'http'), + config.get('hostname'), + config.get('port', 80)) + + element.set_property('proxy', proxy) + element.set_property('proxy-id', config.get('username')) + element.set_property('proxy-pw', config.get('password')) + + def convert_taglist(taglist): """Convert a :class:`gst.Taglist` to plain Python types. @@ -147,7 +168,8 @@ def convert_taglist(taglist): .. _GstTagList: http://gstreamer.freedesktop.org/data/doc/gstreamer/\ 0.10.36/gstreamer/html/gstreamer-GstTagList.html - :param gst.Taglist taglist: A GStreamer taglist to be converted. + :param taglist: A GStreamer taglist to be converted. + :type taglist: :class:`gst.Taglist` :rtype: dictionary of tag keys with a list of values. """ result = {} From 0e4e872d6bda4b71a2b0c6b3011427b0fbe30a50 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 13 Feb 2015 00:52:20 +0100 Subject: [PATCH 061/314] stream: Hook stream scanner up to proxy settings --- docs/changelog.rst | 3 +++ mopidy/stream/actor.py | 7 ++++--- tests/stream/test_library.py | 8 ++++---- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 01476404..c39f56e7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -124,6 +124,9 @@ v0.20.0 (UNRELEASED) - Add basic tests for the stream library provider. +- Add support for proxies when doing initial metadata lookup for stream. + (Fixes :issue:`390`) + **Mopidy.js client library** This version has been released to npm as Mopidy.js v0.5.0. diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index 9599d9d3..58fd966a 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -20,7 +20,8 @@ class StreamBackend(pykka.ThreadingActor, backend.Backend): self.library = StreamLibraryProvider( backend=self, timeout=config['stream']['timeout'], - blacklist=config['stream']['metadata_blacklist']) + blacklist=config['stream']['metadata_blacklist'], + proxy=config['proxy']) self.playback = backend.PlaybackProvider(audio=audio, backend=self) self.playlists = None @@ -29,9 +30,9 @@ class StreamBackend(pykka.ThreadingActor, backend.Backend): class StreamLibraryProvider(backend.LibraryProvider): - def __init__(self, backend, timeout, blacklist): + def __init__(self, backend, timeout, blacklist, proxy): super(StreamLibraryProvider, self).__init__(backend) - self._scanner = scan.Scanner(timeout=timeout) + self._scanner = scan.Scanner(timeout=timeout, proxy_config=proxy) self._blacklist_re = re.compile( r'^(%s)$' % '|'.join(fnmatch.translate(u) for u in blacklist)) diff --git a/tests/stream/test_library.py b/tests/stream/test_library.py index 7ed871cb..93292376 100644 --- a/tests/stream/test_library.py +++ b/tests/stream/test_library.py @@ -25,19 +25,19 @@ class LibraryProviderTest(unittest.TestCase): self.uri = path_to_uri(path_to_data_dir('song1.wav')) def test_lookup_ignores_unknown_scheme(self): - library = actor.StreamLibraryProvider(self.backend, 1000, []) + library = actor.StreamLibraryProvider(self.backend, 1000, [], {}) self.assertFalse(library.lookup('http://example.com')) def test_lookup_respects_blacklist(self): - library = actor.StreamLibraryProvider(self.backend, 100, [self.uri]) + library = actor.StreamLibraryProvider(self.backend, 10, [self.uri], {}) self.assertEqual([Track(uri=self.uri)], library.lookup(self.uri)) def test_lookup_respects_blacklist_globbing(self): blacklist = [path_to_uri(path_to_data_dir('')) + '*'] - library = actor.StreamLibraryProvider(self.backend, 100, blacklist) + library = actor.StreamLibraryProvider(self.backend, 100, blacklist, {}) self.assertEqual([Track(uri=self.uri)], library.lookup(self.uri)) def test_lookup_converts_uri_metadata_to_track(self): - library = actor.StreamLibraryProvider(self.backend, 100, []) + library = actor.StreamLibraryProvider(self.backend, 100, [], {}) self.assertEqual([Track(length=4406, uri=self.uri)], library.lookup(self.uri)) From 333bc69777e5e97c98072c7cd5522643726c8d7b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 00:58:09 +0100 Subject: [PATCH 062/314] jsonrpc: Don't use mixer in tests --- tests/utils/test_jsonrpc.py | 42 ++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index 6e309c7c..535df175 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -13,6 +13,9 @@ from mopidy.utils import jsonrpc class Calculator(object): + def __init__(self): + self._mem = None + def model(self): return 'TI83' @@ -23,6 +26,12 @@ class Calculator(object): def sub(self, a, b): return a - b + def set_mem(self, value): + self._mem = value + + def get_mem(self): + return self._mem + def describe(self): return { 'add': 'Returns the sum of the terms', @@ -43,13 +52,14 @@ class JsonRpcTestBase(unittest.TestCase): def setUp(self): # noqa: N802 self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() + self.calc = Calculator() self.jrw = jsonrpc.JsonRpcWrapper( objects={ 'hello': lambda: 'Hello, world!', - 'calc': Calculator(), + 'calc': self.calc, 'core': self.core, - 'core.mixer': self.core.mixer, + 'core.playback': self.core.playback, 'core.tracklist': self.core.tracklist, 'get_uri_schemes': self.core.get_uri_schemes, }, @@ -188,12 +198,12 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase): def test_call_method_on_actor_member(self): request = { 'jsonrpc': '2.0', - 'method': 'core.mixer.get_volume', + 'method': 'core.playback.get_time_position', 'id': 1, } response = self.jrw.handle_data(request) - self.assertEqual(response['result'], None) + self.assertEqual(response['result'], 0) def test_call_method_which_is_a_directly_mounted_actor_member(self): # 'get_uri_schemes' isn't a regular callable, but a Pykka @@ -215,26 +225,24 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase): def test_call_method_with_positional_params(self): request = { 'jsonrpc': '2.0', - 'method': 'core.mixer.set_volume', - 'params': [37], + 'method': 'calc.add', + 'params': [3, 4], 'id': 1, } response = self.jrw.handle_data(request) - self.assertEqual(response['result'], None) - self.assertEqual(self.core.mixer.get_volume().get(), 37) + self.assertEqual(response['result'], 7) def test_call_methods_with_named_params(self): request = { 'jsonrpc': '2.0', - 'method': 'core.mixer.set_volume', - 'params': {'volume': 37}, + 'method': 'calc.add', + 'params': {'a': 3, 'b': 4}, 'id': 1, } response = self.jrw.handle_data(request) - self.assertEqual(response['result'], None) - self.assertEqual(self.core.mixer.get_volume().get(), 37) + self.assertEqual(response['result'], 7) class JsonRpcSingleNotificationTest(JsonRpcTestBase): @@ -248,17 +256,17 @@ class JsonRpcSingleNotificationTest(JsonRpcTestBase): self.assertIsNone(response) def test_notification_makes_an_observable_change(self): - self.assertEqual(self.core.mixer.get_volume().get(), None) + self.assertEqual(self.calc.get_mem(), None) request = { 'jsonrpc': '2.0', - 'method': 'core.mixer.set_volume', + 'method': 'calc.set_mem', 'params': [37], } response = self.jrw.handle_data(request) self.assertIsNone(response) - self.assertEqual(self.core.mixer.get_volume().get(), 37) + self.assertEqual(self.calc.get_mem(), 37) def test_notification_unknown_method_returns_nothing(self): request = { @@ -526,7 +534,7 @@ class JsonRpcBatchErrorTest(JsonRpcTestBase): def test_batch_of_both_successfull_and_failing_requests(self): request = [ # Call with positional params - {'jsonrpc': '2.0', 'method': 'core.mixer.set_volume', + {'jsonrpc': '2.0', 'method': 'core.playback.seek', 'params': [47], 'id': '1'}, # Notification {'jsonrpc': '2.0', 'method': 'core.tracklist.set_consume', @@ -547,7 +555,7 @@ class JsonRpcBatchErrorTest(JsonRpcTestBase): self.assertEqual(len(response), 5) response = dict((row['id'], row) for row in response) - self.assertEqual(response['1']['result'], None) + self.assertEqual(response['1']['result'], False) self.assertEqual(response['2']['result'], None) self.assertEqual(response[None]['error']['code'], -32600) self.assertEqual(response['5']['error']['code'], -32601) From 886c2b92d8dcc40577341245f7973d4a2d31aa90 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 00:58:37 +0100 Subject: [PATCH 063/314] core: Use a mixer mock in tests --- tests/core/test_mixer.py | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py index e3fa6be6..80e6f7ef 100644 --- a/tests/core/test_mixer.py +++ b/tests/core/test_mixer.py @@ -2,27 +2,34 @@ from __future__ import absolute_import, unicode_literals import unittest -from mopidy import core +import mock + +from mopidy import core, mixer class CoreMixerTest(unittest.TestCase): def setUp(self): # noqa: N802 - self.core = core.Core(mixer=None, backends=[]) + self.mixer = mock.Mock(spec=mixer.Mixer) + self.core = core.Core(mixer=self.mixer, backends=[]) - def test_volume(self): - self.assertEqual(self.core.mixer.get_volume(), None) - - self.core.mixer.set_volume(30) + def test_get_volume(self): + self.mixer.get_volume.return_value.get.return_value = 30 self.assertEqual(self.core.mixer.get_volume(), 30) + self.mixer.get_volume.assert_called_once_with() - self.core.mixer.set_volume(70) + def test_set_volume(self): + self.core.mixer.set_volume(30) - self.assertEqual(self.core.mixer.get_volume(), 70) + self.mixer.set_volume.assert_called_once_with(30) - def test_mute(self): - self.assertEqual(self.core.mixer.get_mute(), False) - - self.core.mixer.set_mute(True) + def test_get_mute(self): + self.mixer.get_mute.return_value.get.return_value = True self.assertEqual(self.core.mixer.get_mute(), True) + self.mixer.get_mute.assert_called_once_with() + + def test_set_mute(self): + self.core.mixer.set_mute(True) + + self.mixer.set_mute.assert_called_once_with(True) From 160afbcd265cfc0602213f7b07c6e4fa92f82cf2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 01:14:36 +0100 Subject: [PATCH 064/314] mpd: Use DummyMixer in tests --- mopidy/mixer.py | 22 ++++++++++++++++++++++ tests/mpd/protocol/__init__.py | 6 ++++-- tests/mpd/test_status.py | 6 ++++-- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/mopidy/mixer.py b/mopidy/mixer.py index e277fe55..b9fc41ca 100644 --- a/mopidy/mixer.py +++ b/mopidy/mixer.py @@ -2,6 +2,8 @@ from __future__ import absolute_import, unicode_literals import logging +import pykka + from mopidy import listener @@ -147,3 +149,23 @@ class MixerListener(listener.Listener): :type mute: bool """ pass + + +class DummyMixer(pykka.ThreadingActor, Mixer): + + def __init__(self): + super(DummyMixer, self).__init__() + self._volume = None + self._mute = None + + def get_volume(self): + return self._volume + + def set_volume(self, volume): + self._volume = volume + + def get_mute(self): + return self._mute + + def set_mute(self, mute): + self._mute = mute diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index 8c7b60f1..ba446cb0 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -6,7 +6,7 @@ import mock import pykka -from mopidy import core +from mopidy import core, mixer from mopidy.backend import dummy from mopidy.mpd import session, uri_mapper @@ -32,8 +32,10 @@ class BaseTestCase(unittest.TestCase): } def setUp(self): # noqa: N802 + self.mixer = mixer.DummyMixer.start().proxy() self.backend = dummy.create_dummy_backend_proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() + self.core = core.Core.start( + mixer=self.mixer, backends=[self.backend]).proxy() self.uri_map = uri_mapper.MpdUriMapper(self.core) self.connection = MockConnection() diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index 75c10c94..8dbfb1e4 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -4,7 +4,7 @@ import unittest import pykka -from mopidy import core +from mopidy import core, mixer from mopidy.backend import dummy from mopidy.core import PlaybackState from mopidy.models import Track @@ -21,8 +21,10 @@ STOPPED = PlaybackState.STOPPED class StatusHandlerTest(unittest.TestCase): def setUp(self): # noqa: N802 + self.mixer = mixer.DummyMixer.start().proxy() self.backend = dummy.create_dummy_backend_proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() + self.core = core.Core.start( + mixer=self.mixer, backends=[self.backend]).proxy() self.dispatcher = dispatcher.MpdDispatcher(core=self.core) self.context = self.dispatcher.context From f7e218b72a09615259b4d77e9169f5237a4cae32 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 00:58:52 +0100 Subject: [PATCH 065/314] core: Remove test-only code paths in MixerController --- mopidy/core/mixer.py | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index e6856f17..4d77f8bc 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -21,11 +21,8 @@ class MixerController(object): The volume scale is linear. """ - if self._mixer: + if self._mixer is not None: return self._mixer.get_volume().get() - else: - # For testing - return self._volume def set_volume(self, volume): """Set the volume. @@ -34,31 +31,22 @@ class MixerController(object): The volume scale is linear. """ - if self._mixer: + if self._mixer is not None: self._mixer.set_volume(volume) - else: - # For testing - self._volume = volume def get_mute(self): """Get mute state. - :class:`True` if muted, :class:`False` otherwise. + :class:`True` if muted, :class:`False` unmuted, :class:`None` if + unknown. """ - if self._mixer: + if self._mixer is not None: return self._mixer.get_mute().get() - else: - # For testing - return self._mute def set_mute(self, mute): """Set mute state. :class:`True` to mute, :class:`False` to unmute. """ - mute = bool(mute) - if self._mixer: - self._mixer.set_mute(mute) - else: - # For testing - self._mute = mute + if self._mixer is not None: + self._mixer.set_mute(bool(mute)) From df95a988b72c66221b2c12e6524995e174f46915 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 01:30:17 +0100 Subject: [PATCH 066/314] backend: Move DummyBackend into tests package --- tests/core/test_events.py | 5 +++-- mopidy/backend/dummy.py => tests/dummy_backend.py | 2 +- tests/mpd/protocol/__init__.py | 5 +++-- tests/mpd/test_dispatcher.py | 5 +++-- tests/mpd/test_status.py | 6 ++++-- tests/utils/test_jsonrpc.py | 5 +++-- 6 files changed, 17 insertions(+), 11 deletions(-) rename mopidy/backend/dummy.py => tests/dummy_backend.py (98%) diff --git a/tests/core/test_events.py b/tests/core/test_events.py index 7226673d..942f9b5f 100644 --- a/tests/core/test_events.py +++ b/tests/core/test_events.py @@ -7,14 +7,15 @@ import mock import pykka from mopidy import core -from mopidy.backend import dummy from mopidy.models import Track +from tests import dummy_backend + @mock.patch.object(core.CoreListener, 'send') class BackendEventsTest(unittest.TestCase): def setUp(self): # noqa: N802 - self.backend = dummy.create_dummy_backend_proxy() + self.backend = dummy_backend.create_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() def tearDown(self): # noqa: N802 diff --git a/mopidy/backend/dummy.py b/tests/dummy_backend.py similarity index 98% rename from mopidy/backend/dummy.py rename to tests/dummy_backend.py index dfddf5ae..05b0fbff 100644 --- a/mopidy/backend/dummy.py +++ b/tests/dummy_backend.py @@ -12,7 +12,7 @@ from mopidy import backend from mopidy.models import Playlist, Ref, SearchResult -def create_dummy_backend_proxy(config=None, audio=None): +def create_proxy(config=None, audio=None): return DummyBackend.start(config=config, audio=audio).proxy() diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index ba446cb0..ed4920f2 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -7,9 +7,10 @@ import mock import pykka from mopidy import core, mixer -from mopidy.backend import dummy from mopidy.mpd import session, uri_mapper +from tests import dummy_backend + class MockConnection(mock.Mock): def __init__(self, *args, **kwargs): @@ -33,7 +34,7 @@ class BaseTestCase(unittest.TestCase): def setUp(self): # noqa: N802 self.mixer = mixer.DummyMixer.start().proxy() - self.backend = dummy.create_dummy_backend_proxy() + self.backend = dummy_backend.create_proxy() self.core = core.Core.start( mixer=self.mixer, backends=[self.backend]).proxy() diff --git a/tests/mpd/test_dispatcher.py b/tests/mpd/test_dispatcher.py index 1a230451..63981668 100644 --- a/tests/mpd/test_dispatcher.py +++ b/tests/mpd/test_dispatcher.py @@ -5,10 +5,11 @@ import unittest import pykka from mopidy import core -from mopidy.backend import dummy from mopidy.mpd.dispatcher import MpdDispatcher from mopidy.mpd.exceptions import MpdAckError +from tests import dummy_backend + class MpdDispatcherTest(unittest.TestCase): def setUp(self): # noqa: N802 @@ -17,7 +18,7 @@ class MpdDispatcherTest(unittest.TestCase): 'password': None, } } - self.backend = dummy.create_dummy_backend_proxy() + self.backend = dummy_backend.create_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.dispatcher = MpdDispatcher(config=config) diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index 8dbfb1e4..069addca 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -5,12 +5,14 @@ import unittest import pykka from mopidy import core, mixer -from mopidy.backend import dummy from mopidy.core import PlaybackState from mopidy.models import Track from mopidy.mpd import dispatcher from mopidy.mpd.protocol import status +from tests import dummy_backend + + PAUSED = PlaybackState.PAUSED PLAYING = PlaybackState.PLAYING STOPPED = PlaybackState.STOPPED @@ -22,7 +24,7 @@ STOPPED = PlaybackState.STOPPED class StatusHandlerTest(unittest.TestCase): def setUp(self): # noqa: N802 self.mixer = mixer.DummyMixer.start().proxy() - self.backend = dummy.create_dummy_backend_proxy() + self.backend = dummy_backend.create_proxy() self.core = core.Core.start( mixer=self.mixer, backends=[self.backend]).proxy() self.dispatcher = dispatcher.MpdDispatcher(core=self.core) diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index 535df175..4471a4a0 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -8,9 +8,10 @@ import mock import pykka from mopidy import core, models -from mopidy.backend import dummy from mopidy.utils import jsonrpc +from tests import dummy_backend + class Calculator(object): def __init__(self): @@ -50,7 +51,7 @@ class Calculator(object): class JsonRpcTestBase(unittest.TestCase): def setUp(self): # noqa: N802 - self.backend = dummy.create_dummy_backend_proxy() + self.backend = dummy_backend.create_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.calc = Calculator() From 016024a081852de7390318c79b2f7ffc4af59a3b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 01:31:33 +0100 Subject: [PATCH 067/314] backend: Convert from package to module --- mopidy/{backend/__init__.py => backend.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename mopidy/{backend/__init__.py => backend.py} (100%) diff --git a/mopidy/backend/__init__.py b/mopidy/backend.py similarity index 100% rename from mopidy/backend/__init__.py rename to mopidy/backend.py From b554a64aadfc42b5dced8b3431db0a05c852d1c7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 01:35:07 +0100 Subject: [PATCH 068/314] mixer: Move DummyMixer into tests package --- mopidy/mixer.py | 22 ---------------------- tests/dummy_mixer.py | 29 +++++++++++++++++++++++++++++ tests/mpd/protocol/__init__.py | 6 +++--- tests/mpd/test_status.py | 6 +++--- 4 files changed, 35 insertions(+), 28 deletions(-) create mode 100644 tests/dummy_mixer.py diff --git a/mopidy/mixer.py b/mopidy/mixer.py index b9fc41ca..e277fe55 100644 --- a/mopidy/mixer.py +++ b/mopidy/mixer.py @@ -2,8 +2,6 @@ from __future__ import absolute_import, unicode_literals import logging -import pykka - from mopidy import listener @@ -149,23 +147,3 @@ class MixerListener(listener.Listener): :type mute: bool """ pass - - -class DummyMixer(pykka.ThreadingActor, Mixer): - - def __init__(self): - super(DummyMixer, self).__init__() - self._volume = None - self._mute = None - - def get_volume(self): - return self._volume - - def set_volume(self, volume): - self._volume = volume - - def get_mute(self): - return self._mute - - def set_mute(self, mute): - self._mute = mute diff --git a/tests/dummy_mixer.py b/tests/dummy_mixer.py new file mode 100644 index 00000000..f7d90b17 --- /dev/null +++ b/tests/dummy_mixer.py @@ -0,0 +1,29 @@ +from __future__ import unicode_literals + +import pykka + +from mopidy import mixer + + +def create_proxy(config=None): + return DummyMixer.start(config=None).proxy() + + +class DummyMixer(pykka.ThreadingActor, mixer.Mixer): + + def __init__(self, config): + super(DummyMixer, self).__init__() + self._volume = None + self._mute = None + + def get_volume(self): + return self._volume + + def set_volume(self, volume): + self._volume = volume + + def get_mute(self): + return self._mute + + def set_mute(self, mute): + self._mute = mute diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index ed4920f2..b07a5ba3 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -6,10 +6,10 @@ import mock import pykka -from mopidy import core, mixer +from mopidy import core from mopidy.mpd import session, uri_mapper -from tests import dummy_backend +from tests import dummy_backend, dummy_mixer class MockConnection(mock.Mock): @@ -33,7 +33,7 @@ class BaseTestCase(unittest.TestCase): } def setUp(self): # noqa: N802 - self.mixer = mixer.DummyMixer.start().proxy() + self.mixer = dummy_mixer.create_proxy() self.backend = dummy_backend.create_proxy() self.core = core.Core.start( mixer=self.mixer, backends=[self.backend]).proxy() diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index 069addca..e130353b 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -4,13 +4,13 @@ import unittest import pykka -from mopidy import core, mixer +from mopidy import core from mopidy.core import PlaybackState from mopidy.models import Track from mopidy.mpd import dispatcher from mopidy.mpd.protocol import status -from tests import dummy_backend +from tests import dummy_backend, dummy_mixer PAUSED = PlaybackState.PAUSED @@ -23,7 +23,7 @@ STOPPED = PlaybackState.STOPPED class StatusHandlerTest(unittest.TestCase): def setUp(self): # noqa: N802 - self.mixer = mixer.DummyMixer.start().proxy() + self.mixer = dummy_mixer.create_proxy() self.backend = dummy_backend.create_proxy() self.core = core.Core.start( mixer=self.mixer, backends=[self.backend]).proxy() From 82b94693f9f319d4dd34a3d347143c8e3579e9ec Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 01:39:57 +0100 Subject: [PATCH 069/314] docs: Add Image to model relation graph --- docs/api/models.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/api/models.rst b/docs/api/models.rst index 11ec017c..270f3896 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -37,6 +37,8 @@ Data model relations SearchResult -> Album [ label="has 0..n" ] SearchResult -> Track [ label="has 0..n" ] + Image + Data model API ============== From 9f199b12cefd2fb0a82cf7395aa05f3634530ef9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 01:51:08 +0100 Subject: [PATCH 070/314] docs: Update authors --- .mailmap | 1 + AUTHORS | 1 + 2 files changed, 2 insertions(+) diff --git a/.mailmap b/.mailmap index 8b8fd865..3ea843b1 100644 --- a/.mailmap +++ b/.mailmap @@ -18,3 +18,4 @@ Colin Montgomerie Ignasi Fosch Christopher Schirner Laura Barber +John Cass diff --git a/AUTHORS b/AUTHORS index 45817e75..08685991 100644 --- a/AUTHORS +++ b/AUTHORS @@ -49,4 +49,5 @@ - Deni Bertovic - Ali Ukani - Dirk Groenen +- John Cass - Laura Barber From a3b7c8d44f8525350f83c84e7927f518a431922c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 09:33:05 +0100 Subject: [PATCH 071/314] audio: Fix AttributeError on shutdown (fix #985) Given the right timings, there was possible to get a stack trace at shutdown if the audio actor was teared down first and a music delivery from libspotify/pyspotify called audio.push() after the teardown. --- mopidy/audio/actor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 6ef48a6b..63b0eebe 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -128,6 +128,9 @@ class _Appsrc(object): self._source = source def push(self, buffer_): + if self._source is None: + return False + if buffer_ is None: gst_logger.debug('Sending appsrc end-of-stream event.') return self._source.emit('end-of-stream') == gst.FLOW_OK From 5270aa65e2c2fe035ccbb00f482cec35bc4b8786 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 01:47:15 +0100 Subject: [PATCH 072/314] audio: Move DummyAudio into tests package --- mopidy/audio/__init__.py | 1 - tests/audio/test_actor.py | 3 +- mopidy/audio/dummy.py => tests/dummy_audio.py | 42 ++++++++++--------- tests/local/test_events.py | 6 +-- tests/local/test_playback.py | 6 +-- tests/local/test_playlists.py | 6 +-- tests/local/test_tracklist.py | 6 +-- 7 files changed, 36 insertions(+), 34 deletions(-) rename mopidy/audio/dummy.py => tests/dummy_audio.py (65%) diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 1d47e682..a74d4456 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -2,7 +2,6 @@ from __future__ import absolute_import, unicode_literals # flake8: noqa from .actor import Audio -from .dummy import DummyAudio from .listener import AudioListener from .constants import PlaybackState from .utils import ( diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index 43f7c076..fbc440de 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -15,11 +15,10 @@ import mock import pykka from mopidy import audio -from mopidy.audio import dummy as dummy_audio from mopidy.audio.constants import PlaybackState from mopidy.utils.path import path_to_uri -from tests import path_to_data_dir +from tests import dummy_audio, path_to_data_dir # We want to make sure both our real audio class and the fake one behave # correctly. So each test is first run against the real class, then repeated diff --git a/mopidy/audio/dummy.py b/tests/dummy_audio.py similarity index 65% rename from mopidy/audio/dummy.py rename to tests/dummy_audio.py index 95b9d0fb..64639e91 100644 --- a/mopidy/audio/dummy.py +++ b/tests/dummy_audio.py @@ -8,14 +8,17 @@ from __future__ import absolute_import, unicode_literals import pykka -from .constants import PlaybackState -from .listener import AudioListener +from mopidy import audio + + +def create_proxy(config=None, mixer=None): + return DummyAudio.start(config, mixer).proxy() class DummyAudio(pykka.ThreadingActor): def __init__(self, config=None, mixer=None): super(DummyAudio, self).__init__() - self.state = PlaybackState.STOPPED + self.state = audio.PlaybackState.STOPPED self._volume = 0 self._position = 0 self._callback = None @@ -42,21 +45,21 @@ class DummyAudio(pykka.ThreadingActor): def set_position(self, position): self._position = position - AudioListener.send('position_changed', position=position) + audio.AudioListener.send('position_changed', position=position) return True def start_playback(self): - return self._change_state(PlaybackState.PLAYING) + return self._change_state(audio.PlaybackState.PLAYING) def pause_playback(self): - return self._change_state(PlaybackState.PAUSED) + return self._change_state(audio.PlaybackState.PAUSED) def prepare_change(self): self._uri = None return True def stop_playback(self): - return self._change_state(PlaybackState.STOPPED) + return self._change_state(audio.PlaybackState.STOPPED) def get_volume(self): return self._volume @@ -84,21 +87,22 @@ class DummyAudio(pykka.ThreadingActor): if not self._uri: return False - if self.state == PlaybackState.STOPPED and self._uri: - AudioListener.send('position_changed', position=0) - AudioListener.send('stream_changed', uri=self._uri) + if self.state == audio.PlaybackState.STOPPED and self._uri: + audio.AudioListener.send('position_changed', position=0) + audio.AudioListener.send('stream_changed', uri=self._uri) - if new_state == PlaybackState.STOPPED: + if new_state == audio.PlaybackState.STOPPED: self._uri = None - AudioListener.send('stream_changed', uri=self._uri) + audio.AudioListener.send('stream_changed', uri=self._uri) old_state, self.state = self.state, new_state - AudioListener.send('state_changed', old_state=old_state, - new_state=new_state, target_state=None) + audio.AudioListener.send( + 'state_changed', + old_state=old_state, new_state=new_state, target_state=None) - if new_state == PlaybackState.PLAYING: + if new_state == audio.PlaybackState.PLAYING: self._tags['audio-codec'] = [u'fake info...'] - AudioListener.send('tags_changed', tags=['audio-codec']) + audio.AudioListener.send('tags_changed', tags=['audio-codec']) return self._state_change_result @@ -114,9 +118,9 @@ class DummyAudio(pykka.ThreadingActor): if not self._uri or not self._callback: self._tags = {} - AudioListener.send('reached_end_of_stream') + audio.AudioListener.send('reached_end_of_stream') else: - AudioListener.send('position_changed', position=0) - AudioListener.send('stream_changed', uri=self._uri) + audio.AudioListener.send('position_changed', position=0) + audio.AudioListener.send('stream_changed', uri=self._uri) return wrapper diff --git a/tests/local/test_events.py b/tests/local/test_events.py index ae2ec66a..945347df 100644 --- a/tests/local/test_events.py +++ b/tests/local/test_events.py @@ -6,10 +6,10 @@ import mock import pykka -from mopidy import audio, backend, core +from mopidy import backend, core from mopidy.local import actor -from tests import path_to_data_dir +from tests import dummy_audio, path_to_data_dir @mock.patch.object(backend.BackendListener, 'send') @@ -24,7 +24,7 @@ class LocalBackendEventsTest(unittest.TestCase): } def setUp(self): # noqa: N802 - self.audio = audio.DummyAudio.start().proxy() + self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() self.core = core.Core.start(backends=[self.backend]).proxy() diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index 0edd89c5..5f1ff525 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -7,12 +7,12 @@ import mock import pykka -from mopidy import audio, core +from mopidy import core from mopidy.core import PlaybackState from mopidy.local import actor from mopidy.models import Track -from tests import path_to_data_dir +from tests import dummy_audio, path_to_data_dir from tests.local import generate_song, populate_tracklist @@ -40,7 +40,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.add([track]) def setUp(self): # noqa: N802 - self.audio = audio.DummyAudio.start().proxy() + self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() self.core = core.Core(backends=[self.backend]) diff --git a/tests/local/test_playlists.py b/tests/local/test_playlists.py index c9aa299a..3e9c280e 100644 --- a/tests/local/test_playlists.py +++ b/tests/local/test_playlists.py @@ -7,11 +7,11 @@ import unittest import pykka -from mopidy import audio, core +from mopidy import core from mopidy.local import actor from mopidy.models import Playlist, Track -from tests import path_to_data_dir +from tests import dummy_audio, path_to_data_dir from tests.local import generate_song @@ -29,7 +29,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.config['local']['playlists_dir'] = tempfile.mkdtemp() self.playlists_dir = self.config['local']['playlists_dir'] - self.audio = audio.DummyAudio.start().proxy() + self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() self.core = core.Core(backends=[self.backend]) diff --git a/tests/local/test_tracklist.py b/tests/local/test_tracklist.py index d74d436c..5c85ac19 100644 --- a/tests/local/test_tracklist.py +++ b/tests/local/test_tracklist.py @@ -5,12 +5,12 @@ import unittest import pykka -from mopidy import audio, core +from mopidy import core from mopidy.core import PlaybackState from mopidy.local import actor from mopidy.models import Playlist, TlTrack, Track -from tests import path_to_data_dir +from tests import dummy_audio, path_to_data_dir from tests.local import generate_song, populate_tracklist @@ -27,7 +27,7 @@ class LocalTracklistProviderTest(unittest.TestCase): Track(uri=generate_song(i), length=4464) for i in range(1, 4)] def setUp(self): # noqa: N802 - self.audio = audio.DummyAudio.start().proxy() + self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() self.core = core.Core(mixer=None, backends=[self.backend]) From 2d958d21b5f954460f6f033faeeaf0ea9fac1109 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 12:11:15 +0100 Subject: [PATCH 073/314] docs: Fix references and typos --- docs/changelog.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 4f4e4a5d..988a0fcd 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -19,16 +19,16 @@ v0.20.0 (UNRELEASED) and setter methods are now the official API. This aligns the Python API with the WebSocket/JavaScript API. (Fixes: :issue:`952`) -- Added :class:`mopidy.core.HistoryController` which keeps track of what - tracks have been played. (Fixes: :issue:`423`, PR: :issue:`803`) +- Add :class:`mopidy.core.HistoryController` which keeps track of what tracks + have been played. (Fixes: :issue:`423`, PR: :issue:`803`) -- Added :class:`mopidy.core.MixerController` which keeps track of volume and +- Add :class:`mopidy.core.MixerController` which keeps track of volume and mute. The old methods on :class:`mopidy.core.PlaybackController` for volume and mute management has been deprecated. (Fixes: :issue:`962`) -- Removed ``clear_current_track`` keyword argument to - :meth:`mopidy.core.Playback.stop`. It was a leaky internal abstraction, - which was never intended to be used externally. +- Remove ``clear_current_track`` keyword argument to + :meth:`mopidy.core.PlaybackController.stop`. It was a leaky internal + abstraction, which was never intended to be used externally. - Add :meth:`mopidy.core.LibraryController.get_images` for looking up images for any URI backends know about. (Fixes :issue:`973`) @@ -38,7 +38,7 @@ v0.20.0 (UNRELEASED) - Make the ``mopidy`` command print a friendly error message if the :mod:`gobject` Python module cannot be imported. (Fixes: :issue:`836`) -- Add support for repeating the :cmdoption:`-v ` argument four times +- Add support for repeating the :option:`-v ` argument four times to set the log level for all loggers to the lowest possible value, including log records at levels lover than ``DEBUG`` too. @@ -71,7 +71,7 @@ v0.20.0 (UNRELEASED) - Improve error logging for scan code (Fixes: :issue:`856`, PR: :issue:`874`) - Add symlink support with loop protection to file finder (Fixes: :issue:`858`, - PR: :isusue:`874`) + PR: :issue:`874`) **MPD frontend** From d60c037fdc5578b3910720ade879161c958ec588 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Feb 2015 12:24:33 +0100 Subject: [PATCH 074/314] docs: Update number of times -v is accepted --- docs/command.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/command.rst b/docs/command.rst index 881fb513..ea9ccce7 100644 --- a/docs/command.rst +++ b/docs/command.rst @@ -43,7 +43,7 @@ Options .. cmdoption:: --verbose, -v - Show more output. Repeat up to 3 times for even more. + Show more output. Repeat up to four times for even more. .. cmdoption:: --save-debug-log From 92910e4362ccb4bd45d5abb4c2306ad343a83265 Mon Sep 17 00:00:00 2001 From: Ali Ukani Date: Mon, 16 Feb 2015 02:00:40 -0500 Subject: [PATCH 075/314] Fix flake8 tests Fixes "W503 line break before binary operator" --- mopidy/core/actor.py | 4 ++-- mopidy/core/tracklist.py | 4 ++-- mopidy/local/search.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index bc8df64d..19e49838 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -98,8 +98,8 @@ class Core( # We ignore cases when target state is set as this is buffering # updates (at least for now) and we need to get #234 fixed... - if (new_state == PlaybackState.PAUSED and not target_state - and self.playback.state != PlaybackState.PAUSED): + if (new_state == PlaybackState.PAUSED and not target_state and + self.playback.state != PlaybackState.PAUSED): self.playback.state = new_state self.playback._trigger_track_playback_paused() diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index c54e6784..08d08329 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -392,8 +392,8 @@ class TracklistController(object): criteria = criteria or kwargs matches = self._tl_tracks for (key, values) in criteria.items(): - if (not isinstance(values, collections.Iterable) - or isinstance(values, compat.string_types)): + if (not isinstance(values, collections.Iterable) or + isinstance(values, compat.string_types)): # Fail hard if anyone is using the <0.17 calling style raise ValueError('Filter values must be iterable: %r' % values) if key == 'tlid': diff --git a/mopidy/local/search.py b/mopidy/local/search.py index 18dad82c..e63d0f8d 100644 --- a/mopidy/local/search.py +++ b/mopidy/local/search.py @@ -124,8 +124,8 @@ def search(tracks, query=None, uris=None): return bool(t.name and q in t.name.lower()) def album_filter(t): - return bool(t.album and t.album.name - and q in t.album.name.lower()) + return bool(t.album and t.album.name and + q in t.album.name.lower()) def artist_filter(t): return bool(filter(lambda a: From fc21d466f0455d441a3540a89508a26a47a9b6ad Mon Sep 17 00:00:00 2001 From: Ali Ukani Date: Mon, 26 Jan 2015 16:16:46 -0500 Subject: [PATCH 076/314] local: use limit and offset when searching json library Fixes the json local library's search behavior. Uses limit and offset arguments when returning search results. --- mopidy/local/json.py | 7 ++--- mopidy/local/search.py | 31 +++++++++++++++++++--- tests/local/test_json.py | 55 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 8 deletions(-) diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 38e1bf6c..f6ff879b 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -157,11 +157,12 @@ class JsonLibrary(local.Library): def search(self, query=None, limit=100, offset=0, uris=None, exact=False): tracks = self._tracks.values() - # TODO: pass limit and offset into search helpers if exact: - return search.find_exact(tracks, query=query, uris=uris) + return search.find_exact( + tracks, query=query, limit=limit, offset=offset, uris=uris) else: - return search.search(tracks, query=query, uris=uris) + return search.search( + tracks, query=query, limit=limit, offset=offset, uris=uris) def begin(self): return compat.itervalues(self._tracks) diff --git a/mopidy/local/search.py b/mopidy/local/search.py index e63d0f8d..9e56d73b 100644 --- a/mopidy/local/search.py +++ b/mopidy/local/search.py @@ -3,7 +3,18 @@ from __future__ import absolute_import, unicode_literals from mopidy.models import SearchResult -def find_exact(tracks, query=None, uris=None): +def find_exact(tracks, query=None, limit=100, offset=0, uris=None): + """ + Filter a list of tracks where ``field`` is ``values``. + + :param list tracks: a list of :class:`~mopidy.models.Track` + :param dict query: one or more queries to search for + :param int limit: maximum number of results to return + :param int offset: offset into result set to use. + :param uris: zero or more URI roots to limit the search to + :type uris: list of strings or :class:`None` + :rtype: :class:`~mopidy.models.SearchResult` + """ # TODO Only return results within URI roots given by ``uris`` if query is None: @@ -96,10 +107,22 @@ def find_exact(tracks, query=None, uris=None): raise LookupError('Invalid lookup field: %s' % field) # TODO: add local:search: - return SearchResult(uri='local:search', tracks=tracks) + return SearchResult( + uri='local:search', tracks=tracks[offset:offset+limit]) -def search(tracks, query=None, uris=None): +def search(tracks, query=None, limit=100, offset=0, uris=None): + """ + Filter a list of tracks where ``field`` is like ``values``. + + :param list tracks: a list of :class:`~mopidy.models.Track` + :param dict query: one or more queries to search for + :param int limit: maximum number of results to return + :param int offset: offset into result set to use. + :param uris: zero or more URI roots to limit the search to + :type uris: list of strings or :class:`None` + :rtype: :class:`~mopidy.models.SearchResult` + """ # TODO Only return results within URI roots given by ``uris`` if query is None: @@ -195,7 +218,7 @@ def search(tracks, query=None, uris=None): else: raise LookupError('Invalid lookup field: %s' % field) # TODO: add local:search: - return SearchResult(uri='local:search', tracks=tracks) + return SearchResult(uri='local:search', tracks=tracks[offset:offset+limit]) def _validate_query(query): diff --git a/tests/local/test_json.py b/tests/local/test_json.py index 0d62c2e3..545833d5 100644 --- a/tests/local/test_json.py +++ b/tests/local/test_json.py @@ -1,9 +1,13 @@ from __future__ import absolute_import, unicode_literals + import unittest from mopidy.local import json -from mopidy.models import Ref +from mopidy.models import Ref, Track + + +from tests import path_to_data_dir class BrowseCacheTest(unittest.TestCase): @@ -38,3 +42,52 @@ class BrowseCacheTest(unittest.TestCase): def test_lookup_foo_baz(self): result = self.cache.lookup('local:directory:foo/unknown') self.assertEqual([], result) + + +class JsonLibraryTest(unittest.TestCase): + + config = { + 'local': { + 'media_dir': path_to_data_dir(''), + 'data_dir': path_to_data_dir(''), + 'playlists_dir': b'', + 'library': 'json', + }, + } + + def setUp(self): # noqa: N802 + self.library = json.JsonLibrary(self.config) + + def _create_tracks(self, count): + for i in xrange(count): + self.library.add(Track(uri='local:track:%d' % i)) + + def test_search_should_default_limit_results(self): + self._create_tracks(101) + + result = self.library.search() + result_exact = self.library.search(exact=True) + + self.assertEqual(len(result.tracks), 100) + self.assertEqual(len(result_exact.tracks), 100) + + def test_search_should_limit_results(self): + self._create_tracks(100) + + result = self.library.search(limit=35) + result_exact = self.library.search(exact=True, limit=35) + + self.assertEqual(len(result.tracks), 35) + self.assertEqual(len(result_exact.tracks), 35) + + def test_search_should_offset_results(self): + self._create_tracks(200) + + expected = self.library.search(limit=110).tracks[10:] + expected_exact = self.library.search(exact=True, limit=110).tracks[10:] + + result = self.library.search(offset=10).tracks + result_exact = self.library.search(offset=10, exact=True).tracks + + self.assertEqual(expected, result) + self.assertEqual(expected_exact, result_exact) From ead147f482995baeac8349871c916c9df6d34840 Mon Sep 17 00:00:00 2001 From: Ali Ukani Date: Mon, 16 Feb 2015 00:47:48 -0500 Subject: [PATCH 077/314] Fix flake8 errors, prepare for Python 3 port Fixes flake8 warnings Reword docstring for find_exact Use range instead of xrange in preparation for porting to Python 3 --- mopidy/local/search.py | 9 +++++---- tests/local/test_json.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/mopidy/local/search.py b/mopidy/local/search.py index 9e56d73b..1f82366f 100644 --- a/mopidy/local/search.py +++ b/mopidy/local/search.py @@ -8,7 +8,7 @@ def find_exact(tracks, query=None, limit=100, offset=0, uris=None): Filter a list of tracks where ``field`` is ``values``. :param list tracks: a list of :class:`~mopidy.models.Track` - :param dict query: one or more queries to search for + :param dict query: one or more field/value pairs to search for :param int limit: maximum number of results to return :param int offset: offset into result set to use. :param uris: zero or more URI roots to limit the search to @@ -108,7 +108,7 @@ def find_exact(tracks, query=None, limit=100, offset=0, uris=None): # TODO: add local:search: return SearchResult( - uri='local:search', tracks=tracks[offset:offset+limit]) + uri='local:search', tracks=tracks[offset:offset + limit]) def search(tracks, query=None, limit=100, offset=0, uris=None): @@ -116,7 +116,7 @@ def search(tracks, query=None, limit=100, offset=0, uris=None): Filter a list of tracks where ``field`` is like ``values``. :param list tracks: a list of :class:`~mopidy.models.Track` - :param dict query: one or more queries to search for + :param dict query: one or more field/value pairs to search for :param int limit: maximum number of results to return :param int offset: offset into result set to use. :param uris: zero or more URI roots to limit the search to @@ -218,7 +218,8 @@ def search(tracks, query=None, limit=100, offset=0, uris=None): else: raise LookupError('Invalid lookup field: %s' % field) # TODO: add local:search: - return SearchResult(uri='local:search', tracks=tracks[offset:offset+limit]) + return SearchResult(uri='local:search', + tracks=tracks[offset:offset + limit]) def _validate_query(query): diff --git a/tests/local/test_json.py b/tests/local/test_json.py index 545833d5..6d57c4d0 100644 --- a/tests/local/test_json.py +++ b/tests/local/test_json.py @@ -59,7 +59,7 @@ class JsonLibraryTest(unittest.TestCase): self.library = json.JsonLibrary(self.config) def _create_tracks(self, count): - for i in xrange(count): + for i in range(count): self.library.add(Track(uri='local:track:%d' % i)) def test_search_should_default_limit_results(self): From f5e63c406389ee80e17d06e6fe1d7c32936ccda1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 16 Feb 2015 10:34:27 +0100 Subject: [PATCH 078/314] flake8: Fix warnings after flake8 upgrade --- docs/conf.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index be748381..fbfb11aa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -34,11 +34,11 @@ class Mock(object): elif name == 'get_user_config_dir': # glib.get_user_config_dir() return str - elif (name[0] == name[0].upper() + elif (name[0] == name[0].upper() and # gst.PadTemplate - and not name.startswith('PadTemplate') + not name.startswith('PadTemplate') and # dbus.String() - and not name == 'String'): + not name == 'String'): return type(name, (), {}) else: return Mock() From 305d88c9209cd07cf71164ce6b49a4b2d6a73fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakab=20Krist=C3=B3f?= Date: Tue, 17 Feb 2015 00:01:01 +0100 Subject: [PATCH 079/314] Update arch.rst yaourt only syncs AUR packages if the -a flag is given --- docs/installation/arch.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/arch.rst b/docs/installation/arch.rst index 3f85bf51..e58d6cf5 100644 --- a/docs/installation/arch.rst +++ b/docs/installation/arch.rst @@ -14,7 +14,7 @@ If you are running Arch Linux, you can install Mopidy using the To upgrade Mopidy to future releases, just upgrade your system using:: - yaourt -Syu + yaourt -Syua #. Optional: If you want to use any Mopidy extensions, like Spotify support or Last.fm scrobbling, AUR also has `packages for several Mopidy extensions From e4ba4b3e5f5691aa7eaef83a8cb9936f32771d9c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 18 Feb 2015 00:13:24 +0100 Subject: [PATCH 080/314] mpd: Support blacklisting MPD commands in the server. Default blacklist set to listall and listallinfo. This change has been done to avoid clients being able to call "bad" MPD commands which are often misused to try and keep a client db. Note that this change will break some MPD clients, but the blacklist can be controlled via config to allow opting out for now. --- docs/changelog.rst | 5 +++++ docs/ext/mpd.rst | 7 +++++++ mopidy/mpd/__init__.py | 1 + mopidy/mpd/dispatcher.py | 5 +++++ mopidy/mpd/exceptions.py | 9 +++++++++ mopidy/mpd/ext.conf | 1 + mopidy/utils/deprecation.py | 1 + tests/mpd/test_dispatcher.py | 17 +++++++++++------ 8 files changed, 40 insertions(+), 6 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 988a0fcd..f403b269 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -84,6 +84,11 @@ v0.20.0 (UNRELEASED) - Share a single mapping between names and URIs across all MPD sessions. (Fixes: :issue:`934`, PR: :issue:`968`) +- Add support for blacklisting MPD commands. This is used to prevent clients + from using `listall` and `listallinfo` which recursively lookup the entire + "database". If you insist on using a client that needs these commands change + :confval:`mpd/command_blacklist`. + **Audio** - Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. Pass a diff --git a/docs/ext/mpd.rst b/docs/ext/mpd.rst index ecfab949..4f85e88f 100644 --- a/docs/ext/mpd.rst +++ b/docs/ext/mpd.rst @@ -99,3 +99,10 @@ See :ref:`config` for general help on configuring Mopidy. ``$hostname`` and ``$port`` can be used in the name. Set to an empty string to disable Zeroconf for MPD. + +.. confval:: mpd/command_blacklist + + List of MPD commands which are disabled by the server. By default this + setting blacklists `listall` and `listallinfo`. These commands don't fit + well with many of Mopidy's backends and are better left disabled unless + you know what you are doing. diff --git a/mopidy/mpd/__init__.py b/mopidy/mpd/__init__.py index 05c83baa..b2438b07 100644 --- a/mopidy/mpd/__init__.py +++ b/mopidy/mpd/__init__.py @@ -24,6 +24,7 @@ class Extension(ext.Extension): schema['max_connections'] = config.Integer(minimum=1) schema['connection_timeout'] = config.Integer(minimum=1) schema['zeroconf'] = config.String(optional=True) + schema['command_blacklist'] = config.List(optional=True) return schema def validate_environment(self): diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index b1b2db77..eece86d9 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -163,6 +163,11 @@ class MpdDispatcher(object): def _call_handler(self, request): tokens = tokenize.split(request) + # TODO: check that blacklist items are valid commands? + blacklist = self.config['mpd'].get('command_blacklist', []) + if tokens and tokens[0] in blacklist: + logger.warning('Client sent us blacklisted command: %s', tokens[0]) + raise exceptions.MpdDisabled(command=tokens[0]) try: return protocol.commands.call(tokens, context=self.context) except exceptions.MpdAckError as exc: diff --git a/mopidy/mpd/exceptions.py b/mopidy/mpd/exceptions.py index e7ab0068..f62b61da 100644 --- a/mopidy/mpd/exceptions.py +++ b/mopidy/mpd/exceptions.py @@ -87,3 +87,12 @@ class MpdNotImplemented(MpdAckError): def __init__(self, *args, **kwargs): super(MpdNotImplemented, self).__init__(*args, **kwargs) self.message = 'Not implemented' + + +class MpdDisabled(MpdAckError): + error_code = 0 + + def __init__(self, *args, **kwargs): + super(MpdDisabled, self).__init__(*args, **kwargs) + assert self.command is not None, 'command must be given explicitly' + self.message = '"%s" has been disabled in the server' % self.command diff --git a/mopidy/mpd/ext.conf b/mopidy/mpd/ext.conf index c62c37ef..fe9a0494 100644 --- a/mopidy/mpd/ext.conf +++ b/mopidy/mpd/ext.conf @@ -6,3 +6,4 @@ password = max_connections = 20 connection_timeout = 60 zeroconf = Mopidy MPD server on $hostname +command_blacklist = listall,listallinfo diff --git a/mopidy/utils/deprecation.py b/mopidy/utils/deprecation.py index 1b744702..06b0fc7c 100644 --- a/mopidy/utils/deprecation.py +++ b/mopidy/utils/deprecation.py @@ -5,6 +5,7 @@ import warnings def _is_pykka_proxy_creation(): + return False stack = inspect.stack() try: calling_frame = stack[3] diff --git a/tests/mpd/test_dispatcher.py b/tests/mpd/test_dispatcher.py index 63981668..d6b11e43 100644 --- a/tests/mpd/test_dispatcher.py +++ b/tests/mpd/test_dispatcher.py @@ -16,6 +16,7 @@ class MpdDispatcherTest(unittest.TestCase): config = { 'mpd': { 'password': None, + 'command_blacklist': ['disabled'], } } self.backend = dummy_backend.create_proxy() @@ -26,14 +27,18 @@ class MpdDispatcherTest(unittest.TestCase): pykka.ActorRegistry.stop_all() def test_call_handler_for_unknown_command_raises_exception(self): - try: + with self.assertRaises(MpdAckError) as cm: self.dispatcher._call_handler('an_unknown_command with args') - self.fail('Should raise exception') - except MpdAckError as e: - self.assertEqual( - e.get_mpd_ack(), - 'ACK [5@0] {} unknown command "an_unknown_command"') + + self.assertEqual( + cm.exception.get_mpd_ack(), + 'ACK [5@0] {} unknown command "an_unknown_command"') def test_handling_unknown_request_yields_error(self): result = self.dispatcher.handle_request('an unhandled request') self.assertEqual(result[0], 'ACK [5@0] {} unknown command "an"') + + def test_handling_blacklisted_command(self): + result = self.dispatcher.handle_request('disabled') + self.assertEqual(result[0], 'ACK [0@0] {disabled} "disabled" has been ' + 'disabled in the server') From 52814715b453c1845b71bb52ac9bebcf18f2146e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 18 Feb 2015 20:57:22 +0100 Subject: [PATCH 081/314] mpd: Fix review comments for commands blacklist --- docs/changelog.rst | 2 +- docs/ext/mpd.rst | 4 ++-- mopidy/mpd/exceptions.py | 1 + mopidy/utils/deprecation.py | 1 - 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f403b269..e1082b09 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -85,7 +85,7 @@ v0.20.0 (UNRELEASED) :issue:`934`, PR: :issue:`968`) - Add support for blacklisting MPD commands. This is used to prevent clients - from using `listall` and `listallinfo` which recursively lookup the entire + from using ``listall`` and ``listallinfo`` which recursively lookup the entire "database". If you insist on using a client that needs these commands change :confval:`mpd/command_blacklist`. diff --git a/docs/ext/mpd.rst b/docs/ext/mpd.rst index 4f85e88f..b02226a2 100644 --- a/docs/ext/mpd.rst +++ b/docs/ext/mpd.rst @@ -103,6 +103,6 @@ See :ref:`config` for general help on configuring Mopidy. .. confval:: mpd/command_blacklist List of MPD commands which are disabled by the server. By default this - setting blacklists `listall` and `listallinfo`. These commands don't fit - well with many of Mopidy's backends and are better left disabled unless + setting blacklists ``listall`` and ``listallinfo``. These commands don't + fit well with many of Mopidy's backends and are better left disabled unless you know what you are doing. diff --git a/mopidy/mpd/exceptions.py b/mopidy/mpd/exceptions.py index f62b61da..62e16ec3 100644 --- a/mopidy/mpd/exceptions.py +++ b/mopidy/mpd/exceptions.py @@ -90,6 +90,7 @@ class MpdNotImplemented(MpdAckError): class MpdDisabled(MpdAckError): + # NOTE: this is a custom error for mopidy that does not exists in MPD. error_code = 0 def __init__(self, *args, **kwargs): diff --git a/mopidy/utils/deprecation.py b/mopidy/utils/deprecation.py index 06b0fc7c..1b744702 100644 --- a/mopidy/utils/deprecation.py +++ b/mopidy/utils/deprecation.py @@ -5,7 +5,6 @@ import warnings def _is_pykka_proxy_creation(): - return False stack = inspect.stack() try: calling_frame = stack[3] From 88c978bdca70d6e20f27ff515a6b673bef4a3c4c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 18 Feb 2015 21:13:25 +0100 Subject: [PATCH 082/314] backend: Add a default get_images impl. --- mopidy/backend.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index 3dc3a28c..70591b3e 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -2,7 +2,7 @@ from __future__ import absolute_import, unicode_literals import copy -from mopidy import listener +from mopidy import listener, models class Backend(object): @@ -97,8 +97,19 @@ class LibraryProvider(object): See :meth:`mopidy.core.LibraryController.get_images`. *MAY be implemented by subclass.* + + Default implementation will simply call lookup and try and use the + album art for any tracks returned. Most extensions should replace this + with something smarter or simply return an empty dictionary. """ - return {} + result = {} + for uri in uris: + for track in self.lookup(uri): + if track.album and track.album.images: + for image_uri in track.album.images: + image = models.Image(uri=image_uri) + result.setdefault(uri, []).append(image) + return result # TODO: replace with search(query, exact=True, ...) def find_exact(self, query=None, uris=None): From 19b7daed325980cdfbd5e2cbf3a0b4946a69081c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 18 Feb 2015 21:14:24 +0100 Subject: [PATCH 083/314] mpd: Fix typos in previous commit. --- mopidy/mpd/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/mpd/exceptions.py b/mopidy/mpd/exceptions.py index 62e16ec3..6fc925a3 100644 --- a/mopidy/mpd/exceptions.py +++ b/mopidy/mpd/exceptions.py @@ -90,7 +90,7 @@ class MpdNotImplemented(MpdAckError): class MpdDisabled(MpdAckError): - # NOTE: this is a custom error for mopidy that does not exists in MPD. + # NOTE: This is a custom error for Mopidy that does not exist in MPD. error_code = 0 def __init__(self, *args, **kwargs): From 2ff2a3719e3d9b356e397f6611b07a21a2fea52e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 18 Feb 2015 21:55:39 +0100 Subject: [PATCH 084/314] backend: Add test for get_images fallback --- tests/backend/test_backend.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/backend/test_backend.py diff --git a/tests/backend/test_backend.py b/tests/backend/test_backend.py new file mode 100644 index 00000000..7c939132 --- /dev/null +++ b/tests/backend/test_backend.py @@ -0,0 +1,19 @@ +from __future__ import absolute_import, unicode_literals + +import unittest + +from mopidy import models + +from tests import dummy_backend + + +class LibraryTest(unittest.TestCase): + def test_default_get_images_impl_falls_back_to_album_image(self): + album = models.Album(images=['imageuri']) + track = models.Track(uri='trackuri', album=album) + + library = dummy_backend.DummyLibraryProvider(backend=None) + library.dummy_library.append(track) + + expected = {'trackuri': [models.Image(uri='imageuri')]} + self.assertEqual(library.get_images(['trackuri']), expected) From 7520b13aa11ee4a0b98a08d49fbce87f0ff35ba5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 18 Feb 2015 22:35:49 +0100 Subject: [PATCH 085/314] mpd: Update listall/info docs --- mopidy/mpd/protocol/music_db.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index c143df31..f08e51f2 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -359,6 +359,13 @@ def listall(context, uri=None): ``listall [URI]`` Lists all songs and directories in ``URI``. + + Do not use this command. Do not manage a client-side copy of MPD's + database. That is fragile and adds huge overhead. It will break with + large databases. Instead, query MPD whenever you need something. + + + .. warning:: This command is disabled by default in Mopidy installs. """ result = [] for path, track_ref in context.browse(uri, lookup=False): @@ -381,6 +388,13 @@ def listallinfo(context, uri=None): Same as ``listall``, except it also returns metadata info in the same format as ``lsinfo``. + + Do not use this command. Do not manage a client-side copy of MPD's + database. That is fragile and adds huge overhead. It will break with + large databases. Instead, query MPD whenever you need something. + + + .. warning:: This command is disabled by default in Mopidy installs. """ result = [] for path, lookup_future in context.browse(uri): From 2ae68d971a56b06c47e894d6496ae175f6a26282 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Wed, 25 Feb 2015 17:35:06 +0100 Subject: [PATCH 086/314] Fix #998: Remove event already sent by PlaylistsController. --- mopidy/local/playlists.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/mopidy/local/playlists.py b/mopidy/local/playlists.py index deeae2b5..2c53d91a 100644 --- a/mopidy/local/playlists.py +++ b/mopidy/local/playlists.py @@ -57,8 +57,6 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): playlists.append(playlist) self.playlists = playlists - # TODO: send what scheme we loaded them for? - backend.BackendListener.send('playlists_loaded') logger.info( 'Loaded %d local playlists from %s', From 96a3cb6ef5918ee8e6292f2bda4418c41f130c54 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Wed, 25 Feb 2015 17:48:41 +0100 Subject: [PATCH 087/314] Remove obsolete unit test. --- tests/local/test_events.py | 38 -------------------------------------- 1 file changed, 38 deletions(-) delete mode 100644 tests/local/test_events.py diff --git a/tests/local/test_events.py b/tests/local/test_events.py deleted file mode 100644 index 945347df..00000000 --- a/tests/local/test_events.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import unittest - -import mock - -import pykka - -from mopidy import backend, core -from mopidy.local import actor - -from tests import dummy_audio, path_to_data_dir - - -@mock.patch.object(backend.BackendListener, 'send') -class LocalBackendEventsTest(unittest.TestCase): - config = { - 'local': { - 'media_dir': path_to_data_dir(''), - 'data_dir': path_to_data_dir(''), - 'playlists_dir': b'', - 'library': 'json', - } - } - - def setUp(self): # noqa: N802 - self.audio = dummy_audio.create_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): # noqa: N802 - 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') From dd54fdb0868e9ae3ce47b436d4422132b85efb88 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Mon, 23 Feb 2015 08:20:16 +0100 Subject: [PATCH 088/314] Fix #937: Local playlists refactoring. --- mopidy/local/playlists.py | 107 +++++++++++++++++-------------- mopidy/local/translator.py | 16 ++++- tests/local/test_playlists.py | 114 ++++++++++++++++++++++------------ 3 files changed, 151 insertions(+), 86 deletions(-) diff --git a/mopidy/local/playlists.py b/mopidy/local/playlists.py index deeae2b5..10a97b39 100644 --- a/mopidy/local/playlists.py +++ b/mopidy/local/playlists.py @@ -3,15 +3,14 @@ from __future__ import absolute_import, division, unicode_literals import glob import logging import os -import shutil +import sys from mopidy import backend from mopidy.models import Playlist -from mopidy.utils import formatting, path +from .translator import local_playlist_uri_to_path, path_to_local_playlist_uri from .translator import parse_m3u - logger = logging.getLogger(__name__) @@ -23,18 +22,27 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): self.refresh() def create(self, name): - name = formatting.slugify(name) - uri = 'local:playlist:%s.m3u' % name - playlist = Playlist(uri=uri, name=name) - return self.save(playlist) + playlist = self._save_m3u(Playlist(name=name)) + old_playlist = self.lookup(playlist.uri) + if old_playlist is not None: + index = self._playlists.index(old_playlist) + self._playlists[index] = playlist + else: + self._playlists.append(playlist) + logger.info('Created playlist %s', playlist.uri) + return playlist def delete(self, uri): playlist = self.lookup(uri) if not playlist: + logger.warn('Trying to delete unknown playlist %s', uri) return - + path = local_playlist_uri_to_path(uri, self._playlists_dir) + if os.path.exists(path): + os.remove(path) + else: + logger.warn('Trying to delete missing playlist file %s', path) self._playlists.remove(playlist) - self._delete_m3u(playlist.uri) def lookup(self, uri): # TODO: store as {uri: playlist}? @@ -45,12 +53,14 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): def refresh(self): playlists = [] - for m3u in glob.glob(os.path.join(self._playlists_dir, '*.m3u')): - name = os.path.splitext(os.path.basename(m3u))[0] - uri = 'local:playlist:%s' % name + encoding = sys.getfilesystemencoding() + for path in glob.glob(os.path.join(self._playlists_dir, b'*.m3u')): + relpath = os.path.basename(path) + name = os.path.splitext(relpath)[0].decode(encoding) + uri = path_to_local_playlist_uri(relpath) tracks = [] - for track in parse_m3u(m3u, self._media_dir): + for track in parse_m3u(path, self._media_dir): tracks.append(track) playlist = Playlist(uri=uri, name=name, tracks=tracks) @@ -67,38 +77,53 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): def save(self, playlist): assert playlist.uri, 'Cannot save playlist without URI' - old_playlist = self.lookup(playlist.uri) + uri = playlist.uri + # TODO: require existing (created) playlist - currently, this + # is a *should* in https://docs.mopidy.com/en/latest/api/core/ + try: + index = self._playlists.index(self.lookup(uri)) + except ValueError: + logger.warn('Saving playlist with new URI %s', uri) + index = -1 - if old_playlist and playlist.name != old_playlist.name: - playlist = playlist.copy(name=formatting.slugify(playlist.name)) - playlist = self._rename_m3u(playlist) - - self._save_m3u(playlist) - - if old_playlist is not None: - index = self._playlists.index(old_playlist) + playlist = self._save_m3u(playlist) + if index >= 0 and uri != playlist.uri: + path = local_playlist_uri_to_path(uri, self._playlists_dir) + if os.path.exists(path): + os.remove(path) + else: + logger.warn('Trying to delete missing playlist file %s', path) + if index >= 0: self._playlists[index] = playlist else: self._playlists.append(playlist) - return playlist - def _m3u_uri_to_path(self, uri): - # TODO: create uri handling helpers for local uri types. - file_path = path.uri_to_path(uri).split(':', 1)[1] - file_path = os.path.join(self._playlists_dir, file_path) - path.check_file_path_is_inside_base_dir(file_path, self._playlists_dir) - return file_path - def _write_m3u_extinf(self, file_handle, track): title = track.name.encode('latin-1', 'replace') runtime = track.length // 1000 if track.length else -1 file_handle.write('#EXTINF:' + str(runtime) + ',' + title + '\n') - def _save_m3u(self, playlist): - file_path = self._m3u_uri_to_path(playlist.uri) + def _sanitize_m3u_name(self, name, encoding=sys.getfilesystemencoding()): + name = name.encode(encoding, errors='replace') + name = os.path.basename(name) + name = name.decode(encoding) + return name + + def _save_m3u(self, playlist, encoding=sys.getfilesystemencoding()): + if playlist.name: + name = self._sanitize_m3u_name(playlist.name, encoding) + uri = path_to_local_playlist_uri(name.encode(encoding) + b'.m3u') + path = local_playlist_uri_to_path(uri, self._playlists_dir) + elif playlist.uri: + uri = playlist.uri + path = local_playlist_uri_to_path(uri, self._playlists_dir) + name, _ = os.path.splitext(os.path.basename(path).decode(encoding)) + else: + raise ValueError('M3U playlist needs name or URI') extended = any(track.name for track in playlist.tracks) - with open(file_path, 'w') as file_handle: + + with open(path, 'w') as file_handle: if extended: file_handle.write('#EXTM3U\n') for track in playlist.tracks: @@ -106,17 +131,5 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): self._write_m3u_extinf(file_handle, track) file_handle.write(track.uri + '\n') - def _delete_m3u(self, uri): - file_path = self._m3u_uri_to_path(uri) - if os.path.exists(file_path): - os.remove(file_path) - - def _rename_m3u(self, playlist): - dst_name = formatting.slugify(playlist.name) - dst_uri = 'local:playlist:%s.m3u' % dst_name - - src_file_path = self._m3u_uri_to_path(playlist.uri) - dst_file_path = self._m3u_uri_to_path(dst_uri) - - shutil.move(src_file_path, dst_file_path) - return playlist.copy(uri=dst_uri) + # assert playlist name matches file name/uri + return playlist.copy(uri=uri, name=name) diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index ab9fc28f..d0c19c27 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -37,8 +37,15 @@ def local_track_uri_to_path(uri, media_dir): return os.path.join(media_dir, file_path) +def local_playlist_uri_to_path(uri, playlists_dir): + if not uri.startswith('local:playlist:'): + raise ValueError('Invalid URI %s' % uri) + file_path = uri_to_path(uri).split(b':', 1)[1] + return os.path.join(playlists_dir, file_path) + + def path_to_local_track_uri(relpath): - """Convert path releative to media_dir to local track URI.""" + """Convert path relative to media_dir to local track URI.""" if isinstance(relpath, compat.text_type): relpath = relpath.encode('utf-8') return b'local:track:%s' % urllib.quote(relpath) @@ -51,6 +58,13 @@ def path_to_local_directory_uri(relpath): return b'local:directory:%s' % urllib.quote(relpath) +def path_to_local_playlist_uri(relpath): + """Convert path relative to playlists_dir to local playlist URI.""" + if isinstance(relpath, compat.text_type): + relpath = relpath.encode('utf-8') + return b'local:playlist:%s' % urllib.quote(relpath) + + def m3u_extinf_to_track(line): """Convert extended M3U directive to track template.""" m = M3U_EXTINF_RE.match(line) diff --git a/tests/local/test_playlists.py b/tests/local/test_playlists.py index 3e9c280e..d52fed82 100644 --- a/tests/local/test_playlists.py +++ b/tests/local/test_playlists.py @@ -9,6 +9,7 @@ import pykka from mopidy import core from mopidy.local import actor +from mopidy.local.translator import local_playlist_uri_to_path from mopidy.models import Playlist, Track from tests import dummy_audio, path_to_data_dir @@ -41,49 +42,50 @@ class LocalPlaylistsProviderTest(unittest.TestCase): shutil.rmtree(self.playlists_dir) def test_created_playlist_is_persisted(self): - path = os.path.join(self.playlists_dir, 'test.m3u') + uri = 'local:playlist:test.m3u' + path = local_playlist_uri_to_path(uri, self.playlists_dir) self.assertFalse(os.path.exists(path)) - self.core.playlists.create('test') + playlist = self.core.playlists.create('test') + self.assertEqual('test', playlist.name) + self.assertEqual(uri, playlist.uri) self.assertTrue(os.path.exists(path)) - def test_create_slugifies_playlist_name(self): - path = os.path.join(self.playlists_dir, 'test-foo-bar.m3u') - self.assertFalse(os.path.exists(path)) - - playlist = self.core.playlists.create('test FOO baR') - self.assertEqual('test-foo-bar', playlist.name) - self.assertTrue(os.path.exists(path)) - - def test_create_slugifies_names_which_tries_to_change_directory(self): - path = os.path.join(self.playlists_dir, 'test-foo-bar.m3u') - self.assertFalse(os.path.exists(path)) - + def test_create_sanitizes_playlist_name(self): playlist = self.core.playlists.create('../../test FOO baR') - self.assertEqual('test-foo-bar', playlist.name) + self.assertEqual('test FOO baR', playlist.name) + path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir) + self.assertEqual(self.playlists_dir, os.path.dirname(path)) self.assertTrue(os.path.exists(path)) def test_saved_playlist_is_persisted(self): - path1 = os.path.join(self.playlists_dir, 'test1.m3u') - path2 = os.path.join(self.playlists_dir, 'test2-foo-bar.m3u') + uri1 = 'local:playlist:test1.m3u' + uri2 = 'local:playlist:test2.m3u' + + path1 = local_playlist_uri_to_path(uri1, self.playlists_dir) + path2 = local_playlist_uri_to_path(uri2, self.playlists_dir) playlist = self.core.playlists.create('test1') - + self.assertEqual('test1', playlist.name) + self.assertEqual(uri1, playlist.uri) self.assertTrue(os.path.exists(path1)) self.assertFalse(os.path.exists(path2)) - playlist = playlist.copy(name='test2 FOO baR') - playlist = self.core.playlists.save(playlist) - - self.assertEqual('test2-foo-bar', playlist.name) + playlist = self.core.playlists.save(playlist.copy(name='test2')) + self.assertEqual('test2', playlist.name) + self.assertEqual(uri2, playlist.uri) self.assertFalse(os.path.exists(path1)) self.assertTrue(os.path.exists(path2)) def test_deleted_playlist_is_removed(self): - path = os.path.join(self.playlists_dir, 'test.m3u') + uri = 'local:playlist:test.m3u' + path = local_playlist_uri_to_path(uri, self.playlists_dir) + self.assertFalse(os.path.exists(path)) playlist = self.core.playlists.create('test') + self.assertEqual('test', playlist.name) + self.assertEqual(uri, playlist.uri) self.assertTrue(os.path.exists(path)) self.core.playlists.delete(playlist.uri) @@ -92,24 +94,22 @@ class LocalPlaylistsProviderTest(unittest.TestCase): def test_playlist_contents_is_written_to_disk(self): track = Track(uri=generate_song(1)) playlist = self.core.playlists.create('test') - playlist_path = os.path.join(self.playlists_dir, 'test.m3u') - playlist = playlist.copy(tracks=[track]) - playlist = self.core.playlists.save(playlist) + playlist = self.core.playlists.save(playlist.copy(tracks=[track])) + path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir) - with open(playlist_path) as playlist_file: - contents = playlist_file.read() + with open(path) as f: + contents = f.read() self.assertEqual(track.uri, contents.strip()) def test_extended_playlist_contents_is_written_to_disk(self): track = Track(uri=generate_song(1), name='Test', length=60000) playlist = self.core.playlists.create('test') - playlist_path = os.path.join(self.playlists_dir, 'test.m3u') - playlist = playlist.copy(tracks=[track]) - playlist = self.core.playlists.save(playlist) + playlist = self.core.playlists.save(playlist.copy(tracks=[track])) + path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir) - with open(playlist_path) as playlist_file: - contents = playlist_file.read().splitlines() + with open(path) as f: + contents = f.read().splitlines() self.assertEqual(contents, ['#EXTM3U', '#EXTINF:60,Test', track.uri]) @@ -123,7 +123,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.assert_(backend.playlists.playlists) self.assertEqual( - 'local:playlist:test', backend.playlists.playlists[0].uri) + playlist.uri, backend.playlists.playlists[0].uri) self.assertEqual( playlist.name, backend.playlists.playlists[0].name) self.assertEqual( @@ -154,7 +154,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.assert_(not self.core.playlists.playlists) def test_delete_non_existant_playlist(self): - self.core.playlists.delete('file:///unknown/playlist') + self.core.playlists.delete('local:playlist:unknown') def test_delete_playlist_removes_it_from_the_collection(self): playlist = self.core.playlists.create('test') @@ -164,6 +164,19 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.assertNotIn(playlist, self.core.playlists.playlists) + def test_delete_playlist_without_file(self): + playlist = self.core.playlists.create('test') + self.assertIn(playlist, self.core.playlists.playlists) + + path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir) + self.assertTrue(os.path.exists(path)) + + os.remove(path) + self.assertFalse(os.path.exists(path)) + + 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()) @@ -201,9 +214,13 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.assertEqual(original_playlist, looked_up_playlist) - @unittest.SkipTest def test_refresh(self): - pass + playlist = self.core.playlists.create('test') + self.assertIn(playlist, self.core.playlists.playlists) + + self.core.playlists.refresh() + + self.assertIn(playlist, self.core.playlists.playlists) def test_save_replaces_existing_playlist_with_updated_playlist(self): playlist1 = self.core.playlists.create('test1') @@ -214,6 +231,27 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.assertNotIn(playlist1, self.core.playlists.playlists) self.assertIn(playlist2, self.core.playlists.playlists) + def test_create_replaces_existing_playlist_with_updated_playlist(self): + track = Track(uri=generate_song(1)) + playlist1 = self.core.playlists.create('test') + playlist1 = self.core.playlists.save(playlist1.copy(tracks=[track])) + self.assertIn(playlist1, self.core.playlists.playlists) + + playlist2 = self.core.playlists.create('test') + self.assertEqual(playlist1.uri, playlist2.uri) + self.assertNotIn(playlist1, self.core.playlists.playlists) + self.assertIn(playlist2, self.core.playlists.playlists) + + def test_save_playlist_with_new_uri(self): + # you *should* not do this + uri = 'local:playlist:test.m3u' + playlist = self.core.playlists.save(Playlist(uri=uri)) + self.assertIn(playlist, self.core.playlists.playlists) + self.assertEqual(uri, playlist.uri) + self.assertEqual('test', playlist.name) + path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir) + self.assertTrue(os.path.exists(path)) + def test_playlist_with_unknown_track(self): track = Track(uri='file:///dev/null') playlist = self.core.playlists.create('test') @@ -224,7 +262,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.assert_(backend.playlists.playlists) self.assertEqual( - 'local:playlist:test', backend.playlists.playlists[0].uri) + 'local:playlist:test.m3u', backend.playlists.playlists[0].uri) self.assertEqual( playlist.name, backend.playlists.playlists[0].name) self.assertEqual( From 0ea39694272141158c1ae492b968f6cd69cabbff Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 25 Feb 2015 21:02:57 +0100 Subject: [PATCH 089/314] config: Debug log ignored sections (fixes: #694) --- mopidy/config/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 885ea3a6..24b4f279 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -175,6 +175,7 @@ def _validate(raw_config, schemas): # Get validated config config = {} errors = {} + sections = set(raw_config) for schema in schemas: values = raw_config.get(schema.name, {}) result, error = schema.deserialize(values) @@ -182,6 +183,12 @@ def _validate(raw_config, schemas): errors[schema.name] = error if result: config[schema.name] = result + if schema.name in sections: + sections.remove(schema.name) + + for section in sections: + logger.debug('Ignoring unknown config section: %s', section) + return config, errors From b11d89d72fc3f69028d3a1499753d1853f4f8660 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 25 Feb 2015 21:28:05 +0100 Subject: [PATCH 090/314] config: Convert the loglevel schema to a generic map schema --- mopidy/config/__init__.py | 2 +- mopidy/config/schemas.py | 19 +++++++++---------- tests/config/test_schemas.py | 4 ++-- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 885ea3a6..a6cb9d94 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -22,7 +22,7 @@ _logging_schema['debug_format'] = String() _logging_schema['debug_file'] = Path() _logging_schema['config_file'] = Path(optional=True) -_loglevels_schema = LogLevelConfigSchema('loglevels') +_loglevels_schema = MapConfigSchema('loglevels', LogLevel()) _audio_schema = ConfigSchema('audio') _audio_schema['mixer'] = String() diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index 56826a53..f1b3a8c1 100644 --- a/mopidy/config/schemas.py +++ b/mopidy/config/schemas.py @@ -94,17 +94,16 @@ class ConfigSchema(collections.OrderedDict): return result -class LogLevelConfigSchema(object): - """Special cased schema for handling a config section with loglevels. +class MapConfigSchema(object): + """Special cased schema for handling mulitple keys with the same type. - Expects the config keys to be logger names and the values to be log levels - as understood by the :class:`LogLevel` config value. Does not sub-class - :class:`ConfigSchema`, but implements the same serialize/deserialize - interface. + Does not sub-class :class:`ConfigSchema`, but implements the same + serialize/deserialize interface. """ - def __init__(self, name): + + def __init__(self, name, value_type): self.name = name - self._config_value = types.LogLevel() + self._value_type = value_type def deserialize(self, values): errors = {} @@ -112,7 +111,7 @@ class LogLevelConfigSchema(object): for key, value in values.items(): try: - result[key] = self._config_value.deserialize(value) + result[key] = self._value_type.deserialize(value) except ValueError as e: # deserialization failed result[key] = None errors[key] = str(e) @@ -121,5 +120,5 @@ class LogLevelConfigSchema(object): def serialize(self, values, display=False): result = collections.OrderedDict() for key in sorted(values.keys()): - result[key] = self._config_value.serialize(values[key], display) + result[key] = self._value_type.serialize(values[key], display) return result diff --git a/tests/config/test_schemas.py b/tests/config/test_schemas.py index 8412b899..502bf61c 100644 --- a/tests/config/test_schemas.py +++ b/tests/config/test_schemas.py @@ -86,9 +86,9 @@ class ConfigSchemaTest(unittest.TestCase): self.assertNotIn('foo', errors) -class LogLevelConfigSchemaTest(unittest.TestCase): +class MapConfigSchemaTest(unittest.TestCase): def test_conversion(self): - schema = schemas.LogLevelConfigSchema('test') + schema = schemas.MapConfigSchema('test', types.LogLevel()) result, errors = schema.deserialize( {'foo.bar': 'DEBUG', 'baz': 'INFO'}) From 5c833e106bf741825e4a6b0ed9922ce6b7215898 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 25 Feb 2015 22:16:30 +0100 Subject: [PATCH 091/314] logging: Add support for per logger colors (fixes: #808) --- docs/changelog.rst | 2 ++ docs/config.rst | 8 ++++++ mopidy/config/__init__.py | 4 ++- mopidy/config/types.py | 13 ++++++++- mopidy/utils/log.py | 55 +++++++++++++++++++++------------------ 5 files changed, 55 insertions(+), 27 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e1082b09..7b3e97b0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -53,6 +53,8 @@ v0.20.0 (UNRELEASED) - Add custom log level ``TRACE`` (numerical level 5), which can be used by Mopidy and extensions to log at an even more detailed level than ``DEBUG``. +- Add support for per logger color overrides. (Fixes: :issue:`808`) + **Local backend** - Add cover URL to all scanned files with MusicBrainz album IDs. (Fixes: diff --git a/docs/config.rst b/docs/config.rst index 03bb83ac..82556dc8 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -128,6 +128,14 @@ Logging configuration The ``loglevels`` config section can be used to change the log level for specific parts of Mopidy during development or debugging. Each key in the config section should match the name of a logger. The value is the log + level to use for that logger, one of ``black``, ``red``, ``green``, + ``yellow``, ``blue``, ``magenta``, ``cyan`` or ``white``. + +.. confval:: logcolors/* + + The ``logcolors`` config section can be used to change the log color for + specific parts of Mopidy during development or debugging. Each key in the + config section should match the name of a logger. The value is the log level to use for that logger, one of ``debug``, ``info``, ``warning``, ``error``, or ``critical``. diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index a6cb9d94..7c4f8755 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -23,6 +23,7 @@ _logging_schema['debug_file'] = Path() _logging_schema['config_file'] = Path(optional=True) _loglevels_schema = MapConfigSchema('loglevels', LogLevel()) +_logcolors_schema = MapConfigSchema('logcolors', LogColor()) _audio_schema = ConfigSchema('audio') _audio_schema['mixer'] = String() @@ -42,7 +43,8 @@ _proxy_schema['password'] = Secret(optional=True) # NOTE: if multiple outputs ever comes something like LogLevelConfigSchema # _outputs_schema = config.AudioOutputConfigSchema() -_schemas = [_logging_schema, _loglevels_schema, _audio_schema, _proxy_schema] +_schemas = [_logging_schema, _loglevels_schema, _logcolors_schema, + _audio_schema, _proxy_schema] _INITIAL_HELP = """ # For further information about options in this file see: diff --git a/mopidy/config/types.py b/mopidy/config/types.py index 785ec55a..d074458b 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -6,7 +6,7 @@ import socket from mopidy import compat from mopidy.config import validators -from mopidy.utils import path +from mopidy.utils import log, path def decode(value): @@ -197,6 +197,17 @@ class List(ConfigValue): return b'\n ' + b'\n '.join(encode(v) for v in value if v) +class LogColor(ConfigValue): + def deserialize(self, value): + validators.validate_choice(value.lower(), log.COLORS) + return value.lower() + + def serialize(self, value, display=False): + if value.lower() in log.COLORS: + return value.lower() + return b'' + + class LogLevel(ConfigValue): """Log level value. diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 3c7ee599..6343a866 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -82,7 +82,7 @@ def setup_console_logging(config, verbosity_level): formatter = logging.Formatter(log_format) if config['logging']['color']: - handler = ColorizingStreamHandler() + handler = ColorizingStreamHandler(config.get('logcolors', {})) else: handler = logging.StreamHandler() handler.addFilter(verbosity_filter) @@ -117,6 +117,11 @@ class VerbosityFilter(logging.Filter): return record.levelno >= required_log_level +#: Available log colors. +COLORS = [b'black', b'red', b'green', b'yellow', b'blue', b'magenta', b'cyan', + b'white'] + + class ColorizingStreamHandler(logging.StreamHandler): """ Stream handler which colorizes the log using ANSI escape sequences. @@ -130,17 +135,6 @@ class ColorizingStreamHandler(logging.StreamHandler): Licensed under the new BSD license. """ - color_map = { - 'black': 0, - 'red': 1, - 'green': 2, - 'yellow': 3, - 'blue': 4, - 'magenta': 5, - 'cyan': 6, - 'white': 7, - } - # Map logging levels to (background, foreground, bold/intense) level_map = { TRACE_LOG_LEVEL: (None, 'blue', False), @@ -150,11 +144,18 @@ class ColorizingStreamHandler(logging.StreamHandler): logging.ERROR: (None, 'red', False), logging.CRITICAL: ('red', 'white', True), } + # Map logger name to foreground colors + logger_map = {} + csi = '\x1b[' reset = '\x1b[0m' is_windows = platform.system() == 'Windows' + def __init__(self, logger_colors): + super(ColorizingStreamHandler, self).__init__() + self.logger_map = logger_colors + @property def is_tty(self): isatty = getattr(self.stream, 'isatty', None) @@ -173,19 +174,23 @@ class ColorizingStreamHandler(logging.StreamHandler): message = logging.StreamHandler.format(self, record) if not self.is_tty or self.is_windows: return message - return self.colorize(message, record) - - def colorize(self, message, record): + for name, color in self.logger_map.iteritems(): + if record.name.startswith(name): + return self.colorize(message, fg=color) if record.levelno in self.level_map: bg, fg, bold = self.level_map[record.levelno] - params = [] - if bg in self.color_map: - params.append(str(self.color_map[bg] + 40)) - if fg in self.color_map: - params.append(str(self.color_map[fg] + 30)) - if bold: - params.append('1') - if params: - message = ''.join(( - self.csi, ';'.join(params), 'm', message, self.reset)) + return self.colorize(message, bg=bg, fg=fg, bold=bold) + return message + + def colorize(self, message, bg=None, fg=None, bold=False): + params = [] + if bg in COLORS: + params.append(str(COLORS.index(bg) + 40)) + if fg in COLORS: + params.append(str(COLORS.index(fg) + 30)) + if bold: + params.append('1') + if params: + message = ''.join(( + self.csi, ';'.join(params), 'm', message, self.reset)) return message From 3b41b268809a8e92ee38e2077eb91f43458ab4be Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 25 Feb 2015 22:57:49 +0100 Subject: [PATCH 092/314] config: Fix review comments --- docs/config.rst | 10 +++++----- mopidy/config/schemas.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index 82556dc8..69945ab8 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -128,16 +128,16 @@ Logging configuration The ``loglevels`` config section can be used to change the log level for specific parts of Mopidy during development or debugging. Each key in the config section should match the name of a logger. The value is the log - level to use for that logger, one of ``black``, ``red``, ``green``, - ``yellow``, ``blue``, ``magenta``, ``cyan`` or ``white``. + level to use for that logger, one of ``debug``, ``info``, ``warning``, + ``error``, or ``critical``. .. confval:: logcolors/* The ``logcolors`` config section can be used to change the log color for specific parts of Mopidy during development or debugging. Each key in the - config section should match the name of a logger. The value is the log - level to use for that logger, one of ``debug``, ``info``, ``warning``, - ``error``, or ``critical``. + config section should match the name of a logger. The value is the color + to use for that logger, one of ``black``, ``red``, ``green``, ``yellow``, + ``blue``, ``magenta``, ``cyan`` or ``white``. .. _the Python logging docs: http://docs.python.org/2/library/logging.config.html diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index f1b3a8c1..2b055663 100644 --- a/mopidy/config/schemas.py +++ b/mopidy/config/schemas.py @@ -95,7 +95,7 @@ class ConfigSchema(collections.OrderedDict): class MapConfigSchema(object): - """Special cased schema for handling mulitple keys with the same type. + """Schema for handling multiple unknown keys with the same type. Does not sub-class :class:`ConfigSchema`, but implements the same serialize/deserialize interface. From 57012670b71ea73c26177ba13a94ba004d82baaa Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 25 Feb 2015 22:58:46 +0100 Subject: [PATCH 093/314] config: Fixing review comments --- mopidy/config/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 24b4f279..434831e4 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -177,14 +177,13 @@ def _validate(raw_config, schemas): errors = {} sections = set(raw_config) for schema in schemas: + sections.discard(schema.name) values = raw_config.get(schema.name, {}) result, error = schema.deserialize(values) if error: errors[schema.name] = error if result: config[schema.name] = result - if schema.name in sections: - sections.remove(schema.name) for section in sections: logger.debug('Ignoring unknown config section: %s', section) From 4a3dfdd415a3c262d170ab97e2da3a7314bd08b1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 25 Feb 2015 23:28:56 +0100 Subject: [PATCH 094/314] docs: Update changelog --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index e1082b09..43d16723 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -48,6 +48,8 @@ v0.20.0 (UNRELEASED) This can be used to show absolutely all log records, including those at custom levels below ``DEBUG``. +- Add debug logging of unknown sections. (Fixes: :issue:`694`) + **Logging** - Add custom log level ``TRACE`` (numerical level 5), which can be used by From 961aafff45a1b94990f9dcb4bae45196fa1a0b71 Mon Sep 17 00:00:00 2001 From: ronaldz Date: Wed, 25 Feb 2015 21:19:47 -0500 Subject: [PATCH 095/314] Maximum was miss-spelled in the local Scan command's help text --- mopidy/local/commands.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index a9920ec8..ba34b22b 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -61,8 +61,7 @@ class ScanCommand(commands.Command): super(ScanCommand, self).__init__() self.add_argument('--limit', action='store', type=int, dest='limit', default=None, - help='Maxmimum number of tracks to scan') - + help='Maximum number of tracks to scan') def run(self, args, config): media_dir = config['local']['media_dir'] scan_timeout = config['local']['scan_timeout'] @@ -121,7 +120,9 @@ class ScanCommand(commands.Command): logger.info('Scanning...') uris_to_update = sorted(uris_to_update, key=lambda v: v.lower()) + print("Before: ", uris_to_update) uris_to_update = uris_to_update[:args.limit] + print("After: ", uris_to_update) scanner = scan.Scanner(scan_timeout) progress = _Progress(flush_threshold, len(uris_to_update)) From 87ea3c974557c484b4831084167f2c00c45e8a13 Mon Sep 17 00:00:00 2001 From: ronaldz Date: Wed, 25 Feb 2015 21:36:02 -0500 Subject: [PATCH 096/314] Added a --force argument. Related to issue #910 --- mopidy/local/commands.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index ba34b22b..80ce5c1d 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -62,6 +62,9 @@ class ScanCommand(commands.Command): self.add_argument('--limit', action='store', type=int, dest='limit', default=None, help='Maximum number of tracks to scan') + self.add_argument('--force', + action='store_true', dest='force', default=False, + help='Force rescan of all media files') def run(self, args, config): media_dir = config['local']['media_dir'] scan_timeout = config['local']['scan_timeout'] @@ -95,8 +98,8 @@ class ScanCommand(commands.Command): mtime = file_mtimes.get(abspath) if mtime is None: logger.debug('Missing file %s', track.uri) - uris_to_remove.add(track.uri) - elif mtime > track.last_modified: + uris_to_remove.add(track.uri) + elif mtime > track.last_modified or args.force: uris_to_update.add(track.uri) uris_in_library.add(track.uri) @@ -120,9 +123,7 @@ class ScanCommand(commands.Command): logger.info('Scanning...') uris_to_update = sorted(uris_to_update, key=lambda v: v.lower()) - print("Before: ", uris_to_update) uris_to_update = uris_to_update[:args.limit] - print("After: ", uris_to_update) scanner = scan.Scanner(scan_timeout) progress = _Progress(flush_threshold, len(uris_to_update)) From 301f7320471da715853505baf2d7d89a204b0561 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Fri, 27 Feb 2015 22:22:28 +0100 Subject: [PATCH 097/314] Improve default get_images() implementation with album/artist URIs. --- mopidy/backend.py | 6 +++--- tests/backend/test_backend.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index 70591b3e..fca01eb0 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -104,11 +104,11 @@ class LibraryProvider(object): """ result = {} for uri in uris: + image_uris = set() for track in self.lookup(uri): if track.album and track.album.images: - for image_uri in track.album.images: - image = models.Image(uri=image_uri) - result.setdefault(uri, []).append(image) + image_uris.update(track.album.images) + result[uri] = list(map(lambda u: models.Image(uri=u), image_uris)) return result # TODO: replace with search(query, exact=True, ...) diff --git a/tests/backend/test_backend.py b/tests/backend/test_backend.py index 7c939132..7c6cc82b 100644 --- a/tests/backend/test_backend.py +++ b/tests/backend/test_backend.py @@ -17,3 +17,14 @@ class LibraryTest(unittest.TestCase): expected = {'trackuri': [models.Image(uri='imageuri')]} self.assertEqual(library.get_images(['trackuri']), expected) + + def test_default_get_images_impl_no_album_image(self): + # default implementation now returns an empty list if no + # images are found, though it's not required to + track = models.Track(uri='trackuri') + + library = dummy_backend.DummyLibraryProvider(backend=None) + library.dummy_library.append(track) + + expected = {'trackuri': []} + self.assertEqual(library.get_images(['trackuri']), expected) From f65195a6769b3898e3bad81d16021e5920ab14be Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Fri, 27 Feb 2015 22:39:25 +0100 Subject: [PATCH 098/314] More pythonic implementation. --- mopidy/backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index fca01eb0..c713d083 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -108,7 +108,7 @@ class LibraryProvider(object): for track in self.lookup(uri): if track.album and track.album.images: image_uris.update(track.album.images) - result[uri] = list(map(lambda u: models.Image(uri=u), image_uris)) + result[uri] = [models.Image(uri=u) for u in image_uris] return result # TODO: replace with search(query, exact=True, ...) From fbd534efbfe8d4f1cb7f446e0ac6b3e62a045250 Mon Sep 17 00:00:00 2001 From: Lasse Bigum Date: Sun, 1 Mar 2015 15:19:12 +0100 Subject: [PATCH 099/314] Don't change to playing state when seeking in paused state Do not switch state from paused to playing when seeking --- docs/changelog.rst | 3 +++ mopidy/core/playback.py | 2 -- tests/core/test_playback.py | 33 +++++++++++++++++++++++++++++++++ tests/local/test_playback.py | 8 +------- 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e6e3bcf4..b316df49 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -33,6 +33,9 @@ v0.20.0 (UNRELEASED) - Add :meth:`mopidy.core.LibraryController.get_images` for looking up images for any URI backends know about. (Fixes :issue:`973`) +- When seeking in paused state, do not change to playing state. (Fixed + :issue:`939`) + **Commands** - Make the ``mopidy`` command print a friendly error message if the diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index d4cdce0d..0d604d61 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -371,8 +371,6 @@ class PlaybackController(object): if self.get_state() == PlaybackState.STOPPED: self.play() - elif self.get_state() == PlaybackState.PAUSED: - self.resume() if time_position < 0: time_position = 0 diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 40741e23..11d63e04 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -85,6 +85,25 @@ class CorePlaybackTest(unittest.TestCase): 'track_playback_started', tl_track=self.tl_tracks[0]), ]) + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_play_when_paused_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.pause() + listener_mock.reset_mock() + + self.core.playback.play(self.tl_tracks[1]) + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='paused', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=self.tl_tracks[1]), + ]) + @mock.patch( 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) def test_play_when_playing_emits_events(self, listener_mock): @@ -389,6 +408,20 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback1.seek.called) self.assertFalse(self.playback2.seek.called) + def test_seek_play_stay_playing(self): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.state = core.PlaybackState.PLAYING + self.core.playback.seek(1000) + + self.assertEqual(self.core.playback.state, core.PlaybackState.PLAYING) + + def test_seek_paused_stay_paused(self): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.state = core.PlaybackState.PAUSED + self.core.playback.seek(1000) + + self.assertEqual(self.core.playback.state, core.PlaybackState.PAUSED) + @mock.patch( 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) def test_seek_emits_seeked_event(self, listener_mock): diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index 5f1ff525..3ccd8d8f 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -798,6 +798,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.playback.pause() result = self.playback.seek(self.tracks[0].length - 1000) self.assert_(result, 'Seek return value was %s' % result) + self.assertEqual(self.playback.state, PlaybackState.PAUSED) @populate_tracklist def test_seek_when_paused_updates_position(self): @@ -808,13 +809,6 @@ class LocalPlaybackProviderTest(unittest.TestCase): 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): From b2976dccb6a0368f9c682ed6d0439edf68e35883 Mon Sep 17 00:00:00 2001 From: ronaldz Date: Sun, 1 Mar 2015 14:40:08 -0500 Subject: [PATCH 100/314] Trailing white space and expected blank line fix --- mopidy/local/commands.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 80ce5c1d..79c1c505 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -62,9 +62,10 @@ class ScanCommand(commands.Command): self.add_argument('--limit', action='store', type=int, dest='limit', default=None, help='Maximum number of tracks to scan') - self.add_argument('--force', - action='store_true', dest='force', default=False, + self.add_argument('--force', + action='store_true', dest='force', default=False, help='Force rescan of all media files') + def run(self, args, config): media_dir = config['local']['media_dir'] scan_timeout = config['local']['scan_timeout'] @@ -98,7 +99,7 @@ class ScanCommand(commands.Command): mtime = file_mtimes.get(abspath) if mtime is None: logger.debug('Missing file %s', track.uri) - uris_to_remove.add(track.uri) + uris_to_remove.add(track.uri) elif mtime > track.last_modified or args.force: uris_to_update.add(track.uri) uris_in_library.add(track.uri) From 713c55321f12b57f097ef38ce50be538ce6dbfb1 Mon Sep 17 00:00:00 2001 From: ronaldz Date: Sun, 1 Mar 2015 14:56:38 -0500 Subject: [PATCH 101/314] Updated the changelog --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index e6e3bcf4..713ba215 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -70,6 +70,8 @@ v0.20.0 (UNRELEASED) - Add support for giving local libraries direct access to tags and duration. (Fixes: :issue:`967`) +- Add "--force" option for local scan (Fixes: :issue:'910') (PR: :issue:'1010') + **File scanner** - Improve error logging for scan code (Fixes: :issue:`856`, PR: :issue:`874`) From ffeb78c2cb3d2bf394c63f5121184bdd7911317d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 1 Mar 2015 22:29:22 +0100 Subject: [PATCH 102/314] Only lint mopidy and tests dir --- tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index 7b5692e3..977a4b9e 100644 --- a/tasks.py +++ b/tasks.py @@ -28,7 +28,7 @@ def test(path=None, coverage=False, watch=False, warn=False): def lint(watch=False, warn=False): if watch: return watcher(lint) - run('flake8', warn=warn) + run('flake8 mopidy tests', warn=warn) @task From aeb4815fb6ca93f69c3516753c7cdffbeba0d443 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 1 Mar 2015 23:58:11 +0100 Subject: [PATCH 103/314] Update lint task and gitignore to exlude tmp/ --- .gitignore | 1 + tasks.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0edb30e0..990d75ca 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ docs/_build/ mopidy.log* nosetests.xml xunit-*.xml +tmp/ diff --git a/tasks.py b/tasks.py index 977a4b9e..03249481 100644 --- a/tasks.py +++ b/tasks.py @@ -28,7 +28,7 @@ def test(path=None, coverage=False, watch=False, warn=False): def lint(watch=False, warn=False): if watch: return watcher(lint) - run('flake8 mopidy tests', warn=warn) + run('flake8 --exclude=tmp,.git,__pycache__', warn=warn) @task From 4ee7dd73bd78b068ccf1d87de15c24e3988d9cc5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 1 Mar 2015 17:07:50 +0100 Subject: [PATCH 104/314] http: Make WS broadcast more robust against disconnect race Adds some WebSocketHandler tests that actually connect using a WS client and plugs a potential race condition. Any call to write_message could fail, either due to WebSocketClosedError like in the log below, or simply due to socket errors. To play it safe we catch all errors and debug log that a broadcast failed. 2015-02-26 21:24:02,266 ERROR [HttpServer] /home/adamcik/dev/mopidy/mopidy/http/handlers.py:116 mopidy.http.handlers WebSocket request error: deque index out of range 2015-02-26 21:24:10,098 ERROR [HttpFrontend-11] build/bdist.linux-x86_64/egg/pykka/actor.py:268 pykka Unhandled exception in HttpFrontend (urn:uuid:e376bd95-c32e-4e17-ad20-7d0b3c0cf2b2): Traceback (most recent call last): File "build/bdist.linux-x86_64/egg/pykka/actor.py", line 200, in _actor_loop response = self._handle_receive(message) File "build/bdist.linux-x86_64/egg/pykka/actor.py", line 294, in _handle_receive return callee(*message['args'], **message['kwargs']) File ".../dev/mopidy/mopidy/http/actor.py", line 77, in on_event on_event(name, **data) File ".../dev/mopidy/mopidy/http/actor.py", line 84, in on_event handlers.WebSocketHandler.broadcast(message) File ".../dev/mopidy/mopidy/http/handlers.py", line 78, in broadcast client.write_message(msg) File ".../dev/mopidy-virtualenv/local/lib/python2.7/site-packages/tornado/websocket.py", line 183, in write_message raise WebSocketClosedError() WebSocketClosedError --- mopidy/http/handlers.py | 10 +++++++- tests/http/test_handlers.py | 47 +++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/mopidy/http/handlers.py b/mopidy/http/handlers.py index 52bd8217..561c34b3 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -75,7 +75,15 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler): @classmethod def broadcast(cls, msg): for client in cls.clients: - client.write_message(msg) + # We could check for client.ws_connection, but we don't really + # care why the broadcast failed, we just want the rest of them + # to succeed, so catch everything. + try: + client.write_message(msg) + except Exception as e: + logger.debug('Broadcast of WebSocket message to %s failed: %s', + client.request.remote_ip, e) + # TODO: should this do the same cleanup as the on_message code? def initialize(self, core): self.jsonrpc = make_jsonrpc_wrapper(core) diff --git a/tests/http/test_handlers.py b/tests/http/test_handlers.py index 5c958d9a..5803adaf 100644 --- a/tests/http/test_handlers.py +++ b/tests/http/test_handlers.py @@ -2,8 +2,11 @@ from __future__ import absolute_import, unicode_literals import os +import mock + import tornado.testing import tornado.web +import tornado.websocket import mopidy from mopidy.http import handlers @@ -35,3 +38,47 @@ class StaticFileHandlerTest(tornado.testing.AsyncHTTPTestCase): response.headers['X-Mopidy-Version'], mopidy.__version__) self.assertEqual( response.headers['Cache-Control'], 'no-cache') + + +class WebSocketHandlerTest(tornado.testing.AsyncHTTPTestCase): + def get_app(self): + self.core = mock.Mock() + return tornado.web.Application([ + (r'/ws/?', handlers.WebSocketHandler, {'core': self.core}) + ]) + + def connection(self): + url = self.get_url('/ws').replace('http', 'ws') + return tornado.websocket.websocket_connect(url, self.io_loop) + + @tornado.testing.gen_test + def test_invalid_json_rpc_request_doesnt_crash_handler(self): + # An uncaught error would result in no message, so this is just a + # simplistic test to verify this. + conn = yield self.connection() + conn.write_message('invalid request') + message = yield conn.read_message() + self.assertTrue(message) + + @tornado.testing.gen_test + def test_broadcast_makes_it_to_client(self): + conn = yield self.connection() + handlers.WebSocketHandler.broadcast('message') + message = yield conn.read_message() + self.assertEqual(message, 'message') + + @tornado.testing.gen_test + def test_broadcast_to_client_that_just_closed_connection(self): + conn = yield self.connection() + conn.close() + handlers.WebSocketHandler.broadcast('message') + + @tornado.testing.gen_test + def test_broadcast_to_client_without_ws_connection_present(self): + yield self.connection() + # Tornado checks for ws_connection and raises WebSocketClosedError + # if it is missing, this test case simulates winning a race were + # this has happened but we have not yet been removed from clients. + for client in handlers.WebSocketHandler.clients: + client.ws_connection = None + handlers.WebSocketHandler.broadcast('message') From 0fb6c620dfc03da2d1f4677fafd05fd620a59ed5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 1 Mar 2015 17:19:43 +0100 Subject: [PATCH 105/314] docs: Add changelog entry for broadcast race --- docs/changelog.rst | 4 ++ tests/http/test_handlers.py | 76 +++++++++++++++++++------------------ 2 files changed, 43 insertions(+), 37 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e6e3bcf4..f832716f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -93,6 +93,10 @@ v0.20.0 (UNRELEASED) "database". If you insist on using a client that needs these commands change :confval:`mpd/command_blacklist`. +**HTTP frontend** + +- Prevent race condition in webservice broadcast from breaking the server. + **Audio** - Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. Pass a diff --git a/tests/http/test_handlers.py b/tests/http/test_handlers.py index 5803adaf..8bd82e11 100644 --- a/tests/http/test_handlers.py +++ b/tests/http/test_handlers.py @@ -40,45 +40,47 @@ class StaticFileHandlerTest(tornado.testing.AsyncHTTPTestCase): response.headers['Cache-Control'], 'no-cache') -class WebSocketHandlerTest(tornado.testing.AsyncHTTPTestCase): - def get_app(self): - self.core = mock.Mock() - return tornado.web.Application([ - (r'/ws/?', handlers.WebSocketHandler, {'core': self.core}) - ]) +# We aren't bothering with skipIf as then we would need to "backport" gen_test +if hasattr(tornado.websocket, 'websocket_connect'): + class WebSocketHandlerTest(tornado.testing.AsyncHTTPTestCase): + def get_app(self): + self.core = mock.Mock() + return tornado.web.Application([ + (r'/ws/?', handlers.WebSocketHandler, {'core': self.core}) + ]) - def connection(self): - url = self.get_url('/ws').replace('http', 'ws') - return tornado.websocket.websocket_connect(url, self.io_loop) + def connection(self): + url = self.get_url('/ws').replace('http', 'ws') + return tornado.websocket.websocket_connect(url, self.io_loop) - @tornado.testing.gen_test - def test_invalid_json_rpc_request_doesnt_crash_handler(self): - # An uncaught error would result in no message, so this is just a - # simplistic test to verify this. - conn = yield self.connection() - conn.write_message('invalid request') - message = yield conn.read_message() - self.assertTrue(message) + @tornado.testing.gen_test + def test_invalid_json_rpc_request_doesnt_crash_handler(self): + # An uncaught error would result in no message, so this is just a + # simplistic test to verify this. + conn = yield self.connection() + conn.write_message('invalid request') + message = yield conn.read_message() + self.assertTrue(message) - @tornado.testing.gen_test - def test_broadcast_makes_it_to_client(self): - conn = yield self.connection() - handlers.WebSocketHandler.broadcast('message') - message = yield conn.read_message() - self.assertEqual(message, 'message') + @tornado.testing.gen_test + def test_broadcast_makes_it_to_client(self): + conn = yield self.connection() + handlers.WebSocketHandler.broadcast('message') + message = yield conn.read_message() + self.assertEqual(message, 'message') - @tornado.testing.gen_test - def test_broadcast_to_client_that_just_closed_connection(self): - conn = yield self.connection() - conn.close() - handlers.WebSocketHandler.broadcast('message') + @tornado.testing.gen_test + def test_broadcast_to_client_that_just_closed_connection(self): + conn = yield self.connection() + conn.stream.close() + handlers.WebSocketHandler.broadcast('message') - @tornado.testing.gen_test - def test_broadcast_to_client_without_ws_connection_present(self): - yield self.connection() - # Tornado checks for ws_connection and raises WebSocketClosedError - # if it is missing, this test case simulates winning a race were - # this has happened but we have not yet been removed from clients. - for client in handlers.WebSocketHandler.clients: - client.ws_connection = None - handlers.WebSocketHandler.broadcast('message') + @tornado.testing.gen_test + def test_broadcast_to_client_without_ws_connection_present(self): + yield self.connection() + # Tornado checks for ws_connection and raises WebSocketClosedError + # if it is missing, this test case simulates winning a race were + # this has happened but we have not yet been removed from clients. + for client in handlers.WebSocketHandler.clients: + client.ws_connection = None + handlers.WebSocketHandler.broadcast('message') From 6c5970ffc393ffcd907cf3e701271b1f4d8bd816 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 1 Mar 2015 20:46:54 +0100 Subject: [PATCH 106/314] http: Make sure to decode exceptions for logging --- mopidy/http/handlers.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/mopidy/http/handlers.py b/mopidy/http/handlers.py index 561c34b3..a5baf992 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -10,7 +10,7 @@ import tornado.websocket import mopidy from mopidy import core, models -from mopidy.utils import jsonrpc +from mopidy.utils import encoding, jsonrpc logger = logging.getLogger(__name__) @@ -81,8 +81,9 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler): try: client.write_message(msg) except Exception as e: + error_msg = encoding.locale_decode(e) logger.debug('Broadcast of WebSocket message to %s failed: %s', - client.request.remote_ip, e) + client.request.remote_ip, error_msg) # TODO: should this do the same cleanup as the on_message code? def initialize(self, core): @@ -121,7 +122,8 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler): 'Sent WebSocket message to %s: %r', self.request.remote_ip, response) except Exception as e: - logger.error('WebSocket request error: %s', e) + error_msg = encoding.locale_decode(e) + logger.error('WebSocket request error: %s', error_msg) if self.ws_connection: # Tornado 3.2+ checks if self.ws_connection is None before # using it, but not older versions. From 00b2b9538e6a1d3dc9c1b938eed603f10127bbc8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 1 Mar 2015 22:46:18 +0100 Subject: [PATCH 107/314] core: Add library.list_distinct for getting distinct field values --- docs/changelog.rst | 3 +++ mopidy/backend.py | 10 ++++++++++ mopidy/core/library.py | 21 +++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 733d122c..03b25897 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -36,6 +36,9 @@ v0.20.0 (UNRELEASED) - When seeking in paused state, do not change to playing state. (Fixed :issue:`939`) +- Add :meth:`mopidy.core.LibraryController.list_distinct` for getting unique + values for a given field. (Fixes: :issue:`913`) + **Commands** - Make the ``mopidy`` command print a friendly error message if the diff --git a/mopidy/backend.py b/mopidy/backend.py index c713d083..38d4c5db 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -92,6 +92,16 @@ class LibraryProvider(object): """ return [] + def list_distinct(self, field, query=None): + """ + See :meth:`mopidy.core.LibraryController.list_distinct`. + + *MAY be implemented by subclass.* + + Default implementation will simply return an empty set. + """ + return set() + def get_images(self, uris): """ See :meth:`mopidy.core.LibraryController.get_images`. diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 822836a6..4ccfd657 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -72,6 +72,27 @@ class LibraryController(object): return [] return backend.library.browse(uri).get() + def list_distinct(self, field, query=None): + """ + List distinct values for a given field from the library. + + This has mainly been added to support the list commands the MPD + protocol supports in a more sane fashion. Other frontends are not + recommended to use this method. + + :param string field: One of ``artist``, ``albumartist``, ``album``, + ``composer``, ``performer``, ``date``or ``genre``. + :param dict query: Query to use for limiting results, see + :method:`search` for details about the query format. + :rtype: set of values corresponding to the requested field type. + """ + futures = [b.library.list_distinct(field, query) + for b in self.backends.with_library.values()] + result = set() + for r in pykka.get_all(futures): + result.update(r) + return result + def get_images(self, uris): """Lookup the images for the given URIs From ba8fc51f860861c5bfebc1db2ca4f5f8b7be0ddf Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 1 Mar 2015 22:48:16 +0100 Subject: [PATCH 108/314] local: Add support for list_distinct and implement for json backend --- mopidy/local/__init__.py | 12 ++++++++++++ mopidy/local/json.py | 32 ++++++++++++++++++++++++++++++++ mopidy/local/library.py | 5 +++++ 3 files changed, 49 insertions(+) diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 31ec6426..3099e240 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -89,6 +89,18 @@ class Library(object): """ raise NotImplementedError + def list_distinct(self, field, query=None): + """ + List distinct values for a given field from the library. + + :param string field: One of ``artist``, ``albumartist``, ``album``, + ``composer``, ``performer``, ``date``or ``genre``. + :param dict query: Query to use for limiting results, see + :method:`search` for details about the query format. + :rtype: set of values corresponding to the requested field type. + """ + return set() + def load(self): """ (Re)load any tracks stored in memory, if any, otherwise just return diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 38e1bf6c..dcf8ff9b 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -155,6 +155,38 @@ class JsonLibrary(local.Library): except KeyError: return [] + def list_distinct(self, field, query=None): + if field == 'artist': + def distinct(track): + return {a.name for a in track.artists} + elif field == 'albumartist': + def distinct(track): + album = track.album or models.Album() + return {a.name for a in album.artists} + elif field == 'album': + def distinct(track): + album = track.album or models.Album() + return {album.name} + elif field == 'composer': + def distinct(track): + return {a.name for a in track.composers} + elif field == 'performer': + def distinct(track): + return {a.name for a in track.performers} + elif field == 'date': + def distinct(track): + return {track.date} + elif field == 'genre': + def distinct(track): + return {track.genre} + else: + return set() + + result = set() + for track in search.search(self._tracks.values(), query).tracks: + result.update(distinct(track)) + return result + def search(self, query=None, limit=100, offset=0, uris=None, exact=False): tracks = self._tracks.values() # TODO: pass limit and offset into search helpers diff --git a/mopidy/local/library.py b/mopidy/local/library.py index f3828f1b..8a6c2a8a 100644 --- a/mopidy/local/library.py +++ b/mopidy/local/library.py @@ -23,6 +23,11 @@ class LocalLibraryProvider(backend.LibraryProvider): return [] return self._library.browse(uri) + def list_distinct(self, field, query=None): + if not self._library: + return set() + return self._library.list_distinct(field, query) + def refresh(self, uri=None): if not self._library: return 0 From 5fd2afa7ca6d65624396fe2f96466542161df449 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 1 Mar 2015 22:48:31 +0100 Subject: [PATCH 109/314] mpd: Switch list command to using list_distinct --- docs/changelog.rst | 3 + mopidy/mpd/protocol/music_db.py | 120 ++++++---------------------- tests/dummy_backend.py | 4 + tests/mpd/protocol/test_music_db.py | 13 ++- 4 files changed, 37 insertions(+), 103 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 03b25897..0cc145fc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -101,6 +101,9 @@ v0.20.0 (UNRELEASED) "database". If you insist on using a client that needs these commands change :confval:`mpd/command_blacklist`. +- Switch the ``list`` command over to using + :meth:`mopidy.core.LibraryController.list_distinct`. (Fixes: :issue:`913`) + **HTTP frontend** - Prevent race condition in webservice broadcast from breaking the server. diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index f08e51f2..04ad7d85 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -30,6 +30,15 @@ _LIST_MAPPING = { 'genre': 'genre', 'performer': 'performer'} +_LIST_NAME_MAPPING = { + 'album': 'Album', + 'albumartist': 'AlbumArtist', + 'artist': 'Artist', + 'composer': 'Composer', + 'date': 'Date', + 'genre': 'Genre', + 'performer': 'Performer'} + def _query_from_mpd_search_parameters(parameters, mapping): query = {} @@ -246,109 +255,30 @@ def list_(context, *args): - does not add quotes around the field argument. - capitalizes the field argument. """ - parameters = list(args) - if not parameters: + params = list(args) + if not params: raise exceptions.MpdArgError('incorrect arguments') - field = parameters.pop(0).lower() + field = params.pop(0).lower() if field not in _LIST_MAPPING: raise exceptions.MpdArgError('incorrect arguments') - if len(parameters) == 1: + if len(params) == 1: if field != 'album': raise exceptions.MpdArgError('should be "Album" for 3 arguments') - return _list_album(context, {'artist': parameters}) + query = {'artist': params} + else: + try: + query = _query_from_mpd_search_parameters(params, _LIST_MAPPING) + except exceptions.MpdArgError as e: + e.message = 'not able to parse args' + raise + except ValueError: + return - try: - query = _query_from_mpd_search_parameters(parameters, _LIST_MAPPING) - except exceptions.MpdArgError as e: - e.message = 'not able to parse args' - raise - except ValueError: - return - - if field == 'artist': - return _list_artist(context, query) - if field == 'albumartist': - return _list_albumartist(context, query) - elif field == 'album': - return _list_album(context, query) - elif field == 'composer': - return _list_composer(context, query) - elif field == 'performer': - return _list_performer(context, query) - elif field == 'date': - return _list_date(context, query) - elif field == 'genre': - return _list_genre(context, query) - - -def _list_artist(context, query): - artists = set() - results = context.core.library.find_exact(**query).get() - for track in _get_tracks(results): - for artist in track.artists: - if artist.name: - artists.add(('Artist', artist.name)) - return artists - - -def _list_albumartist(context, query): - albumartists = set() - results = context.core.library.find_exact(**query).get() - for track in _get_tracks(results): - if track.album: - for artist in track.album.artists: - if artist.name: - albumartists.add(('AlbumArtist', artist.name)) - return albumartists - - -def _list_album(context, query): - albums = set() - results = context.core.library.find_exact(**query).get() - for track in _get_tracks(results): - if track.album and track.album.name: - albums.add(('Album', track.album.name)) - return albums - - -def _list_composer(context, query): - composers = set() - results = context.core.library.find_exact(**query).get() - for track in _get_tracks(results): - for composer in track.composers: - if composer.name: - composers.add(('Composer', composer.name)) - return composers - - -def _list_performer(context, query): - performers = set() - results = context.core.library.find_exact(**query).get() - for track in _get_tracks(results): - for performer in track.performers: - if performer.name: - performers.add(('Performer', performer.name)) - return performers - - -def _list_date(context, query): - dates = set() - results = context.core.library.find_exact(**query).get() - for track in _get_tracks(results): - if track.date: - dates.add(('Date', track.date)) - return dates - - -def _list_genre(context, query): - genres = set() - results = context.core.library.find_exact(**query).get() - for track in _get_tracks(results): - if track.genre: - genres.add(('Genre', track.genre)) - return genres + name = _LIST_NAME_MAPPING[field] + result = context.core.library.list_distinct(field, query) + return [(name, value) for value in result.get()] @protocol.commands.add('listall') diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index 05b0fbff..a20c5686 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -33,6 +33,7 @@ class DummyLibraryProvider(backend.LibraryProvider): def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) self.dummy_library = [] + self.dummy_list_distinct_result = {} self.dummy_browse_result = {} self.dummy_find_exact_result = SearchResult() self.dummy_search_result = SearchResult() @@ -40,6 +41,9 @@ class DummyLibraryProvider(backend.LibraryProvider): def browse(self, path): return self.dummy_browse_result.get(path, []) + def list_distinct(self, field, query=None): + return self.dummy_list_distinct_result.get(field, set()) + def find_exact(self, **query): return self.dummy_find_exact_result diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index 9f3b7348..30ecf27c 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -55,7 +55,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): # Count the lone track self.backend.library.dummy_find_exact_result = SearchResult( tracks=[ - Track(uri='dummy:a', name="foo", date="2001", length=4000), + Track(uri='dummy:a', name='foo', date='2001', length=4000), ]) self.send_request('count "title" "foo"') self.assertInResponse('songs: 1') @@ -613,11 +613,8 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): class MusicDatabaseListTest(protocol.BaseTestCase): def test_list(self): - self.backend.library.dummy_find_exact_result = SearchResult( - tracks=[ - Track(uri='dummy:a', name='A', artists=[ - Artist(name='A Artist')])]) - + self.backend.library.dummy_list_distinct_result = { + 'artist': set(['A Artist'])} self.send_request('list "artist" "artist" "foo"') self.assertInResponse('Artist: A Artist') @@ -891,8 +888,8 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_list_album_with_artist_name(self): - self.backend.library.dummy_find_exact_result = SearchResult( - tracks=[Track(album=Album(name='foo'))]) + self.backend.library.dummy_list_distinct_result = { + 'album': set(['foo'])} self.send_request('list "album" "anartist"') self.assertInResponse('Album: foo') From fdab423a496be0349ad94e9e78493c470c61cfa3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 2 Mar 2015 00:29:46 +0100 Subject: [PATCH 110/314] Setup flake8 exclude in setup.cfg --- setup.cfg | 2 +- tasks.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 834ca945..95211279 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [flake8] application-import-names = mopidy,tests -exclude = .git,.tox,build,js +exclude = .git,.tox,build,js,tmp # Ignored flake8 warnings: # - E402 module level import not at top of file ignore = E402 diff --git a/tasks.py b/tasks.py index 03249481..7b5692e3 100644 --- a/tasks.py +++ b/tasks.py @@ -28,7 +28,7 @@ def test(path=None, coverage=False, watch=False, warn=False): def lint(watch=False, warn=False): if watch: return watcher(lint) - run('flake8 --exclude=tmp,.git,__pycache__', warn=warn) + run('flake8', warn=warn) @task From 45baeb9974204d5f7e09f2286b0b63421bb8a2e0 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Mon, 2 Mar 2015 11:26:05 +0000 Subject: [PATCH 111/314] Fixed OpenHome link for upmpdcli --- docs/clients/upnp.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/clients/upnp.rst b/docs/clients/upnp.rst index 9b24ae46..d0683df8 100644 --- a/docs/clients/upnp.rst +++ b/docs/clients/upnp.rst @@ -43,9 +43,9 @@ upmpdcli -------- `upmpdcli `_ is recommended, since it -is easier to setup, and offers `OpenHome ohMedia`_ -compatibility. upmpdcli exposes a UPnP MediaRenderer to the network, while -using the MPD protocol to control Mopidy. +is easier to setup, and offers `OpenHome +`_ compatibility. upmpdcli exposes a UPnP +MediaRenderer to the network, while using the MPD protocol to control Mopidy. 1. Install upmpdcli. On Debian/Ubuntu:: From 8cc9c9bbc032888cde31919bbe360cfdf5e801f5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 2 Mar 2015 22:41:09 +0100 Subject: [PATCH 112/314] core: Rename list_distinct to get_distinct --- docs/changelog.rst | 4 ++-- mopidy/backend.py | 4 ++-- mopidy/core/library.py | 4 ++-- mopidy/local/__init__.py | 2 +- mopidy/local/json.py | 2 +- mopidy/local/library.py | 4 ++-- mopidy/mpd/protocol/music_db.py | 2 +- tests/dummy_backend.py | 6 +++--- tests/mpd/protocol/test_music_db.py | 4 ++-- 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0cc145fc..36dbcc1e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -36,7 +36,7 @@ v0.20.0 (UNRELEASED) - When seeking in paused state, do not change to playing state. (Fixed :issue:`939`) -- Add :meth:`mopidy.core.LibraryController.list_distinct` for getting unique +- Add :meth:`mopidy.core.LibraryController.get_distinct` for getting unique values for a given field. (Fixes: :issue:`913`) **Commands** @@ -102,7 +102,7 @@ v0.20.0 (UNRELEASED) :confval:`mpd/command_blacklist`. - Switch the ``list`` command over to using - :meth:`mopidy.core.LibraryController.list_distinct`. (Fixes: :issue:`913`) + :meth:`mopidy.core.LibraryController.get_distinct`. (Fixes: :issue:`913`) **HTTP frontend** diff --git a/mopidy/backend.py b/mopidy/backend.py index 38d4c5db..f7808ac8 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -92,9 +92,9 @@ class LibraryProvider(object): """ return [] - def list_distinct(self, field, query=None): + def get_distinct(self, field, query=None): """ - See :meth:`mopidy.core.LibraryController.list_distinct`. + See :meth:`mopidy.core.LibraryController.get_distinct`. *MAY be implemented by subclass.* diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 4ccfd657..5937b2c0 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -72,7 +72,7 @@ class LibraryController(object): return [] return backend.library.browse(uri).get() - def list_distinct(self, field, query=None): + def get_distinct(self, field, query=None): """ List distinct values for a given field from the library. @@ -86,7 +86,7 @@ class LibraryController(object): :method:`search` for details about the query format. :rtype: set of values corresponding to the requested field type. """ - futures = [b.library.list_distinct(field, query) + futures = [b.library.get_distinct(field, query) for b in self.backends.with_library.values()] result = set() for r in pykka.get_all(futures): diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 3099e240..1587b63a 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -89,7 +89,7 @@ class Library(object): """ raise NotImplementedError - def list_distinct(self, field, query=None): + def get_distinct(self, field, query=None): """ List distinct values for a given field from the library. diff --git a/mopidy/local/json.py b/mopidy/local/json.py index dcf8ff9b..aa16a6df 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -155,7 +155,7 @@ class JsonLibrary(local.Library): except KeyError: return [] - def list_distinct(self, field, query=None): + def get_distinct(self, field, query=None): if field == 'artist': def distinct(track): return {a.name for a in track.artists} diff --git a/mopidy/local/library.py b/mopidy/local/library.py index 8a6c2a8a..90a54770 100644 --- a/mopidy/local/library.py +++ b/mopidy/local/library.py @@ -23,10 +23,10 @@ class LocalLibraryProvider(backend.LibraryProvider): return [] return self._library.browse(uri) - def list_distinct(self, field, query=None): + def get_distinct(self, field, query=None): if not self._library: return set() - return self._library.list_distinct(field, query) + return self._library.get_distinct(field, query) def refresh(self, uri=None): if not self._library: diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 04ad7d85..62147b7d 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -277,7 +277,7 @@ def list_(context, *args): return name = _LIST_NAME_MAPPING[field] - result = context.core.library.list_distinct(field, query) + result = context.core.library.get_distinct(field, query) return [(name, value) for value in result.get()] diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index a20c5686..9c5a8c0c 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -33,7 +33,7 @@ class DummyLibraryProvider(backend.LibraryProvider): def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) self.dummy_library = [] - self.dummy_list_distinct_result = {} + self.dummy_get_distinct_result = {} self.dummy_browse_result = {} self.dummy_find_exact_result = SearchResult() self.dummy_search_result = SearchResult() @@ -41,8 +41,8 @@ class DummyLibraryProvider(backend.LibraryProvider): def browse(self, path): return self.dummy_browse_result.get(path, []) - def list_distinct(self, field, query=None): - return self.dummy_list_distinct_result.get(field, set()) + def get_distinct(self, field, query=None): + return self.dummy_get_distinct_result.get(field, set()) def find_exact(self, **query): return self.dummy_find_exact_result diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index 30ecf27c..613467ed 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -613,7 +613,7 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): class MusicDatabaseListTest(protocol.BaseTestCase): def test_list(self): - self.backend.library.dummy_list_distinct_result = { + self.backend.library.dummy_get_distinct_result = { 'artist': set(['A Artist'])} self.send_request('list "artist" "artist" "foo"') @@ -888,7 +888,7 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_list_album_with_artist_name(self): - self.backend.library.dummy_list_distinct_result = { + self.backend.library.dummy_get_distinct_result = { 'album': set(['foo'])} self.send_request('list "album" "anartist"') From 8c7c275f3ae732717573a5633d91d8d0bd3c471c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 2 Mar 2015 23:21:14 +0100 Subject: [PATCH 113/314] docs: Add changelog for issue #917 & PR #947 --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 36dbcc1e..cb76c31d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -78,6 +78,9 @@ v0.20.0 (UNRELEASED) - Add "--force" option for local scan (Fixes: :issue:'910') (PR: :issue:'1010') +- Stop ignoring ``offset`` and ``limit`` in searches. (Fixes: :issue:`917`, + PR: :issue:`949`) + **File scanner** - Improve error logging for scan code (Fixes: :issue:`856`, PR: :issue:`874`) From 319c1fc1e350c66b470907e7b6134149de69bfe4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 2 Mar 2015 23:39:06 +0100 Subject: [PATCH 114/314] local: Readd support for search without limit for get_distinct support --- mopidy/local/json.py | 9 +++++---- mopidy/local/search.py | 15 +++++++++++---- tests/local/test_json.py | 2 -- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/mopidy/local/json.py b/mopidy/local/json.py index e27015f2..969049d6 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -182,10 +182,11 @@ class JsonLibrary(local.Library): else: return set() - result = set() - for track in search.search(self._tracks.values(), query).tracks: - result.update(distinct(track)) - return result + distinct_result = set() + search_result = search.search(self._tracks.values(), query, limit=None) + for track in search_result.tracks: + distinct_result.update(distinct(track)) + return distinct_result def search(self, query=None, limit=100, offset=0, uris=None, exact=False): tracks = self._tracks.values() diff --git a/mopidy/local/search.py b/mopidy/local/search.py index 1f82366f..9d6edea7 100644 --- a/mopidy/local/search.py +++ b/mopidy/local/search.py @@ -106,9 +106,12 @@ def find_exact(tracks, query=None, limit=100, offset=0, uris=None): else: raise LookupError('Invalid lookup field: %s' % field) + if limit is None: + tracks = tracks[offset:] + else: + tracks = tracks[offset:offset + limit] # TODO: add local:search: - return SearchResult( - uri='local:search', tracks=tracks[offset:offset + limit]) + return SearchResult(uri='local:search', tracks=tracks) def search(tracks, query=None, limit=100, offset=0, uris=None): @@ -217,9 +220,13 @@ def search(tracks, query=None, limit=100, offset=0, uris=None): tracks = filter(any_filter, tracks) else: raise LookupError('Invalid lookup field: %s' % field) + + if limit is None: + tracks = tracks[offset:] + else: + tracks = tracks[offset:offset + limit] # TODO: add local:search: - return SearchResult(uri='local:search', - tracks=tracks[offset:offset + limit]) + return SearchResult(uri='local:search', tracks=tracks) def _validate_query(query): diff --git a/tests/local/test_json.py b/tests/local/test_json.py index 6d57c4d0..520287ad 100644 --- a/tests/local/test_json.py +++ b/tests/local/test_json.py @@ -1,12 +1,10 @@ from __future__ import absolute_import, unicode_literals - import unittest from mopidy.local import json from mopidy.models import Ref, Track - from tests import path_to_data_dir From c0d46263608b1ad4a9cde2a7dfa2597e51c9953c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 3 Mar 2015 00:00:42 +0100 Subject: [PATCH 115/314] docs: Update changelog based on all merges since last 0.19.x merge --- docs/changelog.rst | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index cb76c31d..298a6305 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -31,13 +31,14 @@ v0.20.0 (UNRELEASED) abstraction, which was never intended to be used externally. - Add :meth:`mopidy.core.LibraryController.get_images` for looking up images - for any URI backends know about. (Fixes :issue:`973`) + for any URI backends know about. (Fixes :issue:`973`, PR: :issue:`981`, + :issue:`992` and :issue:`1013`) -- When seeking in paused state, do not change to playing state. (Fixed - :issue:`939`) +- When seeking in paused state, do not change to playing state. (Fixes: + :issue:`939`, PR: :issue:`1018`) - Add :meth:`mopidy.core.LibraryController.get_distinct` for getting unique - values for a given field. (Fixes: :issue:`913`) + values for a given field. (Fixes: :issue:`913`, PR: :issue:`1022`) **Commands** @@ -54,7 +55,7 @@ v0.20.0 (UNRELEASED) This can be used to show absolutely all log records, including those at custom levels below ``DEBUG``. -- Add debug logging of unknown sections. (Fixes: :issue:`694`) +- Add debug logging of unknown sections. (Fixes: :issue:`694`, PR: :issue:`1002`) **Logging** @@ -76,11 +77,19 @@ v0.20.0 (UNRELEASED) - Add support for giving local libraries direct access to tags and duration. (Fixes: :issue:`967`) -- Add "--force" option for local scan (Fixes: :issue:'910') (PR: :issue:'1010') +- Add "--force" option for local scan (Fixes: :issue:'910', PR: :issue:'1010') - Stop ignoring ``offset`` and ``limit`` in searches. (Fixes: :issue:`917`, PR: :issue:`949`) +- Removed double triggering of ``playlists_loaded`` event. + (Fixes: :issue:`998`, PR: :issue:`999`) + +- Cleanup and refactoring of local playlist code. Preserves playlist names + better and fixes bug in deletion of playlists. (Fixes: :issue:`937`, + PR: :issue:`995` and rebased into :issue:`1000`) + + **File scanner** - Improve error logging for scan code (Fixes: :issue:`856`, PR: :issue:`874`) @@ -110,6 +119,7 @@ v0.20.0 (UNRELEASED) **HTTP frontend** - Prevent race condition in webservice broadcast from breaking the server. + (PR: :issue:`1020`) **Audio** @@ -160,7 +170,7 @@ v0.20.0 (UNRELEASED) - Add basic tests for the stream library provider. - Add support for proxies when doing initial metadata lookup for stream. - (Fixes :issue:`390`) + (Fixes :issue:`390`, PR: :issue:`982`) **Mopidy.js client library** @@ -656,6 +666,7 @@ guys. Thanks to everyone that has contributed! - The dummy backend used for testing many frontends have moved from :mod:`mopidy.backends.dummy` to :mod:`mopidy.backend.dummy`. + (PR: :issue:`984`) **Commands** From 8b59c4dc87971917709787459ece3c6ffd922ee1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 3 Mar 2015 00:48:11 +0100 Subject: [PATCH 116/314] docs: Update authors --- .mailmap | 1 + AUTHORS | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.mailmap b/.mailmap index 3ea843b1..7c0888ae 100644 --- a/.mailmap +++ b/.mailmap @@ -19,3 +19,4 @@ Ignasi Fosch Christopher Schirner Laura Barber John Cass +Ronald Zielaznicki diff --git a/AUTHORS b/AUTHORS index 08685991..52ea4e34 100644 --- a/AUTHORS +++ b/AUTHORS @@ -51,3 +51,5 @@ - Dirk Groenen - John Cass - Laura Barber +- Jakab Kristóf +- Ronald Zielaznicki From 88ddcc7d358e25bbf365bc32dbc11b94b0bf228b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 3 Mar 2015 01:09:48 +0100 Subject: [PATCH 117/314] docs: Add section on ext installation on OS X --- docs/installation/osx.rst | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/docs/installation/osx.rst b/docs/installation/osx.rst index 9c0e059e..dbc56137 100644 --- a/docs/installation/osx.rst +++ b/docs/installation/osx.rst @@ -57,16 +57,30 @@ If you are running OS X, you can install everything needed with Homebrew. brew install mopidy -#. Optional: If you want to use any Mopidy extensions, like Spotify support or - Last.fm scrobbling, the Homebrew tap has formulas for several Mopidy - extensions as well. - - To list all the extensions available from our tap, you can run:: - - brew search mopidy - - For a full list of available Mopidy extensions, including those not - installable from Homebrew, see :ref:`ext`. - #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. + + +Installing extensions +===================== + +If you want to use any Mopidy extensions, like Spotify support or Last.fm +scrobbling, the Homebrew tap has formulas for several Mopidy extensions as +well. Extensions installed from Homebrew will come complete with all +dependencies, both Python and non-Python ones. + +To list all the extensions available from our tap, you can run:: + + brew search mopidy + +You can also install any Mopidy extension directly from PyPI with ``pip``, just +like on Linux. To list all the extensions available from PyPI, run:: + + pip search mopidy + +Note that extensions installed from PyPI will only automatically install Python +dependencies. Please refer to the extension's documentation for information +about any other requirements needed for the extension to work properly. + +For a full list of available Mopidy extensions, including those not installable +from Homebrew, see :ref:`ext`. From 3283beba697934fd98119b888dacc4ce327100b1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 3 Mar 2015 01:20:09 +0100 Subject: [PATCH 118/314] docs: Add section on starting Mopidy at login Fixes #887 --- docs/installation/osx.rst | 47 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/installation/osx.rst b/docs/installation/osx.rst index dbc56137..71beece3 100644 --- a/docs/installation/osx.rst +++ b/docs/installation/osx.rst @@ -84,3 +84,50 @@ about any other requirements needed for the extension to work properly. For a full list of available Mopidy extensions, including those not installable from Homebrew, see :ref:`ext`. + + +Running Mopidy automatically on login +===================================== + +On OS X, you can use launchd to start Mopidy automatically at login. + +If you installed Mopidy from Homebrew, simply run ``brew info mopidy`` and +follow the instructions in the "Caveats" section:: + + $ brew info mopidy + ... + ==> Caveats + To have launchd start mopidy at login: + ln -sfv /usr/local/opt/mopidy/*.plist ~/Library/LaunchAgents + Then to load mopidy now: + launchctl load ~/Library/LaunchAgents/homebrew.mopidy.mopidy.plist + Or, if you don't want/need launchctl, you can just run: + mopidy + +If you happen to be on OS X, but didn't install Mopidy with Homebrew, you can +get the same effect by adding the file +:file:`~/Library/LaunchAgents/mopidy.plist` with the following contents:: + + + + + + Label + mopidy + ProgramArguments + + /usr/local/bin/mopidy + + RunAtLoad + + KeepAlive + + + + +You might need to adjust the path to the ``mopidy`` executable, +``/usr/local/bin/mopidy``, to match your system. + +Then, to start Mopidy with launchd right away:: + + launchctl load ~/Library/LaunchAgents/mopidy.plist From c3f6016388571736bd58edcb058f45f00e1c330d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 3 Mar 2015 01:28:44 +0100 Subject: [PATCH 119/314] docs: Add section on ext installation on Arch --- docs/installation/arch.rst | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/installation/arch.rst b/docs/installation/arch.rst index e58d6cf5..f8492fdf 100644 --- a/docs/installation/arch.rst +++ b/docs/installation/arch.rst @@ -16,12 +16,25 @@ If you are running Arch Linux, you can install Mopidy using the yaourt -Syua -#. Optional: If you want to use any Mopidy extensions, like Spotify support or - Last.fm scrobbling, AUR also has `packages for several Mopidy extensions - `_. - - For a full list of available Mopidy extensions, including those not - installable from AUR, see :ref:`ext`. - #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. + + +Installing extensions +===================== + +If you want to use any Mopidy extensions, like Spotify support or Last.fm +scrobbling, AUR also has `packages for lots of Mopidy extensions +`_. + +You can also install any Mopidy extension directly from PyPI with ``pip``. To +list all the extensions available from PyPI, run:: + + pip search mopidy + +Note that extensions installed from PyPI will only automatically install Python +dependencies. Please refer to the extension's documentation for information +about any other requirements needed for the extension to work properly. + +For a full list of available Mopidy extensions, including those not installable +from AUR, see :ref:`ext`. From 6ff85aed2959485af305517c6b1a62ce3dbfb033 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 3 Mar 2015 01:32:55 +0100 Subject: [PATCH 120/314] docs: Add section on ext installation on Debian --- docs/installation/debian.rst | 41 ++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/docs/installation/debian.rst b/docs/installation/debian.rst index f34eb255..f39a4d3b 100644 --- a/docs/installation/debian.rst +++ b/docs/installation/debian.rst @@ -52,20 +52,6 @@ from scratch, we have a guide for installing Debian/Raspbian and Mopidy. See sudo apt-get update sudo apt-get install mopidy -#. Optional: If you want to use any Mopidy extensions, like Spotify support or - Last.fm scrobbling, you need to install additional packages. - - To list all the extensions available from apt.mopidy.com, you can run:: - - apt-cache search mopidy - - To install one of the listed packages, e.g. ``mopidy-spotify``, simply run:: - - sudo apt-get install mopidy-spotify - - For a full list of available Mopidy extensions, including those not - installable from apt.mopidy.com, see :ref:`ext`. - #. Before continuing, make sure you've read the :ref:`debian` section to learn about the differences between running Mopidy as a system service and manually as your own system user. @@ -78,3 +64,30 @@ figure it out for itself, run the following to upgrade right away:: sudo apt-get update sudo apt-get dist-upgrade + + +Installing extensions +===================== + +If you want to use any Mopidy extensions, like Spotify support or Last.fm +scrobbling, you need to install additional packages. + +To list all the extensions available from apt.mopidy.com, you can run:: + + apt-cache search mopidy + +To install one of the listed packages, e.g. ``mopidy-spotify``, simply run:: + + sudo apt-get install mopidy-spotify + +You can also install any Mopidy extension directly from PyPI with ``pip``. To +list all the extensions available from PyPI, run:: + + pip search mopidy + +Note that extensions installed from PyPI will only automatically install Python +dependencies. Please refer to the extension's documentation for information +about any other requirements needed for the extension to work properly. + +For a full list of available Mopidy extensions, including those not +installable from apt.mopidy.com, see :ref:`ext`. From 1e41a1192f6884756127a3685d08920324856cf7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 3 Mar 2015 01:36:30 +0100 Subject: [PATCH 121/314] docs: Add section on ext installation from source --- docs/installation/source.rst | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/installation/source.rst b/docs/installation/source.rst index 0b4fc5aa..c2018984 100644 --- a/docs/installation/source.rst +++ b/docs/installation/source.rst @@ -81,9 +81,23 @@ please follow the directions :ref:`here `. sudo pip install --allow-unverified=mopidy mopidy==dev -#. Optional: For Spotify support, Last.fm scrobbling, or many other extra - features, install the required Mopidy extensions. For a full list of - available Mopidy extensions, see :ref:`ext`. - #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. + + +Installing extensions +===================== + +If you want to use any Mopidy extensions, like Spotify support or Last.fm +scrobbling, you need to install additional Mopidy extensions. + +You can install any Mopidy extension directly from PyPI with ``pip``. To list +all the extensions available from PyPI, run:: + + pip search mopidy + +Note that extensions installed from PyPI will only automatically install Python +dependencies. Please refer to the extension's documentation for information +about any other requirements needed for the extension to work properly. + +For a full list of available Mopidy extensions see :ref:`ext`. From 0f9fcc62cb38c1ab53aa07e2f9715be0068a8e84 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 3 Mar 2015 01:53:36 +0100 Subject: [PATCH 122/314] docs: Add missing ext troubleshooting to Debian docs Fixes #879 --- docs/installation/debian.rst | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/installation/debian.rst b/docs/installation/debian.rst index f39a4d3b..72d5843e 100644 --- a/docs/installation/debian.rst +++ b/docs/installation/debian.rst @@ -91,3 +91,44 @@ about any other requirements needed for the extension to work properly. For a full list of available Mopidy extensions, including those not installable from apt.mopidy.com, see :ref:`ext`. + + +Missing extensions +================== + +If you've installed a Mopidy extension with pip, restarted Mopidy, and Mopidy +doesn't find the extension, there's probably a simple explanation and solution. + +Mopidy installed with APT can detect and use Mopidy extensions installed with +either APT and pip. APT installs Mopidy as :file:`/usr/bin/mopidy`. + +Mopidy installed with pip can only detect Mopidy extensions installed from pip. +pip usually installs Mopidy as :file:`/usr/local/bin/mopidy`. + +If you have Mopidy installed from both APT and pip, then the pip-installed +Mopidy will probably shadow the APT-installed Mopidy because +:file:`/usr/local/bin` usually has precedence over :file:`/usr/bin` in the +``PATH`` environment variable. To check if this is the case on your system, you +can use ``which`` to see what installation of Mopidy you use when you run +``mopidy`` in your shell:: + + $ which mopidy + /usr/local/bin/mopidy + +If this is the case on your system, the recommended solution is to check that +you have Mopidy installed from APT too:: + + $ /usr/bin/mopidy --version + Mopidy 0.19.5 + +And then uninstall the pip-installed Mopidy:: + + sudo pip uninstall mopidy + +Depending on what shell you use, the shell may still try to use +:file:`/usr/local/bin/mopidy` even if it no longer exists. Check again with +``which mopidy`` what your shell believes is the right ``mopidy`` executable to +run. If the shell is still confused, you may need to restart it, or in the case +of zsh, run ``rehash`` to update the shell. + +For more details on why this works this way, see :ref:`debian`. From 6dddf34333774a78f1851cb5b56d3cb799c4ab40 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 3 Mar 2015 15:28:30 +0100 Subject: [PATCH 123/314] docs: Fix review comments --- docs/installation/debian.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/installation/debian.rst b/docs/installation/debian.rst index 72d5843e..4def3fbb 100644 --- a/docs/installation/debian.rst +++ b/docs/installation/debian.rst @@ -100,9 +100,9 @@ If you've installed a Mopidy extension with pip, restarted Mopidy, and Mopidy doesn't find the extension, there's probably a simple explanation and solution. Mopidy installed with APT can detect and use Mopidy extensions installed with -either APT and pip. APT installs Mopidy as :file:`/usr/bin/mopidy`. +both APT and pip. APT installs Mopidy as :file:`/usr/bin/mopidy`. -Mopidy installed with pip can only detect Mopidy extensions installed from pip. +Mopidy installed with pip can only detect Mopidy extensions installed with pip. pip usually installs Mopidy as :file:`/usr/local/bin/mopidy`. If you have Mopidy installed from both APT and pip, then the pip-installed From a6f021cc4f0e18403b0b69d628593e6ecfae87d8 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Tue, 3 Mar 2015 15:03:13 +0000 Subject: [PATCH 124/314] docs: Troubleshooting link to discuss, not google group --- docs/troubleshooting.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 51cd8bc4..b7ff3c03 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -4,9 +4,9 @@ Troubleshooting *************** -If you run into problems with Mopidy, we usually hang around at ``#mopidy`` at -`irc.freenode.net `_ and also have a `mailing list at -Google Groups `_. +If you run into problems with Mopidy, we usually hang around at ``#mopidy`` on +`irc.freenode.net `_ and also have a `discussion forum +`_. If you stumble into a bug or have a feature request, please create an issue in the `issue tracker `_. From 9e967f7997219f9db204a330f1e91f0f712c1443 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 4 Mar 2015 23:16:26 +0100 Subject: [PATCH 125/314] docs: Add section on Deb package availability --- docs/debian.rst | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/debian.rst b/docs/debian.rst index f37c0673..f939d9af 100644 --- a/docs/debian.rst +++ b/docs/debian.rst @@ -1,13 +1,20 @@ .. _debian: -************** -Debian package -************** +*************** +Debian packages +*************** -The Mopidy Debian package is available from `apt.mopidy.com +The Mopidy Debian package, ``mopidy``, is available from `apt.mopidy.com `__ as well as from Debian, Ubuntu and other Debian-based Linux distributions. +Some extensions are also available from all of these sources, while others, +like Mopidy-Spotify and its dependencies, are only available from +apt.mopidy.com. This may either be temporary until the package is uploaded to +Debian and with time propagates to the other distributions. It may also be more +long term, like in the Mopidy-Spotify case where there is uncertainities around +licensing and distribution of non-free packages. + Installation ============ From 2280a533c05034791005f7d2de7f7e5bcedab034 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 5 Mar 2015 00:35:20 +0100 Subject: [PATCH 126/314] Use py.test as test runner --- dev-requirements.txt | 7 +++---- docs/contributing.rst | 4 ++-- docs/extensiondev.rst | 5 ----- setup.py | 5 ----- tasks.py | 6 ++---- tests/__main__.py | 5 ----- tox.ini | 10 +++++----- 7 files changed, 12 insertions(+), 30 deletions(-) delete mode 100644 tests/__main__.py diff --git a/dev-requirements.txt b/dev-requirements.txt index 7b0e96c8..eba66348 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -12,12 +12,11 @@ flake8-import-order mock # Test runners -nose +pytest +pytest-cov +pytest-xdist tox -# Measure test's code coverage -coverage - # Check that MANIFEST.in matches Git repo contents before making a release check-manifest diff --git a/docs/contributing.rst b/docs/contributing.rst index 165fee49..f30e16bd 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -89,11 +89,11 @@ Mopidy to come with tests. #. To run all tests, go to the project directory and run:: - nosetests + py.test To run tests with test coverage statistics:: - nosetests --with-coverage + py.test --cov=mopidy --cov-report=term-missing Test coverage statistics can also be viewed online at `coveralls.io `_. diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index c6a88619..93f627dc 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -189,11 +189,6 @@ class that will connect the rest of the dots. 'Pykka >= 1.1', 'pysoundspot', ], - test_suite='nose.collector', - tests_require=[ - 'nose', - 'mock >= 1.0', - ], entry_points={ 'mopidy.ext': [ 'soundspot = mopidy_soundspot:Extension', diff --git a/setup.py b/setup.py index 384aaec5..0d29c041 100644 --- a/setup.py +++ b/setup.py @@ -29,11 +29,6 @@ setup( 'tornado >= 2.3', ], extras_require={'http': []}, - test_suite='nose.collector', - tests_require=[ - 'nose', - 'mock >= 1.0', - ], entry_points={ 'console_scripts': [ 'mopidy = mopidy.__main__:main', diff --git a/tasks.py b/tasks.py index 7b5692e3..9353eb8a 100644 --- a/tasks.py +++ b/tasks.py @@ -15,11 +15,9 @@ def test(path=None, coverage=False, watch=False, warn=False): if watch: return watcher(test, path=path, coverage=coverage) path = path or 'tests/' - cmd = 'nosetests' + cmd = 'py.test' if coverage: - cmd += ( - ' --with-coverage --cover-package=mopidy' - ' --cover-branches --cover-html') + cmd += ' --cov=mopidy --cov-report=term-missing' cmd += ' %s' % path run(cmd, pty=True, warn=warn) diff --git a/tests/__main__.py b/tests/__main__.py deleted file mode 100644 index ae7a18e6..00000000 --- a/tests/__main__.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import nose - -nose.main() diff --git a/tox.ini b/tox.ini index 277ae9d3..3d48e311 100644 --- a/tox.ini +++ b/tox.ini @@ -3,20 +3,20 @@ envlist = py27, py27-tornado23, py27-tornado31, docs, flake8 [testenv] sitepackages = true -commands = nosetests -v --with-xunit --xunit-file=xunit-{envname}.xml --with-coverage --cover-package=mopidy +commands = py.test --junit-xml=xunit-{envname}.xml --cov=mopidy deps = - coverage mock - nose + pytest + pytest-cov [testenv:py27-tornado23] -commands = nosetests -v tests/http +commands = py.test tests/http deps = {[testenv]deps} tornado==2.3 [testenv:py27-tornado31] -commands = nosetests -v tests/http +commands = py.test tests/http deps = {[testenv]deps} tornado==3.1.1 From c916fcb421ff07dede3efbb73528dd8673fd134d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 5 Mar 2015 09:16:32 +0100 Subject: [PATCH 127/314] tox: Use env specific tmpdir for py.test --- tox.ini | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 3d48e311..bffdb2df 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,10 @@ envlist = py27, py27-tornado23, py27-tornado31, docs, flake8 [testenv] sitepackages = true -commands = py.test --junit-xml=xunit-{envname}.xml --cov=mopidy +commands = + py.test \ + --basetemp={envtmpdir} \ + --junit-xml=xunit-{envname}.xml --cov=mopidy deps = mock pytest From d89041e1d3c34843decf79630436483662a5e670 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 5 Mar 2015 09:16:46 +0100 Subject: [PATCH 128/314] tox: Pass args to py.test, include pytest-xdist --- tox.ini | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index bffdb2df..ad43d9ec 100644 --- a/tox.ini +++ b/tox.ini @@ -6,11 +6,13 @@ sitepackages = true commands = py.test \ --basetemp={envtmpdir} \ - --junit-xml=xunit-{envname}.xml --cov=mopidy + --junit-xml=xunit-{envname}.xml --cov=mopidy \ + {posargs} deps = mock pytest pytest-cov + pytest-xdist [testenv:py27-tornado23] commands = py.test tests/http From 1119555809a03be7bc8029b375ae97ad5b2241fd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 5 Mar 2015 12:27:18 +0100 Subject: [PATCH 129/314] core: Remove deprecated property warnings Their use of inspect (I think) made parallel test execution slower than serial test execution. --- mopidy/utils/deprecation.py | 35 ++++++++--------------------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/mopidy/utils/deprecation.py b/mopidy/utils/deprecation.py index 1b744702..bf4756d7 100644 --- a/mopidy/utils/deprecation.py +++ b/mopidy/utils/deprecation.py @@ -1,34 +1,15 @@ from __future__ import unicode_literals -import inspect -import warnings - - -def _is_pykka_proxy_creation(): - stack = inspect.stack() - try: - calling_frame = stack[3] - except IndexError: - return False - else: - filename = calling_frame[1] - funcname = calling_frame[3] - return 'pykka' in filename and funcname == '_get_attributes' - def deprecated_property( getter=None, setter=None, message='Property is deprecated'): - def deprecated_getter(*args): - if not _is_pykka_proxy_creation(): - warnings.warn(message, DeprecationWarning, stacklevel=2) - return getter(*args) + # During development, this is a convenient place to add logging, emit + # warnings, or ``assert False`` to ensure you are not using any of the + # deprecated properties. + # + # Using inspect to find the call sites to emit proper warnings makes + # parallel execution of our test suite slower than serial execution. Thus, + # we don't want to add any extra overhead here by default. - def deprecated_setter(*args): - if not _is_pykka_proxy_creation(): - warnings.warn(message, DeprecationWarning, stacklevel=2) - return setter(*args) - - new_getter = getter and deprecated_getter - new_setter = setter and deprecated_setter - return property(new_getter, new_setter) + return property(getter, setter) From 67a41b980a7049690f9c7cc229c1c7da46d6a9e5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 5 Mar 2015 12:28:31 +0100 Subject: [PATCH 130/314] tox: Run tests with 4 processes in parallel --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index ad43d9ec..e6470146 100644 --- a/tox.ini +++ b/tox.ini @@ -7,6 +7,7 @@ commands = py.test \ --basetemp={envtmpdir} \ --junit-xml=xunit-{envname}.xml --cov=mopidy \ + -n 4 \ {posargs} deps = mock From 51fb2e22422b25f589df745b7484d14d7db0b783 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 5 Mar 2015 22:14:16 +0100 Subject: [PATCH 131/314] docs: Add PR #1024 to changelog --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 298a6305..9dd398e3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -185,6 +185,10 @@ This version has been released to npm as Mopidy.js v0.5.0. - Upgrade dependencies. +**Development** + +- Changed test runner from nose to py.test. (PR: :issue:`1024`) + v0.19.6 (UNRELEASED) ==================== From 733732405f3183da65ae5c224140e46fd10ab814 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 5 Mar 2015 22:59:42 +0100 Subject: [PATCH 132/314] tox: Use better coverage report --- tox.ini | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index e6470146..6dfab5ae 100644 --- a/tox.ini +++ b/tox.ini @@ -6,7 +6,8 @@ sitepackages = true commands = py.test \ --basetemp={envtmpdir} \ - --junit-xml=xunit-{envname}.xml --cov=mopidy \ + --junit-xml=xunit-{envname}.xml \ + --cov=mopidy --cov-report=term-missing \ -n 4 \ {posargs} deps = From 9150c34053832f8e9aaa9e9dddf6ebac564e69df Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Fri, 6 Mar 2015 10:02:08 +0100 Subject: [PATCH 133/314] Fix #1023: Remove support for local album images from coverartarchive.org --- mopidy/local/commands.py | 1 - mopidy/local/translator.py | 9 --------- tests/local/test_translator.py | 15 +-------------- 3 files changed, 1 insertion(+), 24 deletions(-) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 79c1c505..798c10f8 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -141,7 +141,6 @@ class ScanCommand(commands.Command): mtime = file_mtimes.get(os.path.join(media_dir, relpath)) track = utils.convert_tags_to_track(tags).copy( uri=uri, length=duration, last_modified=mtime) - track = translator.add_musicbrainz_coverart_to_track(track) if library.add_supports_tags_and_duration: library.add(track, tags=tags, duration=duration) else: diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index d0c19c27..6800c478 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -13,19 +13,10 @@ from mopidy.utils.path import path_to_uri, uri_to_path M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)') -COVERART_BASE = 'http://coverartarchive.org/release/%s/front' logger = logging.getLogger(__name__) -def add_musicbrainz_coverart_to_track(track): - if track.album and track.album.musicbrainz_id: - images = [COVERART_BASE % track.album.musicbrainz_id] - album = track.album.copy(images=images) - track = track.copy(album=album) - return track - - def local_track_uri_to_file_uri(uri, media_dir): return path_to_uri(local_track_uri_to_path(uri, media_dir)) diff --git a/tests/local/test_translator.py b/tests/local/test_translator.py index b238c909..d3ba9e68 100644 --- a/tests/local/test_translator.py +++ b/tests/local/test_translator.py @@ -7,7 +7,7 @@ import tempfile import unittest from mopidy.local import translator -from mopidy.models import Album, Track +from mopidy.models import Track from mopidy.utils import path from tests import path_to_data_dir @@ -118,16 +118,3 @@ class M3UToUriTest(unittest.TestCase): class URItoM3UTest(unittest.TestCase): pass - - -class AddMusicbrainzCoverartTest(unittest.TestCase): - def test_add_cover_for_album(self): - album = Album(musicbrainz_id='someid') - track = Track(album=album) - - expected = album.copy( - images=['http://coverartarchive.org/release/someid/front']) - - self.assertEqual( - track.copy(album=expected), - translator.add_musicbrainz_coverart_to_track(track)) From 8d2cedcc6909326b6abbe7435aa2c28fff060880 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Fri, 6 Mar 2015 19:31:54 +0100 Subject: [PATCH 134/314] Remove changelog entry for #802. --- docs/changelog.rst | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9dd398e3..3e209743 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -66,9 +66,6 @@ v0.20.0 (UNRELEASED) **Local backend** -- Add cover URL to all scanned files with MusicBrainz album IDs. (Fixes: - :issue:`697`, PR: :issue:`802`) - - Local library API: Implementors of :meth:`mopidy.local.Library.lookup` should now return a list of :class:`~mopidy.models.Track` instead of a single track, just like the other ``lookup()`` methods in Mopidy. For now, returning a From 94c418d5e60a5d75fa304608816ea024a7ece890 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Sat, 7 Mar 2015 22:42:22 +0100 Subject: [PATCH 135/314] Fix #1026: Sort local playlists by name. --- mopidy/local/playlists.py | 8 ++++++-- tests/local/test_playlists.py | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/mopidy/local/playlists.py b/mopidy/local/playlists.py index 1a3afa6e..ba4dbf02 100644 --- a/mopidy/local/playlists.py +++ b/mopidy/local/playlists.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, division, unicode_literals import glob import logging +import operator import os import sys @@ -29,6 +30,7 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): self._playlists[index] = playlist else: self._playlists.append(playlist) + self._playlists.sort(key=operator.attrgetter('name')) logger.info('Created playlist %s', playlist.uri) return playlist @@ -45,7 +47,8 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): self._playlists.remove(playlist) def lookup(self, uri): - # TODO: store as {uri: playlist}? + # TODO: store as {uri: playlist} when get_playlists() gets + # implemented for playlist in self._playlists: if playlist.uri == uri: return playlist @@ -66,7 +69,7 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): playlist = Playlist(uri=uri, name=name, tracks=tracks) playlists.append(playlist) - self.playlists = playlists + self.playlists = sorted(playlists, key=operator.attrgetter('name')) logger.info( 'Loaded %d local playlists from %s', @@ -95,6 +98,7 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): self._playlists[index] = playlist else: self._playlists.append(playlist) + self._playlists.sort(key=operator.attrgetter('name')) return playlist def _write_m3u_extinf(self, file_handle, track): diff --git a/tests/local/test_playlists.py b/tests/local/test_playlists.py index d52fed82..5af0debe 100644 --- a/tests/local/test_playlists.py +++ b/tests/local/test_playlists.py @@ -267,3 +267,27 @@ class LocalPlaylistsProviderTest(unittest.TestCase): playlist.name, backend.playlists.playlists[0].name) self.assertEqual( track.uri, backend.playlists.playlists[0].tracks[0].uri) + + def test_playlist_sort_order(self): + def check_order(playlists, names): + self.assertEqual(names, [playlist.name for playlist in playlists]) + + self.core.playlists.create('c') + self.core.playlists.create('a') + self.core.playlists.create('b') + + check_order(self.core.playlists.playlists, ['a', 'b', 'c']) + + self.core.playlists.refresh() + + check_order(self.core.playlists.playlists, ['a', 'b', 'c']) + + playlist = self.core.playlists.lookup('local:playlist:a.m3u') + playlist = playlist.copy(name='d') + playlist = self.core.playlists.save(playlist) + + check_order(self.core.playlists.playlists, ['b', 'c', 'd']) + + self.core.playlists.delete('local:playlist:c.m3u') + + check_order(self.core.playlists.playlists, ['b', 'd']) From cf0b666a0afa2c5b34eca8f472e8cf621e86f250 Mon Sep 17 00:00:00 2001 From: Lasse Bigum Date: Sun, 1 Mar 2015 15:22:21 +0100 Subject: [PATCH 136/314] Add tests for PlaybackController get_current_(tl_)track Add some more test cases for PlaybackController --- tests/core/test_playback.py | 70 +++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 11d63e04..3b6435c8 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -43,9 +43,51 @@ class CorePlaybackTest(unittest.TestCase): self.tl_tracks = self.core.tracklist.tl_tracks self.unplayable_tl_track = self.tl_tracks[2] - # TODO Test get_current_tl_track + def test_get_current_tl_track_none(self): + self.core.playback.set_current_tl_track(None) - # TODO Test get_current_track + self.assertEqual( + self.core.playback.get_current_tl_track(), None) + + def test_get_current_tl_track_play(self): + self.core.playback.play(self.tl_tracks[0]) + + self.assertEqual( + self.core.playback.get_current_tl_track(), self.tl_tracks[0]) + + def test_get_current_tl_track_next(self): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.next() + + self.assertEqual( + self.core.playback.get_current_tl_track(), self.tl_tracks[1]) + + def test_get_current_tl_track_prev(self): + self.core.playback.play(self.tl_tracks[1]) + self.core.playback.previous() + + self.assertEqual( + self.core.playback.get_current_tl_track(), self.tl_tracks[0]) + + def test_get_current_track_play(self): + self.core.playback.play(self.tl_tracks[0]) + + self.assertEqual( + self.core.playback.get_current_track(), self.tracks[0]) + + def test_get_current_track_next(self): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.next() + + self.assertEqual( + self.core.playback.get_current_track(), self.tracks[1]) + + def test_get_current_track_prev(self): + self.core.playback.play(self.tl_tracks[1]) + self.core.playback.previous() + + self.assertEqual( + self.core.playback.get_current_track(), self.tracks[0]) # TODO Test state @@ -385,6 +427,30 @@ class CorePlaybackTest(unittest.TestCase): 'track_playback_started', tl_track=self.tl_tracks[1]), ]) + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_seek_past_end_of_track_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + listener_mock.reset_mock() + + self.core.playback.seek(self.tracks[0].length * 5) + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='playing', new_state='stopped'), + mock.call( + 'track_playback_ended', + tl_track=self.tl_tracks[0], time_position=mock.ANY), + mock.call( + 'playback_state_changed', + old_state='stopped', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=self.tl_tracks[1]), + ]) + def test_seek_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) self.core.playback.seek(10000) From 0f52316d7791208d15d7111575369974db663cae Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Sun, 8 Mar 2015 19:04:57 +0100 Subject: [PATCH 137/314] docs: Add PR #1028 to changelog --- docs/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9dd398e3..548a574f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -89,6 +89,7 @@ v0.20.0 (UNRELEASED) better and fixes bug in deletion of playlists. (Fixes: :issue:`937`, PR: :issue:`995` and rebased into :issue:`1000`) +- Sort local playlists by name. (Fixes: :issue:`1026`, PR: :issue:`1028`) **File scanner** From 714ff0d64a15ad160ea1df64c7df0079a19384db Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 8 Mar 2015 21:16:31 +0100 Subject: [PATCH 138/314] docs: Fix real name of four contributors --- .mailmap | 4 ++++ AUTHORS | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.mailmap b/.mailmap index 7c0888ae..54e01b7d 100644 --- a/.mailmap +++ b/.mailmap @@ -5,6 +5,9 @@ Kristian Klette Johannes Knutsen Johannes Knutsen John Bäckstrand +David Caruso +Adam Rigg +Ernst Bammer Alli Witheford Alexandre Petitjean Alexandre Petitjean @@ -15,6 +18,7 @@ Janez Troha Janez Troha Luke Giuliani Colin Montgomerie +Nathan Harper Ignasi Fosch Christopher Schirner Laura Barber diff --git a/AUTHORS b/AUTHORS index 52ea4e34..91b71008 100644 --- a/AUTHORS +++ b/AUTHORS @@ -8,14 +8,14 @@ - John Bäckstrand - Fred Hatfull - Erling Børresen -- David C +- David Caruso - Christian Johansen - Matt Bray - Trygve Aaberge - Wouter van Wijk - Jeremy B. Merrill -- 0xadam -- herrernst +- Adam Rigg +- Ernst Bammer - Nick Steel - Zan Dobersek - Thomas Refis @@ -36,7 +36,7 @@ - Colin Montgomerie - Simon de Bakker - Arnaud Barisain-Monrose -- nathanharper +- Nathan Harper - Pierpaolo Frasa - Thomas Scholtes - Sam Willcocks From af87a17ff53210184f8d85549496d14b680d8cbe Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 8 Mar 2015 21:41:54 +0100 Subject: [PATCH 139/314] docs: Fix all warnings --- docs/clients/upnp.rst | 4 ---- mopidy/core/library.py | 2 +- mopidy/local/__init__.py | 4 ++-- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/docs/clients/upnp.rst b/docs/clients/upnp.rst index d0683df8..b5b18268 100644 --- a/docs/clients/upnp.rst +++ b/docs/clients/upnp.rst @@ -37,8 +37,6 @@ There are two ways Mopidy can be made available as an UPnP MediaRenderer: Using Mopidy-MPRIS and Rygel, or using Mopidy-MPD and upmpdcli. -.. _upmpdcli: - upmpdcli -------- @@ -68,8 +66,6 @@ MediaRenderer to the network, while using the MPD protocol to control Mopidy. 4. A UPnP renderer should be available now. -.. _rygel: - Rygel ----- diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 5937b2c0..49a4a796 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -83,7 +83,7 @@ class LibraryController(object): :param string field: One of ``artist``, ``albumartist``, ``album``, ``composer``, ``performer``, ``date``or ``genre``. :param dict query: Query to use for limiting results, see - :method:`search` for details about the query format. + :meth:`search` for details about the query format. :rtype: set of values corresponding to the requested field type. """ futures = [b.library.get_distinct(field, query) diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 1587b63a..97ed4a09 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -96,7 +96,7 @@ class Library(object): :param string field: One of ``artist``, ``albumartist``, ``album``, ``composer``, ``performer``, ``date``or ``genre``. :param dict query: Query to use for limiting results, see - :method:`search` for details about the query format. + :meth:`search` for details about the query format. :rtype: set of values corresponding to the requested field type. """ return set() @@ -159,7 +159,7 @@ class Library(object): :param track: Track to add to the library :type track: :class:`~mopidy.models.Track` :param tags: All the tags the scanner found for the media. See - :module:`mopidy.audio.utils` for details about the tags. + :mod:`mopidy.audio.utils` for details about the tags. :type tags: dictionary of tag keys with a list of values. :param duration: Duration of media in milliseconds or :class:`None` if unknown From e639b2b18ba56051138efa13911aedcf92f7fcc4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 7 Mar 2015 01:44:35 +0100 Subject: [PATCH 140/314] tests: Add method for emitting fake tags changed in tests --- tests/dummy_audio.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/dummy_audio.py b/tests/dummy_audio.py index 64639e91..b73946cb 100644 --- a/tests/dummy_audio.py +++ b/tests/dummy_audio.py @@ -109,6 +109,10 @@ class DummyAudio(pykka.ThreadingActor): def trigger_fake_playback_failure(self): self._state_change_result = False + def trigger_fake_tags_changed(self, tags): + self._tags = tags + audio.AudioListener.send('tags_changed', tags=self._tags.keys()) + def get_about_to_finish_callback(self): # This needs to be called from outside the actor or we lock up. def wrapper(): From cb19b2c48c1c59b0ecd7386f8e4d8ca6e0f43c65 Mon Sep 17 00:00:00 2001 From: Lasse Bigum Date: Sat, 7 Feb 2015 22:54:02 +0100 Subject: [PATCH 141/314] Allow 'none' as audio.mixer value To disable mixing altogether, you can now set the configuration value audio/mixer to 'none'. --- docs/changelog.rst | 7 ++ docs/config.rst | 2 + mopidy/commands.py | 14 +++- mopidy/core/mixer.py | 23 +++--- mopidy/mpd/protocol/audio_output.py | 19 +++-- mopidy/mpd/protocol/playback.py | 15 ++-- tests/core/test_listener.py | 3 + tests/core/test_mixer.py | 55 +++++++++++++++ tests/dummy_mixer.py | 4 ++ tests/mpd/protocol/__init__.py | 7 +- tests/mpd/protocol/test_audio_output.py | 93 +++++++++++++++++++++++++ tests/mpd/protocol/test_idle.py | 30 ++++++++ tests/mpd/protocol/test_playback.py | 16 +++++ tests/mpd/test_exceptions.py | 12 +++- 14 files changed, 274 insertions(+), 26 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index ca36454e..1d2f520d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -57,6 +57,9 @@ v0.20.0 (UNRELEASED) - Add debug logging of unknown sections. (Fixes: :issue:`694`, PR: :issue:`1002`) +- Add support for configuring :confval:`audio/mixer` to ``none``. (Fixes: + :issue:`936`) + **Logging** - Add custom log level ``TRACE`` (numerical level 5), which can be used by @@ -114,6 +117,10 @@ v0.20.0 (UNRELEASED) - Switch the ``list`` command over to using :meth:`mopidy.core.LibraryController.get_distinct`. (Fixes: :issue:`913`) +- Add support for ``toggleoutput`` command. The ``mixrampdb`` and + ``mixrampdelay`` commands are now supported but throw a NotImplemented + exception. + **HTTP frontend** - Prevent race condition in webservice broadcast from breaking the server. diff --git a/docs/config.rst b/docs/config.rst index 69945ab8..46b15635 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -70,6 +70,8 @@ Audio configuration will affect the audio volume if you're streaming the audio from Mopidy through Shoutcast. + If you want to disable audio mixing set the value to ``none``. + If you want to use a hardware mixer, you need to install a Mopidy extension which integrates with your sound subsystem. E.g. for ALSA, install `Mopidy-ALSAMixer `_. diff --git a/mopidy/commands.py b/mopidy/commands.py index d9b4ce0e..5df8dd5a 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -276,7 +276,9 @@ class RootCommand(Command): exit_status_code = 0 try: - mixer = self.start_mixer(config, mixer_class) + mixer = None + if mixer_class is not None: + mixer = self.start_mixer(config, mixer_class) audio = self.start_audio(config, mixer) backends = self.start_backends(config, backend_classes, audio) core = self.start_core(mixer, backends, audio) @@ -297,7 +299,8 @@ class RootCommand(Command): self.stop_core() self.stop_backends(backend_classes) self.stop_audio() - self.stop_mixer(mixer_class) + if mixer_class is not None: + self.stop_mixer(mixer_class) process.stop_remaining_actors() return exit_status_code @@ -306,13 +309,18 @@ class RootCommand(Command): 'Available Mopidy mixers: %s', ', '.join(m.__name__ for m in mixer_classes) or 'none') + if config['audio']['mixer'] == 'none': + logger.debug('Mixer disabled') + return None + selected_mixers = [ m for m in mixer_classes if m.name == config['audio']['mixer']] if len(selected_mixers) != 1: logger.error( 'Did not find unique mixer "%s". Alternatives are: %s', config['audio']['mixer'], - ', '.join([m.name for m in mixer_classes])) + ', '.join([m.name for m in mixer_classes]) + ', none' or + 'none') process.exit_process() return selected_mixers[0] diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index 4d77f8bc..1f5ada9e 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -11,8 +11,6 @@ class MixerController(object): def __init__(self, mixer): self._mixer = mixer - self._volume = None - self._mute = False def get_volume(self): """Get the volume. @@ -27,12 +25,15 @@ class MixerController(object): def set_volume(self, volume): """Set the volume. - The volume is defined as an integer in range [0..100]. + The volume is defined as an integer in range [0..100] or :class:`None` + if the mixer is disabled. The volume scale is linear. """ - if self._mixer is not None: - self._mixer.set_volume(volume) + if self._mixer is None: + return False + else: + return self._mixer.set_volume(volume).get() def get_mute(self): """Get mute state. @@ -40,13 +41,19 @@ class MixerController(object): :class:`True` if muted, :class:`False` unmuted, :class:`None` if unknown. """ - if self._mixer is not None: + if self._mixer is None: + return False + else: return self._mixer.get_mute().get() def set_mute(self, mute): """Set mute state. :class:`True` to mute, :class:`False` to unmute. + + Returns :class:`True` if call is successful, otherwise :class:`False`. """ - if self._mixer is not None: - self._mixer.set_mute(bool(mute)) + if self._mixer is None: + return False + else: + return self._mixer.set_mute(bool(mute)).get() diff --git a/mopidy/mpd/protocol/audio_output.py b/mopidy/mpd/protocol/audio_output.py index 0152f852..6ffedcf1 100644 --- a/mopidy/mpd/protocol/audio_output.py +++ b/mopidy/mpd/protocol/audio_output.py @@ -13,7 +13,9 @@ def disableoutput(context, outputid): Turns an output off. """ if outputid == 0: - context.core.mixer.set_mute(False) + success = context.core.mixer.set_mute(False).get() + if success is False: + raise exceptions.MpdSystemError('problems disabling output') else: raise exceptions.MpdNoExistError('No such audio output') @@ -28,13 +30,14 @@ def enableoutput(context, outputid): Turns an output on. """ if outputid == 0: - context.core.mixer.set_mute(True) + success = context.core.mixer.set_mute(True).get() + if success is False: + raise exceptions.MpdSystemError('problems enabling output') else: raise exceptions.MpdNoExistError('No such audio output') -# TODO: implement and test -# @protocol.commands.add('toggleoutput', outputid=protocol.UINT) +@protocol.commands.add('toggleoutput', outputid=protocol.UINT) def toggleoutput(context, outputid): """ *musicpd.org, audio output section:* @@ -43,7 +46,13 @@ def toggleoutput(context, outputid): Turns an output on or off, depending on the current state. """ - pass + if outputid == 0: + mute_status = context.core.mixer.get_mute().get() + success = context.core.mixer.set_mute(not mute_status) + if success is False: + raise exceptions.MpdSystemError('problems toggling output') + else: + raise exceptions.MpdNoExistError('No such audio output') @protocol.commands.add('outputs') diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index f7856a03..4cf8b2e8 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -32,8 +32,7 @@ def crossfade(context, seconds): raise exceptions.MpdNotImplemented # TODO -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add('mixrampdb') +@protocol.commands.add('mixrampdb') def mixrampdb(context, decibels): """ *musicpd.org, playback section:* @@ -46,11 +45,10 @@ def mixrampdb(context, decibels): volume so use negative values, I prefer -17dB. In the absence of mixramp tags crossfading will be used. See http://sourceforge.net/projects/mixramp """ - pass + raise exceptions.MpdNotImplemented # TODO -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add('mixrampdelay', seconds=protocol.UINT) +@protocol.commands.add('mixrampdelay', seconds=protocol.UINT) def mixrampdelay(context, seconds): """ *musicpd.org, playback section:* @@ -61,7 +59,7 @@ def mixrampdelay(context, seconds): value of "nan" disables MixRamp overlapping and falls back to crossfading. """ - pass + raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('next') @@ -397,7 +395,10 @@ def setvol(context, volume): - issues ``setvol 50`` without quotes around the argument. """ # NOTE: we use INT as clients can pass in +N etc. - context.core.mixer.set_volume(min(max(0, volume), 100)) + value = min(max(0, volume), 100) + success = context.core.mixer.set_volume(value).get() + if success is False: + raise exceptions.MpdSystemError('problems setting volume') @protocol.commands.add('single', state=protocol.BOOL) diff --git a/tests/core/test_listener.py b/tests/core/test_listener.py index 64003769..1338ec5e 100644 --- a/tests/core/test_listener.py +++ b/tests/core/test_listener.py @@ -57,3 +57,6 @@ class CoreListenerTest(unittest.TestCase): def test_listener_has_default_impl_for_seeked(self): self.listener.seeked(0) + + def test_listener_has_default_impl_for_current_metadata_changed(self): + self.listener.current_metadata_changed() diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py index 80e6f7ef..6485f3e8 100644 --- a/tests/core/test_mixer.py +++ b/tests/core/test_mixer.py @@ -4,7 +4,10 @@ import unittest import mock +import pykka + from mopidy import core, mixer +from tests import dummy_mixer class CoreMixerTest(unittest.TestCase): @@ -33,3 +36,55 @@ class CoreMixerTest(unittest.TestCase): self.core.mixer.set_mute(True) self.mixer.set_mute.assert_called_once_with(True) + + +class CoreNoneMixerTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.core = core.Core(mixer=None, backends=[]) + + def test_get_volume_return_none(self): + self.assertEqual(self.core.mixer.get_volume(), None) + + def test_set_volume_return_false(self): + self.assertEqual(self.core.mixer.set_volume(30), False) + + def test_get_set_mute_return_proper_state(self): + self.assertEqual(self.core.mixer.set_mute(False), False) + self.assertEqual(self.core.mixer.get_mute(), False) + self.assertEqual(self.core.mixer.set_mute(True), False) + self.assertEqual(self.core.mixer.get_mute(), False) + + +@mock.patch.object(mixer.MixerListener, 'send') +class CoreMixerListenerTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.mixer = dummy_mixer.create_proxy() + self.core = core.Core(mixer=self.mixer, backends=[]) + + def tearDown(self): # noqa: N802 + pykka.ActorRegistry.stop_all() + + def test_forwards_mixer_volume_changed_event_to_frontends(self, send): + self.assertEqual(self.core.mixer.set_volume(volume=60), True) + self.assertEqual(send.call_args[0][0], 'volume_changed') + self.assertEqual(send.call_args[1]['volume'], 60) + + def test_forwards_mixer_mute_changed_event_to_frontends(self, send): + self.core.mixer.set_mute(mute=True) + + self.assertEqual(send.call_args[0][0], 'mute_changed') + self.assertEqual(send.call_args[1]['mute'], True) + + +@mock.patch.object(mixer.MixerListener, 'send') +class CoreNoneMixerListenerTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.core = core.Core(mixer=None, backends=[]) + + def test_forwards_mixer_volume_changed_event_to_frontends(self, send): + self.assertEqual(self.core.mixer.set_volume(volume=60), False) + self.assertEqual(send.call_count, 0) + + def test_forwards_mixer_mute_changed_event_to_frontends(self, send): + self.core.mixer.set_mute(mute=True) + self.assertEqual(send.call_count, 0) diff --git a/tests/dummy_mixer.py b/tests/dummy_mixer.py index f7d90b17..6defddba 100644 --- a/tests/dummy_mixer.py +++ b/tests/dummy_mixer.py @@ -21,9 +21,13 @@ class DummyMixer(pykka.ThreadingActor, mixer.Mixer): def set_volume(self, volume): self._volume = volume + self.trigger_volume_changed(volume=volume) + return True def get_mute(self): return self._mute def set_mute(self, mute): self._mute = mute + self.trigger_mute_changed(mute=mute) + return True diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index b07a5ba3..88e3567b 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -25,6 +25,8 @@ class MockConnection(mock.Mock): class BaseTestCase(unittest.TestCase): + enable_mixer = True + def get_config(self): return { 'mpd': { @@ -33,7 +35,10 @@ class BaseTestCase(unittest.TestCase): } def setUp(self): # noqa: N802 - self.mixer = dummy_mixer.create_proxy() + if self.enable_mixer: + self.mixer = dummy_mixer.create_proxy() + else: + self.mixer = None self.backend = dummy_backend.create_proxy() self.core = core.Core.start( mixer=self.mixer, backends=[self.backend]).proxy() diff --git a/tests/mpd/protocol/test_audio_output.py b/tests/mpd/protocol/test_audio_output.py index a86f24f0..322bf181 100644 --- a/tests/mpd/protocol/test_audio_output.py +++ b/tests/mpd/protocol/test_audio_output.py @@ -4,6 +4,7 @@ from tests.mpd import protocol class AudioOutputHandlerTest(protocol.BaseTestCase): + def test_enableoutput(self): self.core.mixer.set_mute(False) @@ -50,3 +51,95 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): self.assertInResponse('outputname: Mute') self.assertInResponse('outputenabled: 1') self.assertInResponse('OK') + + def test_outputs_toggleoutput(self): + self.core.mixer.set_mute(False) + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 1') + self.assertInResponse('OK') + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 1') + self.assertInResponse('OK') + + def test_outputs_toggleoutput_unknown_outputid(self): + self.send_request('toggleoutput "7"') + + self.assertInResponse( + 'ACK [50@0] {toggleoutput} No such audio output') + + +class AudioOutputHandlerNoneMixerTest(protocol.BaseTestCase): + enable_mixer = False + + def test_enableoutput(self): + self.core.mixer.set_mute(False) + + self.send_request('enableoutput "0"') + self.assertInResponse( + 'ACK [52@0] {enableoutput} problems enabling output') + self.assertEqual(self.core.mixer.get_mute().get(), False) + + def test_disableoutput(self): + self.core.mixer.set_mute(True) + + self.send_request('disableoutput "0"') + self.assertInResponse( + 'ACK [52@0] {disableoutput} problems disabling output') + self.assertEqual(self.core.mixer.get_mute().get(), False) + + def test_outputs_when_unmuted(self): + self.core.mixer.set_mute(False) + + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') + + def test_outputs_when_muted(self): + self.core.mixer.set_mute(True) + + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') + + def test_outputs_toggleoutput(self): + self.core.mixer.set_mute(False) + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') diff --git a/tests/mpd/protocol/test_idle.py b/tests/mpd/protocol/test_idle.py index 0bd16992..e3c6ad38 100644 --- a/tests/mpd/protocol/test_idle.py +++ b/tests/mpd/protocol/test_idle.py @@ -50,6 +50,12 @@ class IdleHandlerTest(protocol.BaseTestCase): self.assertNoEvents() self.assertNoResponse() + def test_idle_output(self): + self.send_request('idle output') + self.assertEqualSubscriptions(['output']) + self.assertNoEvents() + self.assertNoResponse() + def test_idle_player_playlist(self): self.send_request('idle player playlist') self.assertEqualSubscriptions(['player', 'playlist']) @@ -102,6 +108,22 @@ class IdleHandlerTest(protocol.BaseTestCase): self.assertOnceInResponse('changed: player') self.assertOnceInResponse('OK') + def test_idle_then_output(self): + self.send_request('idle') + self.idle_event('output') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse('changed: output') + self.assertOnceInResponse('OK') + + def test_idle_output_then_event_output(self): + self.send_request('idle output') + self.idle_event('output') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse('changed: output') + self.assertOnceInResponse('OK') + def test_idle_player_then_noidle(self): self.send_request('idle player') self.send_request('noidle') @@ -206,3 +228,11 @@ class IdleHandlerTest(protocol.BaseTestCase): self.assertNotInResponse('changed: player') self.assertOnceInResponse('changed: playlist') self.assertOnceInResponse('OK') + + def test_output_then_idle_toggleoutput(self): + self.idle_event('output') + self.send_request('idle output') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse('changed: output') + self.assertOnceInResponse('OK') diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index ea9c59ce..4f3d6d7a 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -150,6 +150,14 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') self.assertInResponse('off') + def test_mixrampdb(self): + self.send_request('mixrampdb "10"') + self.assertInResponse('ACK [0@0] {mixrampdb} Not implemented') + + def test_mixrampdelay(self): + self.send_request('mixrampdelay "10"') + self.assertInResponse('ACK [0@0] {mixrampdelay} Not implemented') + @unittest.SkipTest def test_replay_gain_status_off(self): pass @@ -463,3 +471,11 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.send_request('stop') self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertInResponse('OK') + + +class PlaybackOptionsHandlerNoneMixerTest(protocol.BaseTestCase): + enable_mixer = False + + def test_setvol_max_error(self): + self.send_request('setvol "100"') + self.assertInResponse('ACK [52@0] {setvol} problems setting volume') diff --git a/tests/mpd/test_exceptions.py b/tests/mpd/test_exceptions.py index d055ef7e..7bb64096 100644 --- a/tests/mpd/test_exceptions.py +++ b/tests/mpd/test_exceptions.py @@ -3,8 +3,8 @@ from __future__ import absolute_import, unicode_literals import unittest from mopidy.mpd.exceptions import ( - MpdAckError, MpdNoCommand, MpdNotImplemented, MpdPermissionError, - MpdSystemError, MpdUnknownCommand) + MpdAckError, MpdNoCommand, MpdNoExistError, MpdNotImplemented, + MpdPermissionError, MpdSystemError, MpdUnknownCommand) class MpdExceptionsTest(unittest.TestCase): @@ -61,3 +61,11 @@ class MpdExceptionsTest(unittest.TestCase): self.assertEqual( e.get_mpd_ack(), 'ACK [4@0] {foo} you don\'t have permission for "foo"') + + def test_mpd_noexist_error(self): + try: + raise MpdNoExistError(command='foo') + except MpdNoExistError as e: + self.assertEqual( + e.get_mpd_ack(), + 'ACK [50@0] {foo} ') From 20e95eac077688f54bd700ffee6f2a80d81068c0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 10 Mar 2015 18:34:49 +0100 Subject: [PATCH 142/314] docs: Fix rST syntax error --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index ca36454e..9e3fb9d2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -74,7 +74,7 @@ v0.20.0 (UNRELEASED) - Add support for giving local libraries direct access to tags and duration. (Fixes: :issue:`967`) -- Add "--force" option for local scan (Fixes: :issue:'910', PR: :issue:'1010') +- Add "--force" option for local scan (Fixes: :issue:`910`, PR: :issue:`1010`) - Stop ignoring ``offset`` and ``limit`` in searches. (Fixes: :issue:`917`, PR: :issue:`949`) From 6fcd43891e7a2b97f3b8681cafd001e61248c96a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 10 Mar 2015 21:55:51 +0100 Subject: [PATCH 143/314] core: Switch to reference based stream info. - Adds tests for new behaviors in core. - Adds stream name to MPD format (fixes #944) - Adds 'stream_changed' core event (needs a new name/event) - Adds 'get_stream_reference' (which I'm also unsure about) The bits I'm unsure about are mostly with respect to #270, but I'm going ahead with this commit so we can discuss the details in PR with this code as an example. --- mopidy/core/actor.py | 30 +++------ mopidy/core/listener.py | 4 +- mopidy/core/playback.py | 30 +++++---- mopidy/mpd/actor.py | 2 +- mopidy/mpd/protocol/current_playlist.py | 25 +++---- mopidy/mpd/protocol/status.py | 8 +-- mopidy/mpd/translator.py | 6 +- tests/core/test_playback.py | 90 ++++++++++++++++++++++++- 8 files changed, 137 insertions(+), 58 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 19e49838..251f6e2c 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -5,9 +5,8 @@ import itertools import pykka -from mopidy import audio, backend, mixer +from mopidy import audio, backend, mixer, models from mopidy.audio import PlaybackState -from mopidy.audio.utils import convert_tags_to_track from mopidy.core.history import HistoryController from mopidy.core.library import LibraryController from mopidy.core.listener import CoreListener @@ -15,7 +14,6 @@ from mopidy.core.mixer import MixerController from mopidy.core.playback import PlaybackController from mopidy.core.playlists import PlaylistsController from mopidy.core.tracklist import TracklistController -from mopidy.models import TlTrack, Track from mopidy.utils import versioning from mopidy.utils.deprecation import deprecated_property @@ -88,6 +86,9 @@ class Core( def reached_end_of_stream(self): self.playback.on_end_of_track() + def stream_changed(self, uri): + self.playback.on_stream_changed(uri) + def state_changed(self, old_state, new_state, target_state): # XXX: This is a temporary fix for issue #232 while we wait for a more # permanent solution with the implementation of issue #234. When the @@ -116,30 +117,15 @@ class Core( CoreListener.send('mute_changed', mute=mute) def tags_changed(self, tags): - if not self.audio: - return - - current_tl_track = self.playback.get_current_tl_track() - if current_tl_track is None: + if not self.audio or 'title' not in tags: return tags = self.audio.get_current_tags().get() - if not tags: + if not tags or 'title' not in tags or not tags['title']: return - current_track = current_tl_track.track - tags_track = convert_tags_to_track(tags) - - track_kwargs = {k: v for k, v in current_track.__dict__.items() if v} - track_kwargs.update( - {k: v for k, v in tags_track.__dict__.items() if v}) - - self.playback._current_metadata_track = TlTrack(**{ - 'tlid': current_tl_track.tlid, - 'track': Track(**track_kwargs)}) - - # TODO Move this into playback.current_metadata_track setter? - CoreListener.send('current_metadata_changed') + self.playback._stream_ref = models.Ref.track(name=tags['title'][0]) + CoreListener.send('stream_changed') class Backends(list): diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index 9d952473..f013fa18 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -164,9 +164,9 @@ class CoreListener(listener.Listener): """ pass - def current_metadata_changed(self): + def stream_changed(self): """ - Called whenever current track's metadata changed + Called whenever the currently playing stream changes. *MAY* be implemented by actor. """ diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 0d604d61..6314442b 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -20,7 +20,7 @@ class PlaybackController(object): self.core = core self._current_tl_track = None - self._current_metadata_track = None + self._stream_ref = None self._state = PlaybackState.STOPPED def _get_backend(self): @@ -73,20 +73,23 @@ class PlaybackController(object): Use :meth:`get_current_track` instead. """ - def get_current_metadata_track(self): + def get_stream_reference(self): """ - Get a :class:`mopidy.models.TlTrack` with updated metadata for the - currently playing track. + Get additional information about the current stream. - Returns :class:`None` if no track is currently playing. + For most cases this value won't be set, but for radio streams it will + contain a reference with the name of the currently playing track or + program. Clients should show this when available. + + The :class:`mopidy.models.Ref` instance may or may not have an URI set. + If present you can call ``lookup`` on it to get the full metadata for + the URI. + + Returns a :class:`mopidy.models.Ref` instance representing the current + stream. If nothing is playing, or no stream info is available this will + return :class:`None`. """ - return self._current_metadata_track - - current_metadata_track = deprecated_property(get_current_metadata_track) - """ - .. deprecated:: 0.20 - Use :meth:`get_current_metadata_track` instead. - """ + return self._stream_ref def get_state(self): """Get The playback state.""" @@ -244,6 +247,9 @@ class PlaybackController(object): self.stop() self.set_current_tl_track(None) + def on_stream_changed(self, uri): + self._stream_ref = None + def next(self): """ Change to the next track. diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index b56e507d..2c63bcb2 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -74,5 +74,5 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): def mute_changed(self, mute): self.send_idle('output') - def current_metadata_changed(self): + def stream_changed(self): self.send_idle('playlist') diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index e083ea7c..fdd65bde 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -276,25 +276,20 @@ def plchanges(context, version): """ # XXX Naive implementation that returns all tracks as changed tracklist_version = context.core.tracklist.version.get() - iversion = int(version) - if iversion < tracklist_version: + if version < tracklist_version: return translator.tracks_to_mpd_format( context.core.tracklist.tl_tracks.get()) - elif iversion == tracklist_version: - # If version are equals, it is just a metadata update - # So we replace the updated track in playlist - current_md_track = context.core.playback.current_metadata_track.get() - if current_md_track is None: + elif version == tracklist_version: + # A version match could indicate this is just a metadata update, so + # check for a stream ref and let the client know about the change. + stream_ref = context.core.playback.get_stream_reference().get() + if stream_ref is None: return None - ntl_tracks = [] - tl_tracks = context.core.tracklist.tl_tracks.get() - for tl_track in tl_tracks: - if tl_track.tlid == current_md_track.tlid: - ntl_tracks.append(current_md_track) - else: - ntl_tracks.append(tl_track) - return translator.tracks_to_mpd_format(ntl_tracks) + tl_track = context.core.playback.current_tl_track.get() + position = context.core.tracklist.index(tl_track).get() + return translator.track_to_mpd_format( + tl_track, position=position, stream=stream_ref) @protocol.commands.add('plchangesposid', version=protocol.INT) diff --git a/mopidy/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py index d33e0afa..e2e73e6f 100644 --- a/mopidy/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -34,12 +34,12 @@ def currentsong(context): Displays the song info of the current song (same song that is identified in status). """ - tl_track = context.core.playback.current_metadata_track.get() - if tl_track is None: - tl_track = context.core.playback.current_tl_track.get() + tl_track = context.core.playback.current_tl_track.get() + stream = context.core.playback.get_stream_reference().get() if tl_track is not None: position = context.core.tracklist.index(tl_track).get() - return translator.track_to_mpd_format(tl_track, position=position) + return translator.track_to_mpd_format( + tl_track, position=position, stream=stream) @protocol.commands.add('idle', list_command=False) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 23fb2874..37c1493b 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -15,7 +15,7 @@ def normalize_path(path, relative=False): return '/'.join(parts) -def track_to_mpd_format(track, position=None): +def track_to_mpd_format(track, position=None, stream=None): """ Format track for output to MPD client. @@ -33,6 +33,7 @@ def track_to_mpd_format(track, position=None): (tlid, track) = track else: (tlid, track) = (None, track) + result = [ ('file', track.uri or ''), ('Time', track.length and (track.length // 1000) or 0), @@ -41,6 +42,9 @@ def track_to_mpd_format(track, position=None): ('Album', track.album and track.album.name or ''), ] + if stream and stream.name != track.name: + result.append(('Name', stream.name)) + if track.date: result.append(('Date', track.date)) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 3b6435c8..15d2d5f8 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -4,8 +4,12 @@ import unittest import mock +import pykka + from mopidy import backend, core -from mopidy.models import Track +from mopidy.models import Ref, Track + +from tests import dummy_audio as audio class CorePlaybackTest(unittest.TestCase): @@ -525,3 +529,87 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback2.get_time_position.called) # TODO Test on_tracklist_change + + +# Since we rely on our DummyAudio to actually emit events we need a "real" +# backend and not a mock so the right calls make it through to audio. +class TestBackend(pykka.ThreadingActor, backend.Backend): + uri_schemes = ['dummy'] + + def __init__(self, config, audio): + super(TestBackend, self).__init__() + self.playback = backend.PlaybackProvider(audio=audio, backend=self) + + +class TestStream(unittest.TestCase): + def setUp(self): # noqa: N802 + self.audio = audio.DummyAudio.start().proxy() + self.backend = TestBackend.start(config={}, audio=self.audio).proxy() + self.core = core.Core(audio=self.audio, backends=[self.backend]) + self.playback = self.core.playback + + self.tracks = [Track(uri='dummy:a', length=1234), + Track(uri='dummy:b', length=1234)] + + self.core.tracklist.add(self.tracks) + + self.events = [] + self.patcher = mock.patch('mopidy.audio.listener.AudioListener.send') + self.send_mock = self.patcher.start() + + def send(event, **kwargs): + self.events.append((event, kwargs)) + + self.send_mock.side_effect = send + + def tearDown(self): # noqa: N802 + pykka.ActorRegistry.stop_all() + self.patcher.stop() + + def replay_audio_events(self): + while self.events: + event, kwargs = self.events.pop(0) + self.core.on_event(event, **kwargs) + + def test_get_stream_reference_before_playback(self): + self.assertEqual(self.playback.get_stream_reference(), None) + + def test_get_stream_reference_during_playback(self): + self.core.playback.play() + + self.replay_audio_events() + self.assertEqual(self.playback.get_stream_reference(), None) + + def test_get_stream_reference_during_playback_with_tags_change(self): + self.core.playback.play() + self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() + + self.replay_audio_events() + expected = Ref.track(name='foobar') + self.assertEqual(self.playback.get_stream_reference(), expected) + + def test_get_stream_reference_after_next(self): + self.core.playback.play() + self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() + self.core.playback.next() + + self.replay_audio_events() + self.assertEqual(self.playback.get_stream_reference(), None) + + def test_get_stream_reference_after_next_with_tags_change(self): + self.core.playback.play() + self.audio.trigger_fake_tags_changed({'title': ['foo']}).get() + self.core.playback.next() + self.audio.trigger_fake_tags_changed({'title': ['bar']}).get() + + self.replay_audio_events() + expected = Ref.track(name='bar') + self.assertEqual(self.playback.get_stream_reference(), expected) + + def test_get_stream_reference_after_stop(self): + self.core.playback.play() + self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() + self.core.playback.stop() + + self.replay_audio_events() + self.assertEqual(self.playback.get_stream_reference(), None) From 9a507e17df0c5f3487fac6cff356fbf8697982bf Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 8 Mar 2015 21:51:28 +0100 Subject: [PATCH 144/314] docs: Improve pointer to contribution page --- docs/authors.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/authors.rst b/docs/authors.rst index 1a0f21ed..90ec6f23 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -14,7 +14,7 @@ our Git repository. .. include:: ../AUTHORS -If you already enjoy Mopidy, or don't enjoy it and want to help us making -Mopidy better, the best way to do so is to contribute back to the community. -You can contribute code, documentation, tests, bug reports, or help other -users, spreading the word, etc. See :ref:`contributing` for a head start. +If want to help us making Mopidy better, the best way to do so is to contribute +back to the community, either through code, documentation, tests, bug reports, +or by helping other users, spreading the word, etc. See :ref:`contributing` for +a head start. From e655d3938455d02bd7559e310493f97d0b4db5ce Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Thu, 12 Mar 2015 11:43:27 +0100 Subject: [PATCH 145/314] Fix #1031: Add get_images() to local library. --- docs/changelog.rst | 3 +++ mopidy/local/__init__.py | 23 ++++++++++++++++++++++- mopidy/local/library.py | 5 +++++ tests/local/test_library.py | 29 ++++++++++++++++++++++++++++- 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9e3fb9d2..64cc2ed0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -88,6 +88,9 @@ v0.20.0 (UNRELEASED) - Sort local playlists by name. (Fixes: :issue:`1026`, PR: :issue:`1028`) +- Add :meth:`mopidy.local.Library.get_images` for looking up images + for local URIs. (Fixes: :issue:`1031`, PR: :issue:`1032`) + **File scanner** - Improve error logging for scan code (Fixes: :issue:`856`, PR: :issue:`874`) diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 97ed4a09..eecaa4a2 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -4,7 +4,7 @@ import logging import os import mopidy -from mopidy import config, ext +from mopidy import config, ext, models logger = logging.getLogger(__name__) @@ -101,6 +101,27 @@ class Library(object): """ return set() + def get_images(self, uris): + """ + Lookup the images for the given URIs. + + The default implementation will simply call :meth:`lookup` and + try and use the album art for any tracks returned. Most local + libraries should replace this with something smarter or simply + return an empty dictionary. + + :param list uris: list of URIs to find images for + :rtype: {uri: tuple of :class:`mopidy.models.Image`} + """ + result = {} + for uri in uris: + image_uris = set() + for track in self.lookup(uri): + if track.album and track.album.images: + image_uris.update(track.album.images) + result[uri] = [models.Image(uri=u) for u in image_uris] + return result + def load(self): """ (Re)load any tracks stored in memory, if any, otherwise just return diff --git a/mopidy/local/library.py b/mopidy/local/library.py index 90a54770..77c122bd 100644 --- a/mopidy/local/library.py +++ b/mopidy/local/library.py @@ -28,6 +28,11 @@ class LocalLibraryProvider(backend.LibraryProvider): return set() return self._library.get_distinct(field, query) + def get_images(self, uris): + if not self._library: + return {} + return self._library.get_images(uris) + def refresh(self, uri=None): if not self._library: return 0 diff --git a/tests/local/test_library.py b/tests/local/test_library.py index 6cc1992e..13ad9405 100644 --- a/tests/local/test_library.py +++ b/tests/local/test_library.py @@ -11,7 +11,7 @@ import pykka from mopidy import core from mopidy.local import actor, json -from mopidy.models import Album, Artist, Track +from mopidy.models import Album, Artist, Image, Track from tests import path_to_data_dir @@ -580,3 +580,30 @@ class LocalLibraryProviderTest(unittest.TestCase): with self.assertRaises(LookupError): self.library.search(any=['']) + + def test_default_get_images_impl_no_images(self): + result = self.library.get_images([track.uri for track in self.tracks]) + self.assertEqual(result, {track.uri: tuple() for track in self.tracks}) + + @mock.patch.object(json.JsonLibrary, 'lookup') + def test_default_get_images_impl_album_images(self, mock_lookup): + library = actor.LocalBackend(config=self.config, audio=None).library + + image = Image(uri='imageuri') + album = Album(images=[image.uri]) + track = Track(uri='trackuri', album=album) + mock_lookup.return_value = [track] + + result = library.get_images([track.uri]) + self.assertEqual(result, {track.uri: [image]}) + + @mock.patch.object(json.JsonLibrary, 'get_images') + def test_local_library_get_images(self, mock_get_images): + library = actor.LocalBackend(config=self.config, audio=None).library + + image = Image(uri='imageuri') + track = Track(uri='trackuri') + mock_get_images.return_value = {track.uri: [image]} + + result = library.get_images([track.uri]) + self.assertEqual(result, {track.uri: [image]}) From 40c7225cb75c940d0e0774c51f1c5e2bec4e88e2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Mar 2015 22:11:33 +0100 Subject: [PATCH 146/314] local: Fix remainder display in local scan --- mopidy/local/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 798c10f8..279fda13 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -177,6 +177,6 @@ class _Progress(object): logger.info('Scanned %d of %d files in %ds.', self.count, self.total, duration) else: - remainder = duration // self.count * (self.total - self.count) + remainder = duration / self.count * (self.total - self.count) logger.info('Scanned %d of %d files in %ds, ~%ds left.', self.count, self.total, duration, remainder) From f4e6956bb749045b35179f99357c551f44d0dfda Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 11 Mar 2015 22:58:41 +0100 Subject: [PATCH 147/314] audio: Catch missing plugins in scanner for better error messages --- mopidy/audio/scan.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 38b86437..c3eec941 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -5,11 +5,14 @@ import time import pygst pygst.require('0.10') import gst # noqa +import gst.pbutils from mopidy import exceptions from mopidy.audio import utils from mopidy.utils import encoding +_missing_plugin_desc = gst.pbutils.missing_plugin_message_get_description + class Scanner(object): """ @@ -86,7 +89,11 @@ class Scanner(object): continue message = self._bus.pop() - if message.type == gst.MESSAGE_ERROR: + if message.type == gst.MESSAGE_ELEMENT: + if gst.pbutils.is_missing_plugin_message(message): + description = _missing_plugin_desc(message) + raise exceptions.ScannerError(description) + elif message.type == gst.MESSAGE_ERROR: raise exceptions.ScannerError( encoding.locale_decode(message.parse_error()[0])) elif message.type == gst.MESSAGE_EOS: From cee73b5501b7956ebc6cc5a5712fc31c3ff30523 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 11 Mar 2015 23:09:14 +0100 Subject: [PATCH 148/314] audio: Add support for checking seekable state in scanner Return type of scanner changed to a named tuple with (uri, tags, duration, seekable). This should help with #872 and the related "live" issues. Tests, local scan and stream metadata lookup have been updated to account for the changes. --- mopidy/audio/scan.py | 22 +++++++++++++++++----- mopidy/local/commands.py | 3 ++- mopidy/stream/actor.py | 6 +++--- tests/audio/test_scan.py | 6 +++--- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index c3eec941..d443b8bd 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, division, unicode_literals +import collections import time import pygst @@ -13,6 +14,9 @@ from mopidy.utils import encoding _missing_plugin_desc = gst.pbutils.missing_plugin_message_get_description +Result = collections.namedtuple( + 'Result', ('uri', 'tags', 'duration', 'seekable')) + class Scanner(object): """ @@ -54,19 +58,22 @@ class Scanner(object): :param uri: URI of the resource to scan. :type event: string - :return: (tags, duration) pair. tags is a dictionary of lists for all - the tags we found and duration is the length of the URI in - milliseconds, or :class:`None` if the URI has no duration. + :return: A named tuple containing ``(uri, tags, duration, seekable)``. + ``tags`` is a dictionary of lists for all the tags we found. + ``duration`` is the length of the URI in milliseconds, or + :class:`None` if the URI has no duration. ``seekable`` is boolean + indicating if a seek would succeed. """ - tags, duration = None, None + tags, duration, seekable = None, None, None try: self._setup(uri) tags = self._collect() duration = self._query_duration() + seekable = self._query_seekable() finally: self._reset() - return tags, duration + return Result(uri, tags, duration, seekable) def _setup(self, uri): """Primes the pipeline for collection.""" @@ -123,3 +130,8 @@ class Scanner(object): return None else: return duration // gst.MSECOND + + def _query_seekable(self): + query = gst.query_new_seeking(gst.FORMAT_TIME) + self._pipe.query(query) + return query.parse_seeking()[1] diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 279fda13..af8b0025 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -133,7 +133,8 @@ class ScanCommand(commands.Command): try: relpath = translator.local_track_uri_to_path(uri, media_dir) file_uri = path.path_to_uri(os.path.join(media_dir, relpath)) - tags, duration = scanner.scan(file_uri) + result = scanner.scan(file_uri) + tags, duration = result.tags, result.duration if duration < MIN_DURATION_MS: logger.warning('Failed %s: Track shorter than %dms', uri, MIN_DURATION_MS) diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index 58fd966a..47bfd58f 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -45,9 +45,9 @@ class StreamLibraryProvider(backend.LibraryProvider): return [Track(uri=uri)] try: - tags, duration = self._scanner.scan(uri) - track = utils.convert_tags_to_track(tags).copy( - uri=uri, length=duration) + result = self._scanner.scan(uri) + track = utils.convert_tags_to_track(result.tags).copy( + uri=uri, length=result.duration) except exceptions.ScannerError as e: logger.warning('Problem looking up %s: %s', uri, e) track = Track(uri=uri) diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index 50ec8352..b2937a3f 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -31,9 +31,9 @@ class ScannerTest(unittest.TestCase): uri = path_lib.path_to_uri(path) key = uri[len('file://'):] try: - tags, duration = scanner.scan(uri) - self.tags[key] = tags - self.durations[key] = duration + result = scanner.scan(uri) + self.tags[key] = result.tags + self.durations[key] = result.duration except exceptions.ScannerError as error: self.errors[key] = error From ccd3753b30514a02245f923c39d77a008bb8c847 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 11 Mar 2015 23:14:24 +0100 Subject: [PATCH 149/314] audio: Switch to decodebin2 in scanner and handle our own sources This is needed to be able to put in our own typefind and catch playlists before they make it to the decoder. --- mopidy/audio/scan.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index d443b8bd..c4fca6ad 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -29,24 +29,21 @@ class Scanner(object): def __init__(self, timeout=1000, proxy_config=None): self._timeout_ms = timeout + self._proxy_config = proxy_config or {} sink = gst.element_factory_make('fakesink') - - audio_caps = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') + self._src = None def pad_added(src, pad): return pad.link(sink.get_pad('sink')) - def source_setup(element, source): - utils.setup_proxy(source, proxy_config or {}) - - self._uribin = gst.element_factory_make('uridecodebin') - self._uribin.set_property('caps', audio_caps) - self._uribin.connect('pad-added', pad_added) - self._uribin.connect('source-setup', source_setup) + audio_caps = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') + self._decodebin = gst.element_factory_make('decodebin2') + self._decodebin.set_property('caps', audio_caps) + self._decodebin.connect('pad-added', pad_added) self._pipe = gst.element_factory_make('pipeline') - self._pipe.add(self._uribin) + self._pipe.add(self._decodebin) self._pipe.add(sink) self._bus = self._pipe.get_bus() @@ -78,8 +75,16 @@ class Scanner(object): def _setup(self, uri): """Primes the pipeline for collection.""" self._pipe.set_state(gst.STATE_READY) - self._uribin.set_property(b'uri', uri) + + self._src = gst.element_make_from_uri(gst.URI_SRC, uri) + utils.setup_proxy(self._src, self._proxy_config) + + self._pipe.add(self._src) + self._src.sync_state_with_parent() + self._src.link(self._decodebin) + self._bus.set_flushing(False) + result = self._pipe.set_state(gst.STATE_PAUSED) if result == gst.STATE_CHANGE_NO_PREROLL: # Live sources don't pre-roll, so set to playing to get data. @@ -119,6 +124,9 @@ class Scanner(object): """Ensures we cleanup child elements and flush the bus.""" self._bus.set_flushing(True) self._pipe.set_state(gst.STATE_NULL) + self._src.unlink(self._decodebin) + self._pipe.remove(self._src) + self._src = None def _query_duration(self): try: From cd579ff7bbd36b8a69fad05080d6a661b043cc95 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 11 Mar 2015 23:20:30 +0100 Subject: [PATCH 150/314] audio: Going to NULL already handles the flushing for us --- mopidy/audio/scan.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index c4fca6ad..84477def 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -47,7 +47,6 @@ class Scanner(object): self._pipe.add(sink) self._bus = self._pipe.get_bus() - self._bus.set_flushing(True) def scan(self, uri): """ @@ -83,8 +82,6 @@ class Scanner(object): self._src.sync_state_with_parent() self._src.link(self._decodebin) - self._bus.set_flushing(False) - result = self._pipe.set_state(gst.STATE_PAUSED) if result == gst.STATE_CHANGE_NO_PREROLL: # Live sources don't pre-roll, so set to playing to get data. @@ -121,8 +118,7 @@ class Scanner(object): raise exceptions.ScannerError('Timeout after %dms' % self._timeout_ms) def _reset(self): - """Ensures we cleanup child elements and flush the bus.""" - self._bus.set_flushing(True) + """Ensures we cleanup child elements.""" self._pipe.set_state(gst.STATE_NULL) self._src.unlink(self._decodebin) self._pipe.remove(self._src) From 24cceb69ebf2453b7f8cbd1f6c3126c39f2fd885 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 11 Mar 2015 23:21:41 +0100 Subject: [PATCH 151/314] audio: Going to ready is pointless in this code. --- mopidy/audio/scan.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 84477def..359f31cf 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -73,11 +73,8 @@ class Scanner(object): def _setup(self, uri): """Primes the pipeline for collection.""" - self._pipe.set_state(gst.STATE_READY) - self._src = gst.element_make_from_uri(gst.URI_SRC, uri) utils.setup_proxy(self._src, self._proxy_config) - self._pipe.add(self._src) self._src.sync_state_with_parent() self._src.link(self._decodebin) From c93eaad7ed53e9175a5b88636c1009ca10ae7804 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Mar 2015 00:16:02 +0100 Subject: [PATCH 152/314] audio: Try and reuse source when we can --- mopidy/audio/scan.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 359f31cf..87e60076 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -73,11 +73,21 @@ class Scanner(object): def _setup(self, uri): """Primes the pipeline for collection.""" - self._src = gst.element_make_from_uri(gst.URI_SRC, uri) - utils.setup_proxy(self._src, self._proxy_config) - self._pipe.add(self._src) - self._src.sync_state_with_parent() - self._src.link(self._decodebin) + protocol = gst.uri_get_protocol(uri) + if self._src and protocol not in self._src.get_protocols(): + self._src.unlink(self._decodebin) + self._pipe.remove(self._src) + self._src = None + + if not self._src: + self._src = gst.element_make_from_uri(gst.URI_SRC, uri) + utils.setup_proxy(self._src, self._proxy_config) + self._pipe.add(self._src) + self._src.sync_state_with_parent() + self._src.link(self._decodebin) + + self._pipe.set_state(gst.STATE_READY) + self._src.set_uri(uri) result = self._pipe.set_state(gst.STATE_PAUSED) if result == gst.STATE_CHANGE_NO_PREROLL: @@ -115,11 +125,7 @@ class Scanner(object): raise exceptions.ScannerError('Timeout after %dms' % self._timeout_ms) def _reset(self): - """Ensures we cleanup child elements.""" self._pipe.set_state(gst.STATE_NULL) - self._src.unlink(self._decodebin) - self._pipe.remove(self._src) - self._src = None def _query_duration(self): try: From 837f2de62985232d9b7140e334ac70816d54933b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Mar 2015 01:06:36 +0100 Subject: [PATCH 153/314] audio: Add typefinder to scanner and add mime to result This should allow us to move playlist handling out of GStreamer as we will short circuit for text/* and application/xml now. --- mopidy/audio/scan.py | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 87e60076..39cf172e 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -15,7 +15,7 @@ from mopidy.utils import encoding _missing_plugin_desc = gst.pbutils.missing_plugin_message_get_description Result = collections.namedtuple( - 'Result', ('uri', 'tags', 'duration', 'seekable')) + 'Result', ('uri', 'tags', 'duration', 'seekable', 'mime')) class Scanner(object): @@ -37,15 +37,25 @@ class Scanner(object): def pad_added(src, pad): return pad.link(sink.get_pad('sink')) + def have_type(finder, probability, caps): + msg = gst.message_new_application(finder, caps.get_structure(0)) + finder.get_bus().post(msg) + + self._typefinder = gst.element_factory_make('typefind') + self._typefinder.connect('have-type', have_type) + audio_caps = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') self._decodebin = gst.element_factory_make('decodebin2') self._decodebin.set_property('caps', audio_caps) self._decodebin.connect('pad-added', pad_added) self._pipe = gst.element_factory_make('pipeline') + self._pipe.add(self._typefinder) self._pipe.add(self._decodebin) self._pipe.add(sink) + self._typefinder.link(self._decodebin) + self._bus = self._pipe.get_bus() def scan(self, uri): @@ -54,28 +64,29 @@ class Scanner(object): :param uri: URI of the resource to scan. :type event: string - :return: A named tuple containing ``(uri, tags, duration, seekable)``. + :return: A named tuple containing + ``(uri, tags, duration, seekable, mime)``. ``tags`` is a dictionary of lists for all the tags we found. ``duration`` is the length of the URI in milliseconds, or - :class:`None` if the URI has no duration. ``seekable`` is boolean + :class:`None` if the URI has no duration. ``seekable`` is boolean. indicating if a seek would succeed. """ - tags, duration, seekable = None, None, None + tags, duration, seekable, mime = None, None, None, None try: self._setup(uri) - tags = self._collect() + tags, mime = self._collect() duration = self._query_duration() seekable = self._query_seekable() finally: self._reset() - return Result(uri, tags, duration, seekable) + return Result(uri, tags, duration, seekable, mime) def _setup(self, uri): """Primes the pipeline for collection.""" protocol = gst.uri_get_protocol(uri) if self._src and protocol not in self._src.get_protocols(): - self._src.unlink(self._decodebin) + self._src.unlink(self._typefinder) self._pipe.remove(self._src) self._src = None @@ -83,8 +94,7 @@ class Scanner(object): self._src = gst.element_make_from_uri(gst.URI_SRC, uri) utils.setup_proxy(self._src, self._proxy_config) self._pipe.add(self._src) - self._src.sync_state_with_parent() - self._src.link(self._decodebin) + self._src.link(self._typefinder) self._pipe.set_state(gst.STATE_READY) self._src.set_uri(uri) @@ -98,7 +108,7 @@ class Scanner(object): """Polls for messages to collect data.""" start = time.time() timeout_s = self._timeout_ms / 1000.0 - tags = {} + tags, mime = {}, None while time.time() - start < timeout_s: if not self._bus.have_pending(): @@ -109,14 +119,18 @@ class Scanner(object): if gst.pbutils.is_missing_plugin_message(message): description = _missing_plugin_desc(message) raise exceptions.ScannerError(description) + elif message.type == gst.MESSAGE_APPLICATION: + mime = message.structure.get_name() + if mime.startswith('text/') or mime == 'application/xml': + return tags, mime elif message.type == gst.MESSAGE_ERROR: raise exceptions.ScannerError( encoding.locale_decode(message.parse_error()[0])) elif message.type == gst.MESSAGE_EOS: - return tags + return tags, mime elif message.type == gst.MESSAGE_ASYNC_DONE: if message.src == self._pipe: - return tags + return tags, mime elif message.type == gst.MESSAGE_TAG: taglist = message.parse_tag() # Note that this will only keep the last tag. From 9c9d05be36616f528c9d79d7bc54e48d2e938283 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Mar 2015 21:55:17 +0100 Subject: [PATCH 154/314] audio: Only warn about missing plugin on errors --- mopidy/audio/scan.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 39cf172e..ed8e9eb9 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -108,7 +108,7 @@ class Scanner(object): """Polls for messages to collect data.""" start = time.time() timeout_s = self._timeout_ms / 1000.0 - tags, mime = {}, None + tags, mime, missing_description = {}, None, None while time.time() - start < timeout_s: if not self._bus.have_pending(): @@ -117,15 +117,17 @@ class Scanner(object): if message.type == gst.MESSAGE_ELEMENT: if gst.pbutils.is_missing_plugin_message(message): - description = _missing_plugin_desc(message) - raise exceptions.ScannerError(description) + missing_description = encoding.locale_decode( + _missing_plugin_desc(message)) elif message.type == gst.MESSAGE_APPLICATION: mime = message.structure.get_name() if mime.startswith('text/') or mime == 'application/xml': return tags, mime elif message.type == gst.MESSAGE_ERROR: - raise exceptions.ScannerError( - encoding.locale_decode(message.parse_error()[0])) + error = encoding.locale_decode(message.parse_error()[0]) + if missing_description: + error = '%s (%s)' % (missing_description, error) + raise exceptions.ScannerError(error) elif message.type == gst.MESSAGE_EOS: return tags, mime elif message.type == gst.MESSAGE_ASYNC_DONE: From 411bae5a56aaeb5e59e57bdb5939aa76f398511d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 12 Mar 2015 21:58:27 +0100 Subject: [PATCH 155/314] audio: Raise error for unknown protocol types --- mopidy/audio/scan.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index ed8e9eb9..c4516531 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -92,6 +92,9 @@ class Scanner(object): if not self._src: self._src = gst.element_make_from_uri(gst.URI_SRC, uri) + if not self._src: + raise exceptions.ScannerError('Could not find any elements to ' + 'handle %s URI.' % protocol) utils.setup_proxy(self._src, self._proxy_config) self._pipe.add(self._src) self._src.link(self._typefinder) From 628c8280877e85ba6f027ac3a691a5830e0d2243 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 13 Mar 2015 00:18:50 +0100 Subject: [PATCH 156/314] audio: Recreate scan pipeline for each scan Turns out this code runs a lot faster when we fully destroy the decodebins between scans. And since going to NULL isn't enough I opted to just go for redoing the whole pipeline instead of adding and removing decodebins all the time. As part of this almost all the logic has been ripped out of the scan class and into internal functions. The external interface has been kept the same for now. But we could easily switch to `scan(uri, timeout=1000, proxy=None)` --- mopidy/audio/scan.py | 203 +++++++++++++++++++++---------------------- 1 file changed, 98 insertions(+), 105 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index c4516531..50fb8700 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -14,10 +14,13 @@ from mopidy.utils import encoding _missing_plugin_desc = gst.pbutils.missing_plugin_message_get_description -Result = collections.namedtuple( +_Result = collections.namedtuple( 'Result', ('uri', 'tags', 'duration', 'seekable', 'mime')) +_RAW_AUDIO = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') + +# TODO: replace with a scan(uri, timeout=1000, proxy_config=None)? class Scanner(object): """ Helper to get tags and other relevant info from URIs. @@ -31,33 +34,6 @@ class Scanner(object): self._timeout_ms = timeout self._proxy_config = proxy_config or {} - sink = gst.element_factory_make('fakesink') - self._src = None - - def pad_added(src, pad): - return pad.link(sink.get_pad('sink')) - - def have_type(finder, probability, caps): - msg = gst.message_new_application(finder, caps.get_structure(0)) - finder.get_bus().post(msg) - - self._typefinder = gst.element_factory_make('typefind') - self._typefinder.connect('have-type', have_type) - - audio_caps = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') - self._decodebin = gst.element_factory_make('decodebin2') - self._decodebin.set_property('caps', audio_caps) - self._decodebin.connect('pad-added', pad_added) - - self._pipe = gst.element_factory_make('pipeline') - self._pipe.add(self._typefinder) - self._pipe.add(self._decodebin) - self._pipe.add(sink) - - self._typefinder.link(self._decodebin) - - self._bus = self._pipe.get_bus() - def scan(self, uri): """ Scan the given uri collecting relevant metadata. @@ -72,92 +48,109 @@ class Scanner(object): indicating if a seek would succeed. """ tags, duration, seekable, mime = None, None, None, None + pipeline = _setup_pipeline(uri, self._proxy_config) + try: - self._setup(uri) - tags, mime = self._collect() - duration = self._query_duration() - seekable = self._query_seekable() + _start_pipeline(pipeline) + tags, mime = _process(pipeline, self._timeout_ms / 1000.0) + duration = _query_duration(pipeline) + seekable = _query_seekable(pipeline) finally: - self._reset() + pipeline.set_state(gst.STATE_NULL) + del pipeline - return Result(uri, tags, duration, seekable, mime) + return _Result(uri, tags, duration, seekable, mime) - def _setup(self, uri): - """Primes the pipeline for collection.""" - protocol = gst.uri_get_protocol(uri) - if self._src and protocol not in self._src.get_protocols(): - self._src.unlink(self._typefinder) - self._pipe.remove(self._src) - self._src = None - if not self._src: - self._src = gst.element_make_from_uri(gst.URI_SRC, uri) - if not self._src: - raise exceptions.ScannerError('Could not find any elements to ' - 'handle %s URI.' % protocol) - utils.setup_proxy(self._src, self._proxy_config) - self._pipe.add(self._src) - self._src.link(self._typefinder) +# Turns out it's _much_ faster to just create a new pipeline for every as +# decodebins and other elements don't seem to take well to being reused. +def _setup_pipeline(uri, proxy_config=None): + src = gst.element_make_from_uri(gst.URI_SRC, uri) + if not src: + raise exceptions.ScannerError('GStreamer can not open: %s' % uri) - self._pipe.set_state(gst.STATE_READY) - self._src.set_uri(uri) + typefind = gst.element_factory_make('typefind') + decodebin = gst.element_factory_make('decodebin2') + sink = gst.element_factory_make('fakesink') - result = self._pipe.set_state(gst.STATE_PAUSED) - if result == gst.STATE_CHANGE_NO_PREROLL: - # Live sources don't pre-roll, so set to playing to get data. - self._pipe.set_state(gst.STATE_PLAYING) + pipeline = gst.element_factory_make('pipeline') + pipeline.add_many(src, typefind, decodebin, sink) + gst.element_link_many(src, typefind, decodebin) - def _collect(self): - """Polls for messages to collect data.""" - start = time.time() - timeout_s = self._timeout_ms / 1000.0 - tags, mime, missing_description = {}, None, None + if proxy_config: + utils.setup_proxy(src, proxy_config) - while time.time() - start < timeout_s: - if not self._bus.have_pending(): - continue - message = self._bus.pop() + decodebin.set_property('caps', _RAW_AUDIO) + decodebin.connect('pad-added', _pad_added, sink) + typefind.connect('have-type', _have_type, decodebin) - if message.type == gst.MESSAGE_ELEMENT: - if gst.pbutils.is_missing_plugin_message(message): - missing_description = encoding.locale_decode( - _missing_plugin_desc(message)) - elif message.type == gst.MESSAGE_APPLICATION: - mime = message.structure.get_name() - if mime.startswith('text/') or mime == 'application/xml': - return tags, mime - elif message.type == gst.MESSAGE_ERROR: - error = encoding.locale_decode(message.parse_error()[0]) - if missing_description: - error = '%s (%s)' % (missing_description, error) - raise exceptions.ScannerError(error) - elif message.type == gst.MESSAGE_EOS: + return pipeline + + +def _have_type(element, probability, caps, decodebin): + decodebin.set_property('sink-caps', caps) + msg = gst.message_new_application(element, caps.get_structure(0)) + element.get_bus().post(msg) + + +def _pad_added(element, pad, sink): + return pad.link(sink.get_pad('sink')) + + +def _start_pipeline(pipeline): + if pipeline.set_state(gst.STATE_PAUSED) == gst.STATE_CHANGE_NO_PREROLL: + pipeline.set_state(gst.STATE_PLAYING) + + +def _query_duration(pipeline): + try: + duration = pipeline.query_duration(gst.FORMAT_TIME, None)[0] + except gst.QueryError: + return None + + if duration < 0: + return None + else: + return duration // gst.MSECOND + + +def _query_seekable(pipeline): + query = gst.query_new_seeking(gst.FORMAT_TIME) + pipeline.query(query) + return query.parse_seeking()[1] + + +def _process(pipeline, timeout): + start = time.time() + tags, mime, missing_description = {}, None, None + bus = pipeline.get_bus() + + while time.time() - start < timeout: + if not bus.have_pending(): + continue + message = bus.pop() + + if message.type == gst.MESSAGE_ELEMENT: + if gst.pbutils.is_missing_plugin_message(message): + missing_description = encoding.locale_decode( + _missing_plugin_desc(message)) + elif message.type == gst.MESSAGE_APPLICATION: + mime = message.structure.get_name() + if mime.startswith('text/') or mime == 'application/xml': return tags, mime - elif message.type == gst.MESSAGE_ASYNC_DONE: - if message.src == self._pipe: - return tags, mime - elif message.type == gst.MESSAGE_TAG: - taglist = message.parse_tag() - # Note that this will only keep the last tag. - tags.update(utils.convert_taglist(taglist)) + elif message.type == gst.MESSAGE_ERROR: + error = encoding.locale_decode(message.parse_error()[0]) + if missing_description: + error = '%s (%s)' % (missing_description, error) + raise exceptions.ScannerError(error) + elif message.type == gst.MESSAGE_EOS: + return tags, mime + elif message.type == gst.MESSAGE_ASYNC_DONE: + if message.src == pipeline: + return tags, mime + elif message.type == gst.MESSAGE_TAG: + taglist = message.parse_tag() + # Note that this will only keep the last tag. + tags.update(utils.convert_taglist(taglist)) - raise exceptions.ScannerError('Timeout after %dms' % self._timeout_ms) - - def _reset(self): - self._pipe.set_state(gst.STATE_NULL) - - def _query_duration(self): - try: - duration = self._pipe.query_duration(gst.FORMAT_TIME, None)[0] - except gst.QueryError: - return None - - if duration < 0: - return None - else: - return duration // gst.MSECOND - - def _query_seekable(self): - query = gst.query_new_seeking(gst.FORMAT_TIME) - self._pipe.query(query) - return query.parse_seeking()[1] + raise exceptions.ScannerError('Timeout after %dms' % (timeout * 1000)) From 73bb6c2a8a18c6e6749878ec4ccd6d36389090e4 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Fri, 13 Mar 2015 19:10:57 +0100 Subject: [PATCH 157/314] Replace Mopidy-HTTP-Kuechenradio with Mopidy-Mobile. --- docs/ext/mobile.png | Bin 0 -> 88350 bytes docs/ext/web.rst | 14 +++++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 docs/ext/mobile.png diff --git a/docs/ext/mobile.png b/docs/ext/mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..983aa27cf394c7de26c0203b80c29cdeb02e7a5c GIT binary patch literal 88350 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

092SqyyM@YNg7?mh?3+EeJHV^*Ro+% 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 literal 0 HcmV?d00001 diff --git a/docs/ext/web.rst b/docs/ext/web.rst index 5abf5b15..425ecf78 100644 --- a/docs/ext/web.rst +++ b/docs/ext/web.rst @@ -30,17 +30,21 @@ To install, run:: pip install Mopidy-API-Explorer -Mopidy-HTTP-Kuechenradio +Mopidy-Mobile ========================= -https://github.com/tkem/mopidy-http-kuechenradio +https://github.com/tkem/mopidy-mobile -A deliberately simple Mopidy Web client for mobile devices. Made with jQuery -Mobile by Thomas Kemmer. +A Mopidy Web client extension and hybrid mobile app, made with Ionic, +AngularJS and Apache Cordova by Thomas Kemmer. + +.. image:: /ext/mobile.png + :width: 1024 + :height: 606 To install, run:: - pip install Mopidy-HTTP-Kuechenradio + pip install Mopidy-Mobile Mopidy-Moped From e4ef6d13caa70f91c51c2cb30462754f117e8ddf Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Mar 2015 21:04:37 +0100 Subject: [PATCH 158/314] core: Correct mixer.set_volume() docstring --- mopidy/core/mixer.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index 1f5ada9e..224c09df 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -25,10 +25,11 @@ class MixerController(object): def set_volume(self, volume): """Set the volume. - The volume is defined as an integer in range [0..100] or :class:`None` - if the mixer is disabled. + The volume is defined as an integer in range [0..100]. The volume scale is linear. + + Returns :class:`True` if call is successful, otherwise :class:`False`. """ if self._mixer is None: return False From b29f9e10c4ada28a07a9c977e9032d834795aa76 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Mar 2015 21:18:28 +0100 Subject: [PATCH 159/314] core: get_mute() with no mixer returns None ...and not False, because the mute state is unknown (None) and not unmuted (False) when there is no mixer. Note that this change does not affect the MPD responses. --- mopidy/core/mixer.py | 4 +--- tests/core/test_mixer.py | 12 ++++++------ tests/mpd/protocol/test_audio_output.py | 10 ++++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index 224c09df..3388d706 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -42,9 +42,7 @@ class MixerController(object): :class:`True` if muted, :class:`False` unmuted, :class:`None` if unknown. """ - if self._mixer is None: - return False - else: + if self._mixer is not None: return self._mixer.get_mute().get() def set_mute(self, mute): diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py index 6485f3e8..c4126eaa 100644 --- a/tests/core/test_mixer.py +++ b/tests/core/test_mixer.py @@ -42,17 +42,17 @@ class CoreNoneMixerTest(unittest.TestCase): def setUp(self): # noqa: N802 self.core = core.Core(mixer=None, backends=[]) - def test_get_volume_return_none(self): + def test_get_volume_return_none_because_it_is_unknown(self): self.assertEqual(self.core.mixer.get_volume(), None) - def test_set_volume_return_false(self): + def test_set_volume_return_false_because_it_failed(self): self.assertEqual(self.core.mixer.set_volume(30), False) - def test_get_set_mute_return_proper_state(self): - self.assertEqual(self.core.mixer.set_mute(False), False) - self.assertEqual(self.core.mixer.get_mute(), False) + def test_get_mute_return_none_because_it_is_unknown(self): + self.assertEqual(self.core.mixer.get_mute(), None) + + def test_set_mute_return_false_because_it_failed(self): self.assertEqual(self.core.mixer.set_mute(True), False) - self.assertEqual(self.core.mixer.get_mute(), False) @mock.patch.object(mixer.MixerListener, 'send') diff --git a/tests/mpd/protocol/test_audio_output.py b/tests/mpd/protocol/test_audio_output.py index 322bf181..b42b4c56 100644 --- a/tests/mpd/protocol/test_audio_output.py +++ b/tests/mpd/protocol/test_audio_output.py @@ -90,20 +90,22 @@ class AudioOutputHandlerNoneMixerTest(protocol.BaseTestCase): enable_mixer = False def test_enableoutput(self): - self.core.mixer.set_mute(False) + self.assertEqual(self.core.mixer.get_mute().get(), None) self.send_request('enableoutput "0"') self.assertInResponse( 'ACK [52@0] {enableoutput} problems enabling output') - self.assertEqual(self.core.mixer.get_mute().get(), False) + + self.assertEqual(self.core.mixer.get_mute().get(), None) def test_disableoutput(self): - self.core.mixer.set_mute(True) + self.assertEqual(self.core.mixer.get_mute().get(), None) self.send_request('disableoutput "0"') self.assertInResponse( 'ACK [52@0] {disableoutput} problems disabling output') - self.assertEqual(self.core.mixer.get_mute().get(), False) + + self.assertEqual(self.core.mixer.get_mute().get(), None) def test_outputs_when_unmuted(self): self.core.mixer.set_mute(False) From 9adb2c86a9ee2df65c280a0e042d045c6437b38e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Mar 2015 21:20:25 +0100 Subject: [PATCH 160/314] mpd: Make code read better The result of set_mute() and set_volume() is always True or False, never another falsy value like None. --- mopidy/mpd/protocol/audio_output.py | 6 +++--- mopidy/mpd/protocol/playback.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/mpd/protocol/audio_output.py b/mopidy/mpd/protocol/audio_output.py index 6ffedcf1..565ea3d0 100644 --- a/mopidy/mpd/protocol/audio_output.py +++ b/mopidy/mpd/protocol/audio_output.py @@ -14,7 +14,7 @@ def disableoutput(context, outputid): """ if outputid == 0: success = context.core.mixer.set_mute(False).get() - if success is False: + if not success: raise exceptions.MpdSystemError('problems disabling output') else: raise exceptions.MpdNoExistError('No such audio output') @@ -31,7 +31,7 @@ def enableoutput(context, outputid): """ if outputid == 0: success = context.core.mixer.set_mute(True).get() - if success is False: + if not success: raise exceptions.MpdSystemError('problems enabling output') else: raise exceptions.MpdNoExistError('No such audio output') @@ -49,7 +49,7 @@ def toggleoutput(context, outputid): if outputid == 0: mute_status = context.core.mixer.get_mute().get() success = context.core.mixer.set_mute(not mute_status) - if success is False: + if not success: raise exceptions.MpdSystemError('problems toggling output') else: raise exceptions.MpdNoExistError('No such audio output') diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index 4cf8b2e8..86f2e36b 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -397,7 +397,7 @@ def setvol(context, volume): # NOTE: we use INT as clients can pass in +N etc. value = min(max(0, volume), 100) success = context.core.mixer.set_volume(value).get() - if success is False: + if not success: raise exceptions.MpdSystemError('problems setting volume') From 4ce16ce6385ca02e940ee7dc9293799d35a20061 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 13 Mar 2015 21:31:33 +0100 Subject: [PATCH 161/314] docs: Fix header marker --- docs/ext/web.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ext/web.rst b/docs/ext/web.rst index 425ecf78..b4a9660f 100644 --- a/docs/ext/web.rst +++ b/docs/ext/web.rst @@ -31,7 +31,7 @@ To install, run:: Mopidy-Mobile -========================= +============= https://github.com/tkem/mopidy-mobile From 9e8b3263abf5fa8529dedeb46392f8f19eb723f4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 13 Mar 2015 22:36:35 +0100 Subject: [PATCH 162/314] audio: Use timed pop for message loop and gst clocks --- mopidy/audio/scan.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 50fb8700..cbf4c170 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -1,7 +1,6 @@ from __future__ import absolute_import, division, unicode_literals import collections -import time import pygst pygst.require('0.10') @@ -31,7 +30,7 @@ class Scanner(object): """ def __init__(self, timeout=1000, proxy_config=None): - self._timeout_ms = timeout + self._timeout_ms = int(timeout) self._proxy_config = proxy_config or {} def scan(self, uri): @@ -52,7 +51,7 @@ class Scanner(object): try: _start_pipeline(pipeline) - tags, mime = _process(pipeline, self._timeout_ms / 1000.0) + tags, mime = _process(pipeline, self._timeout_ms) duration = _query_duration(pipeline) seekable = _query_seekable(pipeline) finally: @@ -120,17 +119,19 @@ def _query_seekable(pipeline): return query.parse_seeking()[1] -def _process(pipeline, timeout): - start = time.time() - tags, mime, missing_description = {}, None, None +def _process(pipeline, timeout_ms): + clock = pipeline.get_clock() bus = pipeline.get_bus() + timeout = timeout_ms * gst.MSECOND + tags, mime, missing_description = {}, None, None - while time.time() - start < timeout: - if not bus.have_pending(): - continue - message = bus.pop() + start = clock.get_time() + while timeout > 0: + message = bus.timed_pop(timeout) - if message.type == gst.MESSAGE_ELEMENT: + if message is None: + break + elif message.type == gst.MESSAGE_ELEMENT: if gst.pbutils.is_missing_plugin_message(message): missing_description = encoding.locale_decode( _missing_plugin_desc(message)) @@ -153,4 +154,6 @@ def _process(pipeline, timeout): # Note that this will only keep the last tag. tags.update(utils.convert_taglist(taglist)) - raise exceptions.ScannerError('Timeout after %dms' % (timeout * 1000)) + timeout -= clock.get_time() - start + + raise exceptions.ScannerError('Timeout after %dms' % timeout_ms) From faab0b755af9ceb92b2f80b6a9654a670cf38f19 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 13 Mar 2015 22:39:52 +0100 Subject: [PATCH 163/314] audio: Filter for messages we care about, rest will be dropped --- mopidy/audio/scan.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index cbf4c170..3880d91a 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -125,9 +125,12 @@ def _process(pipeline, timeout_ms): timeout = timeout_ms * gst.MSECOND tags, mime, missing_description = {}, None, None + types = (gst.MESSAGE_ELEMENT | gst.MESSAGE_APPLICATION | gst.MESSAGE_ERROR + | gst.MESSAGE_EOS | gst.MESSAGE_ASYNC_DONE | gst.MESSAGE_TAG) + start = clock.get_time() while timeout > 0: - message = bus.timed_pop(timeout) + message = bus.timed_pop_filtered(timeout, types) if message is None: break From 6b7f9b4899555c8b2badadc4e2016e0f5ec3ee4d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 13 Mar 2015 22:45:57 +0100 Subject: [PATCH 164/314] docs: Add changelog for the scanner improvements --- docs/changelog.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9e3fb9d2..c5808833 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -144,6 +144,13 @@ v0.20.0 (UNRELEASED) - Update scanner to operate with milliseconds for duration. + - Update scanner to use a custom src, typefind and decodebin. This allows us + to catch playlists before we try to decode them. + + - Refactored scanner to create a new pipeline per song, this is needed as + reseting decodebin is much slower than tearing it down and making a fresh + one. + - Add :meth:`mopidy.audio.AudioListener.tags_changed`. Notifies core when new tags are found. @@ -163,6 +170,12 @@ v0.20.0 (UNRELEASED) - Add workaround for volume not persisting across tracks on OS X. (Issue: :issue:`886`, PR: :issue:`958`) +- Improved missing plugin error reporting in scanner. + +- Introduced a new return type for the scanner, a named tuple with ``uri``, + ``tags``, ``duration``, ``seekable`` and ``mime``. Also added support for + checking seekable, and the initial MIME type guess. + **Stream backend** - Add basic tests for the stream library provider. From 4db4b4d63b80510ec25805d3ecb39838c1d76f3b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 13 Mar 2015 23:56:51 +0100 Subject: [PATCH 165/314] core: Reduce stream metadata to just the title The API I really want for this to support regular tracks, stream updates and dynamic playlists is still unclear to me. As such I'm taking the KISS approach and reducing this to just the stream title and nothing else. If all goes as planed this will be replaced by playback_track_changed(tlid, ref) style events and other improvements in a later version. --- mopidy/core/actor.py | 7 ++++--- mopidy/core/listener.py | 4 ++-- mopidy/core/playback.py | 24 +++++------------------ mopidy/mpd/actor.py | 2 +- mopidy/mpd/protocol/current_playlist.py | 6 +++--- mopidy/mpd/protocol/status.py | 4 ++-- mopidy/mpd/translator.py | 12 +++++------- tests/core/test_playback.py | 26 ++++++++++++------------- 8 files changed, 34 insertions(+), 51 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 251f6e2c..ed1c33ab 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -5,7 +5,7 @@ import itertools import pykka -from mopidy import audio, backend, mixer, models +from mopidy import audio, backend, mixer from mopidy.audio import PlaybackState from mopidy.core.history import HistoryController from mopidy.core.library import LibraryController @@ -124,8 +124,9 @@ class Core( if not tags or 'title' not in tags or not tags['title']: return - self.playback._stream_ref = models.Ref.track(name=tags['title'][0]) - CoreListener.send('stream_changed') + title = tags['title'][0] + self.playback._stream_title = title + CoreListener.send('stream_title_changed', title=title) class Backends(list): diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index f013fa18..3ae03925 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -164,9 +164,9 @@ class CoreListener(listener.Listener): """ pass - def stream_changed(self): + def stream_title_changed(self, title): """ - Called whenever the currently playing stream changes. + Called whenever the currently playing stream title changes. *MAY* be implemented by actor. """ diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 6314442b..e92563dd 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -20,7 +20,7 @@ class PlaybackController(object): self.core = core self._current_tl_track = None - self._stream_ref = None + self._stream_title = None self._state = PlaybackState.STOPPED def _get_backend(self): @@ -73,23 +73,9 @@ class PlaybackController(object): Use :meth:`get_current_track` instead. """ - def get_stream_reference(self): - """ - Get additional information about the current stream. - - For most cases this value won't be set, but for radio streams it will - contain a reference with the name of the currently playing track or - program. Clients should show this when available. - - The :class:`mopidy.models.Ref` instance may or may not have an URI set. - If present you can call ``lookup`` on it to get the full metadata for - the URI. - - Returns a :class:`mopidy.models.Ref` instance representing the current - stream. If nothing is playing, or no stream info is available this will - return :class:`None`. - """ - return self._stream_ref + def get_stream_title(self): + """Get the current stream title or :class:`None`.""" + return self._stream_title def get_state(self): """Get The playback state.""" @@ -248,7 +234,7 @@ class PlaybackController(object): self.set_current_tl_track(None) def on_stream_changed(self, uri): - self._stream_ref = None + self._stream_title = None def next(self): """ diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index 2c63bcb2..2aecb6d1 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -74,5 +74,5 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): def mute_changed(self, mute): self.send_idle('output') - def stream_changed(self): + def stream_title_changed(self, title): self.send_idle('playlist') diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index fdd65bde..d8e1a9d8 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -282,14 +282,14 @@ def plchanges(context, version): elif version == tracklist_version: # A version match could indicate this is just a metadata update, so # check for a stream ref and let the client know about the change. - stream_ref = context.core.playback.get_stream_reference().get() - if stream_ref is None: + stream_title = context.core.playback.get_stream_title().get() + if stream_title is None: return None tl_track = context.core.playback.current_tl_track.get() position = context.core.tracklist.index(tl_track).get() return translator.track_to_mpd_format( - tl_track, position=position, stream=stream_ref) + tl_track, position=position, stream_title=stream_title) @protocol.commands.add('plchangesposid', version=protocol.INT) diff --git a/mopidy/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py index e2e73e6f..aa78b387 100644 --- a/mopidy/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -35,11 +35,11 @@ def currentsong(context): identified in status). """ tl_track = context.core.playback.current_tl_track.get() - stream = context.core.playback.get_stream_reference().get() + stream_title = context.core.playback.get_stream_title().get() if tl_track is not None: position = context.core.tracklist.index(tl_track).get() return translator.track_to_mpd_format( - tl_track, position=position, stream=stream) + tl_track, position=position, stream_title=stream_title) @protocol.commands.add('idle', list_command=False) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 37c1493b..10207a69 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -15,7 +15,7 @@ def normalize_path(path, relative=False): return '/'.join(parts) -def track_to_mpd_format(track, position=None, stream=None): +def track_to_mpd_format(track, position=None, stream_title=None): """ Format track for output to MPD client. @@ -23,10 +23,8 @@ def track_to_mpd_format(track, position=None, stream=None): :type track: :class:`mopidy.models.Track` or :class:`mopidy.models.TlTrack` :param position: track's position in playlist :type position: integer - :param key: if we should set key - :type key: boolean - :param mtime: if we should set mtime - :type mtime: boolean + :param stream_title: The current streams title. + :type position: string :rtype: list of two-tuples """ if isinstance(track, TlTrack): @@ -42,8 +40,8 @@ def track_to_mpd_format(track, position=None, stream=None): ('Album', track.album and track.album.name or ''), ] - if stream and stream.name != track.name: - result.append(('Name', stream.name)) + if stream_title: + result.append(('Name', stream_title)) if track.date: result.append(('Date', track.date)) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 15d2d5f8..80efc38a 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -571,45 +571,43 @@ class TestStream(unittest.TestCase): event, kwargs = self.events.pop(0) self.core.on_event(event, **kwargs) - def test_get_stream_reference_before_playback(self): - self.assertEqual(self.playback.get_stream_reference(), None) + def test_get_stream_title_before_playback(self): + self.assertEqual(self.playback.get_stream_title(), None) - def test_get_stream_reference_during_playback(self): + def test_get_stream_title_during_playback(self): self.core.playback.play() self.replay_audio_events() - self.assertEqual(self.playback.get_stream_reference(), None) + self.assertEqual(self.playback.get_stream_title(), None) - def test_get_stream_reference_during_playback_with_tags_change(self): + def test_get_stream_title_during_playback_with_tags_change(self): self.core.playback.play() self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() self.replay_audio_events() - expected = Ref.track(name='foobar') - self.assertEqual(self.playback.get_stream_reference(), expected) + self.assertEqual(self.playback.get_stream_title(), 'foobar') - def test_get_stream_reference_after_next(self): + def test_get_stream_title_after_next(self): self.core.playback.play() self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() self.core.playback.next() self.replay_audio_events() - self.assertEqual(self.playback.get_stream_reference(), None) + self.assertEqual(self.playback.get_stream_title(), None) - def test_get_stream_reference_after_next_with_tags_change(self): + def test_get_stream_title_after_next_with_tags_change(self): self.core.playback.play() self.audio.trigger_fake_tags_changed({'title': ['foo']}).get() self.core.playback.next() self.audio.trigger_fake_tags_changed({'title': ['bar']}).get() self.replay_audio_events() - expected = Ref.track(name='bar') - self.assertEqual(self.playback.get_stream_reference(), expected) + self.assertEqual(self.playback.get_stream_title(), 'bar') - def test_get_stream_reference_after_stop(self): + def test_get_stream_title_after_stop(self): self.core.playback.play() self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() self.core.playback.stop() self.replay_audio_events() - self.assertEqual(self.playback.get_stream_reference(), None) + self.assertEqual(self.playback.get_stream_title(), None) From 3a61445519473a00904feaed9af10f1e8bdca508 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 00:05:10 +0100 Subject: [PATCH 166/314] models: Change Track.last_modified from seconds to ms --- docs/changelog.rst | 6 ++++++ mopidy/models.py | 7 ++++--- mopidy/utils/path.py | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f06f291d..6c111ff2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,6 +13,12 @@ v0.20.0 (UNRELEASED) - Add :class:`mopidy.models.Image` model to be returned by :meth:`mopidy.core.LibraryController.get_images`. (Part of :issue:`973`) +- Change the semantics of :attr:`mopidy.models.Track.last_modified` to be + milliseconds instead of seconds since Unix epoch, or a simple counter, + depending on the source of the track. This makes it match the semantics of + :attr:`mopidy.models.Playlist.last_modified`. (Fixes: :issue:`678`, PR: + :issue:`1036`) + **Core API** - Deprecate all properties in the core API. The previously undocumented getter diff --git a/mopidy/models.py b/mopidy/models.py index 4d6ed27d..c0931855 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -378,9 +378,10 @@ class Track(ImmutableObject): #: The MusicBrainz ID of the track. Read-only. musicbrainz_id = None - #: Integer representing when the track was last modified, exact meaning - #: depends on source of track. For local files this is the mtime, for other - #: backends it could be a timestamp or simply a version counter. + #: Integer representing when the track was last modified. Exact meaning + #: depends on source of track. For local files this is the modification + #: time in milliseconds since Unix epoch. For other backends it could be an + #: equivalent timestamp or simply a version counter. last_modified = None def __init__(self, *args, **kwargs): diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index c72d3b18..0c0d6676 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -200,7 +200,7 @@ def _find(root, thread_count=10, relative=False, follow=False): def find_mtimes(root, follow=False): results, errors = _find(root, relative=False, follow=follow) - mtimes = dict((f, int(st.st_mtime)) for f, st in results.items()) + mtimes = dict((f, int(st.st_mtime * 1000)) for f, st in results.items()) return mtimes, errors From ea97047607b31303c01cd87ca22ea43ac53182dd Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 Mar 2015 00:10:21 +0100 Subject: [PATCH 167/314] flake8: Fix bad import --- tests/core/test_playback.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 80efc38a..8911978a 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -7,7 +7,7 @@ import mock import pykka from mopidy import backend, core -from mopidy.models import Ref, Track +from mopidy.models import Track from tests import dummy_audio as audio From 6d50f835a4a2374a2c8c9635ccd5ff56e35980c5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 Mar 2015 00:22:22 +0100 Subject: [PATCH 168/314] review: docstring update for mpd translator --- mopidy/mpd/translator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 10207a69..77adecd0 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -23,7 +23,7 @@ def track_to_mpd_format(track, position=None, stream_title=None): :type track: :class:`mopidy.models.Track` or :class:`mopidy.models.TlTrack` :param position: track's position in playlist :type position: integer - :param stream_title: The current streams title. + :param stream_title: the current streams title :type position: string :rtype: list of two-tuples """ From 36fe8321b112542661ef20cf5753350df8415f6a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 Mar 2015 00:25:20 +0100 Subject: [PATCH 169/314] docs: Add changelog entry for stream title stuff --- docs/changelog.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index ca36454e..c354f2b9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -40,6 +40,10 @@ v0.20.0 (UNRELEASED) - Add :meth:`mopidy.core.LibraryController.get_distinct` for getting unique values for a given field. (Fixes: :issue:`913`, PR: :issue:`1022`) +- Add :meth:`mopidy.core.Listener.stream_title_changed` and + :meth:`mopidy.core.PlaybackController.get_stream_title` for letting clients + know about the current song in streams. + **Commands** - Make the ``mopidy`` command print a friendly error message if the @@ -114,6 +118,9 @@ v0.20.0 (UNRELEASED) - Switch the ``list`` command over to using :meth:`mopidy.core.LibraryController.get_distinct`. (Fixes: :issue:`913`) +- Start setting the ``Name`` field which is used for radio streams. + (Fixes: :issue:`944`) + **HTTP frontend** - Prevent race condition in webservice broadcast from breaking the server. From 6260ba00bec4bce4e607ce93ef1fde9aaf2d47f3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 14 Mar 2015 00:30:46 +0100 Subject: [PATCH 170/314] core: Test stream_title_changed listener --- tests/core/test_listener.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/core/test_listener.py b/tests/core/test_listener.py index 1338ec5e..8ec3a843 100644 --- a/tests/core/test_listener.py +++ b/tests/core/test_listener.py @@ -58,5 +58,5 @@ class CoreListenerTest(unittest.TestCase): def test_listener_has_default_impl_for_seeked(self): self.listener.seeked(0) - def test_listener_has_default_impl_for_current_metadata_changed(self): - self.listener.current_metadata_changed() + def test_listener_has_default_impl_for_stream_title_changed(self): + self.listener.stream_title_changed('foobar') From abe9b7aea76ebdbfa4eb0883a3417bdcd566e73b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 01:17:52 +0100 Subject: [PATCH 171/314] docs: Initial cleanup of v0.20 changelog --- docs/changelog.rst | 101 +++++++++++++++++++++++---------------------- 1 file changed, 52 insertions(+), 49 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1828b108..74c37366 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -30,7 +30,7 @@ v0.20.0 (UNRELEASED) - Add :class:`mopidy.core.MixerController` which keeps track of volume and mute. The old methods on :class:`mopidy.core.PlaybackController` for volume - and mute management has been deprecated. (Fixes: :issue:`962`) + and mute management have been deprecated. (Fixes: :issue:`962`) - Remove ``clear_current_track`` keyword argument to :meth:`mopidy.core.PlaybackController.stop`. It was a leaky internal @@ -48,7 +48,7 @@ v0.20.0 (UNRELEASED) - Add :meth:`mopidy.core.Listener.stream_title_changed` and :meth:`mopidy.core.PlaybackController.get_stream_title` for letting clients - know about the current song in streams. + know about the current song in streams. (PR: :issue:`938`, :issue:`1030`) **Commands** @@ -67,50 +67,49 @@ v0.20.0 (UNRELEASED) - Add debug logging of unknown sections. (Fixes: :issue:`694`, PR: :issue:`1002`) -- Add support for configuring :confval:`audio/mixer` to ``none``. (Fixes: - :issue:`936`) - **Logging** - Add custom log level ``TRACE`` (numerical level 5), which can be used by Mopidy and extensions to log at an even more detailed level than ``DEBUG``. -- Add support for per logger color overrides. (Fixes: :issue:`808`) +- Add support for per logger color overrides. (Fixes: :issue:`808`, PR: + :issue:`1005`) **Local backend** -- Local library API: Implementors of :meth:`mopidy.local.Library.lookup` should - now return a list of :class:`~mopidy.models.Track` instead of a single track, - just like the other ``lookup()`` methods in Mopidy. For now, returning a - single track will continue to work. (PR: :issue:`840`) +- Improve error logging for scanner. (Fixes: :issue:`856`, PR: :issue:`874`) -- Add support for giving local libraries direct access to tags and duration. - (Fixes: :issue:`967`) +- Add symlink support with loop protection to file finder. (Fixes: + :issue:`858`, PR: :issue:`874`) -- Add "--force" option for local scan (Fixes: :issue:`910`, PR: :issue:`1010`) +- Add ``--force`` option for ``mopidy local scan`` for forcing a full rescan of + the library. (Fixes: :issue:`910`, PR: :issue:`1010`) -- Stop ignoring ``offset`` and ``limit`` in searches. (Fixes: :issue:`917`, - PR: :issue:`949`) +- Stop ignoring ``offset`` and ``limit`` in searches when using the default + JSON backed local library. (Fixes: :issue:`917`, PR: :issue:`949`) - Removed double triggering of ``playlists_loaded`` event. (Fixes: :issue:`998`, PR: :issue:`999`) - Cleanup and refactoring of local playlist code. Preserves playlist names - better and fixes bug in deletion of playlists. (Fixes: :issue:`937`, + better and fixes bug in deletion of playlists. (Fixes: :issue:`937`, PR: :issue:`995` and rebased into :issue:`1000`) - Sort local playlists by name. (Fixes: :issue:`1026`, PR: :issue:`1028`) +**Local library API** + +- Implementors of :meth:`mopidy.local.Library.lookup` should now return a list + of :class:`~mopidy.models.Track` instead of a single track, just like the + other ``lookup()`` methods in Mopidy. For now, returning a single track will + continue to work. (PR: :issue:`840`) + +- Add support for giving local libraries direct access to tags and duration. + (Fixes: :issue:`967`) + - Add :meth:`mopidy.local.Library.get_images` for looking up images for local URIs. (Fixes: :issue:`1031`, PR: :issue:`1032`) -**File scanner** - -- Improve error logging for scan code (Fixes: :issue:`856`, PR: :issue:`874`) - -- Add symlink support with loop protection to file finder (Fixes: :issue:`858`, - PR: :issue:`874`) - **MPD frontend** - In stored playlist names, replace "/", which are illegal, with "|" instead of @@ -128,26 +127,40 @@ v0.20.0 (UNRELEASED) :confval:`mpd/command_blacklist`. - Switch the ``list`` command over to using - :meth:`mopidy.core.LibraryController.get_distinct`. (Fixes: :issue:`913`) + :meth:`mopidy.core.LibraryController.get_distinct` for increased performance. + (Fixes: :issue:`913`) -- Add support for ``toggleoutput`` command. The ``mixrampdb`` and - ``mixrampdelay`` commands are now supported but throw a NotImplemented - exception. +- Add support for ``toggleoutput`` command. (PR: :issue:`1015`) -- Start setting the ``Name`` field which is used for radio streams. - (Fixes: :issue:`944`) +- The ``mixrampdb`` and ``mixrampdelay`` commands are now known to Mopidy, but + are not implemented. (PR: :issue:`1015`) + +- Start setting the ``Name`` field with the stream title when listening to + radio streams. (Fixes: :issue:`944`, PR: :issue:`1030`) **HTTP frontend** -- Prevent race condition in webservice broadcast from breaking the server. +- Prevent race condition in WebSocket broadcast from breaking the web server. (PR: :issue:`1020`) +**Mixer** + +- Add support for disabling volume control in Mopidy entirely by setting the + configuration :confval:`audio/mixer` to ``none``. (Fixes: :issue:`936`, PR: + :issue:`1015`, :issue:`1035`) + **Audio** - Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. Pass a :class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` to end the stream. +- Kill support for visualizers. Feature was originally added as a workaround for + all the people asking for ncmpcpp visualizer support. And since we could get + it almost for free thanks to GStreamer. But this feature didn't really ever + make sense for a server such as Mopidy. Currently the only way to find out if + it is in use and will be missed is to go ahead and remove it. + - Internal code cleanup within audio subsystem: - Started splitting audio code into smaller better defined pieces. @@ -182,22 +195,20 @@ v0.20.0 (UNRELEASED) - Move and rename helper for converting tags to tracks. - - Helper now ignores albums without a name. +- Ignore albums without a name when converting tags to tracks. -- Kill support for visualizers. Feature was originally added as a workaround for - all the people asking for ncmpcpp visualizer support. And since we could get - it almost for free thanks to GStreamer. But this feature didn't really ever - make sense for a server such as Mopidy. Currently the only way to find out if - it is in use and will be missed is to go ahead and remove it. +- Support UTF-8 in M3U playlists. (Fixes: :issue:`853`) - Add workaround for volume not persisting across tracks on OS X. (Issue: :issue:`886`, PR: :issue:`958`) -- Improved missing plugin error reporting in scanner. +- Improved missing plugin error reporting in scanner. (PR: :issue:`1033`) - Introduced a new return type for the scanner, a named tuple with ``uri``, - ``tags``, ``duration``, ``seekable`` and ``mime``. Also added support for - checking seekable, and the initial MIME type guess. + ``tags``, ``duration``, ``seekable`` and ``mime``. (PR: :issue:`1033`) + +- Added support for checking if the media is seekable, and getting the initial + MIME type guess. (PR: :issue:`1033`) **Stream backend** @@ -221,19 +232,11 @@ This version has been released to npm as Mopidy.js v0.5.0. **Development** +- Speed up event emitting. + - Changed test runner from nose to py.test. (PR: :issue:`1024`) -v0.19.6 (UNRELEASED) -==================== - -Bug fix release. - -- Audio: Support UTF-8 in M3U playlists. (Fixes: :issue:`853`) - -- Events: Speed up event emitting. - - v0.19.5 (2014-12-23) ==================== From 29b4a2075aef4ad6375a35bca3f245ad7a3ef128 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Sat, 14 Mar 2015 16:12:46 +0100 Subject: [PATCH 172/314] local: Fix get_images() for local libraries returning single track from lookup(). --- mopidy/local/__init__.py | 6 +++++- tests/local/test_library.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index eecaa4a2..542d99f3 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -116,7 +116,11 @@ class Library(object): result = {} for uri in uris: image_uris = set() - for track in self.lookup(uri): + tracks = self.lookup(uri) + # local libraries may return single track + if isinstance(tracks, models.Track): + tracks = [tracks] + for track in tracks: if track.album and track.album.images: image_uris.update(track.album.images) result[uri] = [models.Image(uri=u) for u in image_uris] diff --git a/tests/local/test_library.py b/tests/local/test_library.py index 13ad9405..39f0e53e 100644 --- a/tests/local/test_library.py +++ b/tests/local/test_library.py @@ -597,6 +597,18 @@ class LocalLibraryProviderTest(unittest.TestCase): result = library.get_images([track.uri]) self.assertEqual(result, {track.uri: [image]}) + @mock.patch.object(json.JsonLibrary, 'lookup') + def test_default_get_images_impl_single_track(self, mock_lookup): + library = actor.LocalBackend(config=self.config, audio=None).library + + image = Image(uri='imageuri') + album = Album(images=[image.uri]) + track = Track(uri='trackuri', album=album) + mock_lookup.return_value = track + + result = library.get_images([track.uri]) + self.assertEqual(result, {track.uri: [image]}) + @mock.patch.object(json.JsonLibrary, 'get_images') def test_local_library_get_images(self, mock_get_images): library = actor.LocalBackend(config=self.config, audio=None).library From 9a1833a6986f20248cf76f43d5983b8e377ba506 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Sat, 14 Mar 2015 16:43:02 +0100 Subject: [PATCH 173/314] Update change log for PR #1037. --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 74c37366..4689c29d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -108,7 +108,7 @@ v0.20.0 (UNRELEASED) (Fixes: :issue:`967`) - Add :meth:`mopidy.local.Library.get_images` for looking up images - for local URIs. (Fixes: :issue:`1031`, PR: :issue:`1032`) + for local URIs. (Fixes: :issue:`1031`, PR: :issue:`1032` and :issue:`1037`) **MPD frontend** From 003454535188d386ac95ec71388b3c20941a89e2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 20:43:34 +0100 Subject: [PATCH 174/314] http: Deprecate http/static_dir config --- docs/changelog.rst | 4 ++++ docs/ext/http.rst | 18 +++++++++++------- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 4689c29d..7e619024 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -140,6 +140,10 @@ v0.20.0 (UNRELEASED) **HTTP frontend** +- **Deprecated:** Deprecated the :confval:`http/static_dir` config. Please make + your web clients pip-installable Mopidy extensions to make it easier to + install for end users. + - Prevent race condition in WebSocket broadcast from breaking the web server. (PR: :issue:`1020`) diff --git a/docs/ext/http.rst b/docs/ext/http.rst index 54d44ce0..8745130f 100644 --- a/docs/ext/http.rst +++ b/docs/ext/http.rst @@ -73,17 +73,21 @@ See :ref:`config` for general help on configuring Mopidy. .. confval:: http/static_dir + **Deprecated:** This config is deprecated and will be removed in a future + version of Mopidy. + Which directory the HTTP server should serve at "/" Change this to have Mopidy serve e.g. files for your JavaScript client. - "/mopidy" will continue to work as usual even if you change this setting. + "/mopidy" will continue to work as usual even if you change this setting, + but any other Mopidy webclient installed with pip to be served at + "/ext_name" will stop working if you set this config. - This config value isn't deprecated yet, but you're strongly encouraged to - make Mopidy extensions which use the the :ref:`http-server-api` to host - static files on Mopidy's web server instead of using - :confval:`http/static_dir`. That way, installation of your web client will - be a lot easier for your end users, and multiple web clients can easily - share the same web server. + You're strongly encouraged to make Mopidy extensions which use the the + :ref:`http-server-api` to host static files on Mopidy's web server instead + of using :confval:`http/static_dir`. That way, installation of your web + client will be a lot easier for your end users, and multiple web clients + can easily share the same web server. .. confval:: http/zeroconf From 0eb2e1675af6fba7ef7a6f8e3788aac97d7ac6b4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 20:48:12 +0100 Subject: [PATCH 175/314] docs: Highlight deprecations in the changelog --- docs/changelog.rst | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 7e619024..aa1aad43 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -21,16 +21,18 @@ v0.20.0 (UNRELEASED) **Core API** -- Deprecate all properties in the core API. The previously undocumented getter - and setter methods are now the official API. This aligns the Python API with - the WebSocket/JavaScript API. (Fixes: :issue:`952`) +- **Deprecated:** Deprecate all properties in the core API. The previously + undocumented getter and setter methods are now the official API. This aligns + the Python API with the WebSocket/JavaScript API. (Fixes: :issue:`952`) - Add :class:`mopidy.core.HistoryController` which keeps track of what tracks have been played. (Fixes: :issue:`423`, PR: :issue:`803`) - Add :class:`mopidy.core.MixerController` which keeps track of volume and - mute. The old methods on :class:`mopidy.core.PlaybackController` for volume - and mute management have been deprecated. (Fixes: :issue:`962`) + mute. (Fixes: :issue:`962`) + +- **Deprecated:** The old methods on :class:`mopidy.core.PlaybackController` for + volume and mute management have been deprecated. (Fixes: :issue:`962`) - Remove ``clear_current_track`` keyword argument to :meth:`mopidy.core.PlaybackController.stop`. It was a leaky internal @@ -155,9 +157,9 @@ v0.20.0 (UNRELEASED) **Audio** -- Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. Pass a - :class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` to end the - stream. +- **Deprecated:** Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. + Pass a :class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` to end + the stream. - Kill support for visualizers. Feature was originally added as a workaround for all the people asking for ncmpcpp visualizer support. And since we could get From 3dc19014367a08427a2454eaf3b2ce1f8a1c92e2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 20:59:49 +0100 Subject: [PATCH 176/314] docs: Remove GStreamer elements from extdev guide Mixers are no longer custom GStreamer elements, and while still possible to do, custom GStreamer elements will probably not be as well supported when we port to GStreamer 1.x. --- docs/extensiondev.rst | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 93f627dc..a2a5f463 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -307,12 +307,6 @@ This is ``mopidy_soundspot/__init__.py``:: from .backend import SoundspotBackend registry.add('backend', SoundspotBackend) - # Register a custom GStreamer element - from .mixer import SoundspotMixer - gobject.type_register(SoundspotMixer) - gst.element_register( - SoundspotMixer, 'soundspotmixer', gst.RANK_MARGINAL) - # Or nothing to register e.g. command extension pass @@ -416,17 +410,6 @@ examples, see the :ref:`http-server-api` docs or explore with :ref:`http-explore-extension` extension. -Example GStreamer element -========================= - -If you want to extend Mopidy's GStreamer pipeline with new custom GStreamer -elements, you'll need to register them in GStreamer before they can be used. - -Basically, you just implement your GStreamer element in Python and then make -your :meth:`~mopidy.ext.Extension.setup` method register all your custom -GStreamer elements. - - Running an extension ==================== From 19e120582d1e3ecf26af456960b7120892b1d2c5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 21:06:28 +0100 Subject: [PATCH 177/314] docs: Update API concepts --- docs/api/concepts.rst | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/api/concepts.rst b/docs/api/concepts.rst index d127561b..9c542777 100644 --- a/docs/api/concepts.rst +++ b/docs/api/concepts.rst @@ -22,15 +22,16 @@ Frontends ========= Frontends expose Mopidy to the external world. They can implement servers for -protocols like MPD and MPRIS, and they can be used to update other services -when something happens in Mopidy, like the Last.fm scrobbler frontend does. See -:ref:`frontend-api` for more details. +protocols like HTTP, MPD and MPRIS, and they can be used to update other +services when something happens in Mopidy, like the Last.fm scrobbler frontend +does. See :ref:`frontend-api` for more details. .. digraph:: frontend_architecture + "HTTP\nfrontend" -> Core "MPD\nfrontend" -> Core "MPRIS\nfrontend" -> Core - "Last.fm\nfrontend" -> Core + "Scrobbler\nfrontend" -> Core Core @@ -55,6 +56,7 @@ See :ref:`core-api` for more details. Core -> "Library\ncontroller" Core -> "Playback\ncontroller" Core -> "Playlists\ncontroller" + Core -> "History\ncontroller" "Library\ncontroller" -> "Local backend" "Library\ncontroller" -> "Spotify backend" @@ -95,7 +97,8 @@ Audio The audio actor is a thin wrapper around the parts of the GStreamer library we use. If you implement an advanced backend, you may need to implement your own -playback provider using the :ref:`audio-api`. +playback provider using the :ref:`audio-api`, but most backends can use the +default playback provider without any changes. Mixer From 5be2849547266bb5b8d5a7f34a2da72491635919 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 21:51:37 +0100 Subject: [PATCH 178/314] docs: Add missing models to graph, reorder model docs --- docs/api/models.rst | 51 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/docs/api/models.rst b/docs/api/models.rst index 270f3896..23a08002 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -28,21 +28,54 @@ Data model relations .. digraph:: model_relations - Playlist -> Track [ label="has 0..n" ] - Track -> Album [ label="has 0..1" ] - Track -> Artist [ label="has 0..n" ] - Album -> Artist [ label="has 0..n" ] + Ref -> Album [ style="dotted", weight=1 ] + Ref -> Artist [ style="dotted", weight=1 ] + Ref -> Directory [ style="dotted", weight=1 ] + Ref -> Playlist [ style="dotted", weight=1 ] + Ref -> Track [ style="dotted", weight=1 ] - SearchResult -> Artist [ label="has 0..n" ] - SearchResult -> Album [ label="has 0..n" ] - SearchResult -> Track [ label="has 0..n" ] + Playlist -> Track [ label="has 0..n", weight=2 ] + Track -> Album [ label="has 0..1", weight=10 ] + Track -> Artist [ label="has 0..n", weight=10 ] + Album -> Artist [ label="has 0..n", weight=10 ] Image + SearchResult -> Artist [ label="has 0..n", weight=1 ] + SearchResult -> Album [ label="has 0..n", weight=1 ] + SearchResult -> Track [ label="has 0..n", weight=1 ] + + TlTrack -> Track [ label="has 1", weight=20 ] + Data model API ============== -.. automodule:: mopidy.models +.. module:: mopidy.models :synopsis: Data model API - :members: + +.. autoclass:: mopidy.models.Ref + +.. autoclass:: mopidy.models.Track + +.. autoclass:: mopidy.models.Album + +.. autoclass:: mopidy.models.Artist + +.. autoclass:: mopidy.models.Playlist + +.. autoclass:: mopidy.models.Image + +.. autoclass:: mopidy.models.TlTrack + +.. autoclass:: mopidy.models.SearchResult + + +Data model helpers +================== + +.. autoclass:: mopidy.models.ImmutableObject + +.. autoclass:: mopidy.models.ModelJSONEncoder + +.. autofunction:: mopidy.models.model_json_decoder From 6f5ae2f9c4578e0fc910ec64e4376b0471598337 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 22:02:19 +0100 Subject: [PATCH 179/314] docs: Fix syntax error in deprecation notices --- mopidy/core/playback.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index e92563dd..3a492371 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -126,7 +126,7 @@ class PlaybackController(object): def get_volume(self): """ - ... deprecated:: 0.20 + .. deprecated:: 0.20 Use :meth:`core.mixer.get_volume() ` instead. """ @@ -136,7 +136,7 @@ class PlaybackController(object): def set_volume(self, volume): """ - ... deprecated:: 0.20 + .. deprecated:: 0.20 Use :meth:`core.mixer.set_volume() ` instead. """ @@ -155,7 +155,7 @@ class PlaybackController(object): def get_mute(self): """ - ... deprecated:: 0.20 + .. deprecated:: 0.20 Use :meth:`core.mixer.get_mute() ` instead. """ @@ -164,7 +164,7 @@ class PlaybackController(object): def set_mute(self, mute): """ - ... deprecated:: 0.20 + .. deprecated:: 0.20 Use :meth:`core.mixer.set_mute() ` instead. """ From b36083bae6b7602810d9980eb7047d01b116b8bb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 22:02:27 +0100 Subject: [PATCH 180/314] docs: Fix mock of gst.Caps --- docs/conf.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index fbfb11aa..a47901d4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,6 +35,8 @@ class Mock(object): # glib.get_user_config_dir() return str elif (name[0] == name[0].upper() and + # gst.Caps + not name.startswith('Caps') and # gst.PadTemplate not name.startswith('PadTemplate') and # dbus.String() From a1e866e46e88e1822f918615e4e671a9129cd3fb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 22:06:38 +0100 Subject: [PATCH 181/314] docs: Use sphinx_rtd_theme bundled with Sphinx 1.3 --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index a47901d4..88ea49f0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -114,7 +114,7 @@ modindex_common_prefix = ['mopidy.'] # -- Options for HTML output -------------------------------------------------- -html_theme = 'default' +html_theme = 'sphinx_rtd_theme' html_theme_path = ['_themes'] html_static_path = ['_static'] From 336ef4534ab1d04cda477c7872ebc15957b98a3e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 23:00:09 +0100 Subject: [PATCH 182/314] tests: Use assertEqual instead of assertEquals --- tests/local/test_tracklist.py | 2 +- tests/mpd/protocol/test_command_list.py | 4 ++-- tests/mpd/protocol/test_playback.py | 12 ++++++------ tests/mpd/protocol/test_regression.py | 10 +++++----- tests/test_models.py | 22 +++++++++++----------- tests/utils/test_deps.py | 24 ++++++++++++------------ tests/utils/test_jsonrpc.py | 14 +++++++------- 7 files changed, 44 insertions(+), 44 deletions(-) diff --git a/tests/local/test_tracklist.py b/tests/local/test_tracklist.py index 5c85ac19..db5de58b 100644 --- a/tests/local/test_tracklist.py +++ b/tests/local/test_tracklist.py @@ -310,7 +310,7 @@ class LocalTracklistProviderTest(unittest.TestCase): def test_version_does_not_change_when_adding_nothing(self): version = self.controller.version self.controller.add([]) - self.assertEquals(version, self.controller.version) + self.assertEqual(version, self.controller.version) def test_version_increases_when_adding_something(self): version = self.controller.version diff --git a/tests/mpd/protocol/test_command_list.py b/tests/mpd/protocol/test_command_list.py index 28642b47..bd9a9e6c 100644 --- a/tests/mpd/protocol/test_command_list.py +++ b/tests/mpd/protocol/test_command_list.py @@ -6,7 +6,7 @@ from tests.mpd import protocol class CommandListsTest(protocol.BaseTestCase): def test_command_list_begin(self): response = self.send_request('command_list_begin') - self.assertEquals([], response) + self.assertEqual([], response) def test_command_list_end(self): self.send_request('command_list_begin') @@ -42,7 +42,7 @@ class CommandListsTest(protocol.BaseTestCase): def test_command_list_ok_begin(self): response = self.send_request('command_list_ok_begin') - self.assertEquals([], response) + self.assertEqual([], response) def test_command_list_ok_with_ping(self): self.send_request('command_list_ok_begin') diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index 4f3d6d7a..22527e1e 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -273,7 +273,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) - self.assertEquals(PLAYING, self.core.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.send_request('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -286,9 +286,9 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) - self.assertEquals(PLAYING, self.core.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.core.playback.pause() - self.assertEquals(PAUSED, self.core.playback.state.get()) + self.assertEqual(PAUSED, self.core.playback.state.get()) self.send_request('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -347,7 +347,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) - self.assertEquals(PLAYING, self.core.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.send_request('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -360,9 +360,9 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) - self.assertEquals(PLAYING, self.core.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.core.playback.pause() - self.assertEquals(PAUSED, self.core.playback.state.get()) + self.assertEqual(PAUSED, self.core.playback.state.get()) self.send_request('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) diff --git a/tests/mpd/protocol/test_regression.py b/tests/mpd/protocol/test_regression.py index 09ec8a46..6fb59afd 100644 --- a/tests/mpd/protocol/test_regression.py +++ b/tests/mpd/protocol/test_regression.py @@ -29,21 +29,21 @@ class IssueGH17RegressionTest(protocol.BaseTestCase): random.seed(1) # Playlist order: abcfde self.send_request('play') - self.assertEquals( + self.assertEqual( 'dummy:a', self.core.playback.current_track.get().uri) self.send_request('random "1"') self.send_request('next') - self.assertEquals( + self.assertEqual( 'dummy:b', self.core.playback.current_track.get().uri) self.send_request('next') # Should now be at track 'c', but playback fails and it skips ahead - self.assertEquals( + self.assertEqual( 'dummy:f', self.core.playback.current_track.get().uri) self.send_request('next') - self.assertEquals( + self.assertEqual( 'dummy:d', self.core.playback.current_track.get().uri) self.send_request('next') - self.assertEquals( + self.assertEqual( 'dummy:e', self.core.playback.current_track.get().uri) diff --git a/tests/test_models.py b/tests/test_models.py index e7aec877..7711f00d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -54,7 +54,7 @@ class GenericCopyTest(unittest.TestCase): def test_copying_track_to_remove(self): track = Track(name='foo').copy(name=None) - self.assertEquals(track.__dict__, Track().__dict__) + self.assertEqual(track.__dict__, Track().__dict__) class RefTest(unittest.TestCase): @@ -77,7 +77,7 @@ class RefTest(unittest.TestCase): Ref(foo='baz') def test_repr_without_results(self): - self.assertEquals( + self.assertEqual( "Ref(name=u'foo', type=u'artist', uri=u'uri')", repr(Ref(uri='uri', name='foo', type='artist'))) @@ -189,7 +189,7 @@ class ArtistTest(unittest.TestCase): Artist(serialize='baz') def test_repr(self): - self.assertEquals( + self.assertEqual( "Artist(name=u'name', uri=u'uri')", repr(Artist(uri='uri', name='name'))) @@ -353,12 +353,12 @@ class AlbumTest(unittest.TestCase): Album(foo='baz') def test_repr_without_artists(self): - self.assertEquals( + self.assertEqual( "Album(name=u'name', uri=u'uri')", repr(Album(uri='uri', name='name'))) def test_repr_with_artists(self): - self.assertEquals( + self.assertEqual( "Album(artists=[Artist(name=u'foo')], name=u'name', uri=u'uri')", repr(Album(uri='uri', name='name', artists=[Artist(name='foo')]))) @@ -596,12 +596,12 @@ class TrackTest(unittest.TestCase): Track(foo='baz') def test_repr_without_artists(self): - self.assertEquals( + self.assertEqual( "Track(name=u'name', uri=u'uri')", repr(Track(uri='uri', name='name'))) def test_repr_with_artists(self): - self.assertEquals( + self.assertEqual( "Track(artists=[Artist(name=u'foo')], name=u'name', uri=u'uri')", repr(Track(uri='uri', name='name', artists=[Artist(name='foo')]))) @@ -830,7 +830,7 @@ class TlTrackTest(unittest.TestCase): self.assertEqual(track2, track) def test_repr(self): - self.assertEquals( + self.assertEqual( "TlTrack(tlid=123, track=Track(uri=u'uri'))", repr(TlTrack(tlid=123, track=Track(uri='uri')))) @@ -962,12 +962,12 @@ class PlaylistTest(unittest.TestCase): Playlist(foo='baz') def test_repr_without_tracks(self): - self.assertEquals( + self.assertEqual( "Playlist(name=u'name', uri=u'uri')", repr(Playlist(uri='uri', name='name'))) def test_repr_with_tracks(self): - self.assertEquals( + self.assertEqual( "Playlist(name=u'name', tracks=[Track(name=u'foo')], uri=u'uri')", repr(Playlist(uri='uri', name='name', tracks=[Track(name='foo')]))) @@ -1098,7 +1098,7 @@ class SearchResultTest(unittest.TestCase): SearchResult(foo='baz') def test_repr_without_results(self): - self.assertEquals( + self.assertEqual( "SearchResult(uri=u'uri')", repr(SearchResult(uri='uri'))) diff --git a/tests/utils/test_deps.py b/tests/utils/test_deps.py index 3144fe30..2281765e 100644 --- a/tests/utils/test_deps.py +++ b/tests/utils/test_deps.py @@ -49,13 +49,13 @@ class DepsTest(unittest.TestCase): def test_platform_info(self): result = deps.platform_info() - self.assertEquals('Platform', result['name']) + self.assertEqual('Platform', result['name']) self.assertIn(platform.platform(), result['version']) def test_python_info(self): result = deps.python_info() - self.assertEquals('Python', result['name']) + self.assertEqual('Python', result['name']) self.assertIn(platform.python_implementation(), result['version']) self.assertIn(platform.python_version(), result['version']) self.assertIn('python', result['path']) @@ -64,8 +64,8 @@ class DepsTest(unittest.TestCase): def test_gstreamer_info(self): result = deps.gstreamer_info() - self.assertEquals('GStreamer', result['name']) - self.assertEquals( + self.assertEqual('GStreamer', result['name']) + self.assertEqual( '.'.join(map(str, gst.get_gst_version())), result['version']) self.assertIn('gst', result['path']) self.assertNotIn('__init__.py', result['path']) @@ -99,17 +99,17 @@ class DepsTest(unittest.TestCase): result = deps.pkg_info() - self.assertEquals('Mopidy', result['name']) - self.assertEquals('0.13', result['version']) + self.assertEqual('Mopidy', result['name']) + self.assertEqual('0.13', result['version']) self.assertIn('mopidy', result['path']) dep_info_pykka = result['dependencies'][0] - self.assertEquals('Pykka', dep_info_pykka['name']) - self.assertEquals('1.1', dep_info_pykka['version']) + self.assertEqual('Pykka', dep_info_pykka['name']) + self.assertEqual('1.1', dep_info_pykka['version']) dep_info_setuptools = dep_info_pykka['dependencies'][0] - self.assertEquals('setuptools', dep_info_setuptools['name']) - self.assertEquals('0.6', dep_info_setuptools['version']) + self.assertEqual('setuptools', dep_info_setuptools['name']) + self.assertEqual('0.6', dep_info_setuptools['version']) @mock.patch('pkg_resources.get_distribution') def test_pkg_info_for_missing_dist(self, get_distribution_mock): @@ -117,7 +117,7 @@ class DepsTest(unittest.TestCase): result = deps.pkg_info() - self.assertEquals('Mopidy', result['name']) + self.assertEqual('Mopidy', result['name']) self.assertNotIn('version', result) self.assertNotIn('path', result) @@ -127,6 +127,6 @@ class DepsTest(unittest.TestCase): result = deps.pkg_info() - self.assertEquals('Mopidy', result['name']) + self.assertEqual('Mopidy', result['name']) self.assertNotIn('version', result) self.assertNotIn('path', result) diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index 4471a4a0..fb59d06b 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -629,23 +629,23 @@ class JsonRpcInspectorTest(JsonRpcTestBase): methods = inspector.describe() self.assertIn('core.get_uri_schemes', methods) - self.assertEquals(len(methods['core.get_uri_schemes']['params']), 0) + self.assertEqual(len(methods['core.get_uri_schemes']['params']), 0) self.assertIn('core.library.lookup', methods.keys()) - self.assertEquals( + self.assertEqual( methods['core.library.lookup']['params'][0]['name'], 'uri') self.assertIn('core.playback.next', methods) - self.assertEquals(len(methods['core.playback.next']['params']), 0) + self.assertEqual(len(methods['core.playback.next']['params']), 0) self.assertIn('core.playlists.get_playlists', methods) - self.assertEquals( + self.assertEqual( len(methods['core.playlists.get_playlists']['params']), 1) self.assertIn('core.tracklist.filter', methods.keys()) - self.assertEquals( + self.assertEqual( methods['core.tracklist.filter']['params'][0]['name'], 'criteria') - self.assertEquals( + self.assertEqual( methods['core.tracklist.filter']['params'][1]['name'], 'kwargs') - self.assertEquals( + self.assertEqual( methods['core.tracklist.filter']['params'][1]['kwargs'], True) From aed91008a39a9138fbd719c78f1995a328ea2131 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 14 Mar 2015 23:07:59 +0100 Subject: [PATCH 183/314] deps: Add executable path to 'mopidy deps' output --- docs/changelog.rst | 4 ++++ mopidy/utils/deps.py | 9 +++++++++ tests/utils/test_deps.py | 7 +++++++ 3 files changed, 20 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index aa1aad43..81d8a2f1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -61,6 +61,10 @@ v0.20.0 (UNRELEASED) to set the log level for all loggers to the lowest possible value, including log records at levels lover than ``DEBUG`` too. +- Add path to the current ``mopidy`` executable to the output of ``mopidy + deps``. This make it easier to see that a user is using pip-installed Mopidy + instead of APT-installed Mopidy without asking for ``which mopidy`` output. + **Configuration** - Add support for the log level value ``all`` to the loglevels configurations. diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index 886b8818..bc9f7c2f 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -3,6 +3,7 @@ from __future__ import absolute_import, unicode_literals import functools import os import platform +import sys import pygst pygst.require('0.10') @@ -24,6 +25,7 @@ def format_dependency_list(adapters=None): for dist_name in dist_names] adapters = [ + executable_info, platform_info, python_info, functools.partial(pkg_info, 'Mopidy', True) @@ -63,6 +65,13 @@ def _format_dependency(dep_info): return '\n'.join(lines) +def executable_info(): + return { + 'name': 'Executable', + 'version': sys.argv[0], + } + + def platform_info(): return { 'name': 'Platform', diff --git a/tests/utils/test_deps.py b/tests/utils/test_deps.py index 2281765e..95f5b982 100644 --- a/tests/utils/test_deps.py +++ b/tests/utils/test_deps.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, unicode_literals import platform +import sys import unittest import mock @@ -46,6 +47,12 @@ class DepsTest(unittest.TestCase): self.assertIn(' pylast: 0.5', result) self.assertIn(' setuptools: 0.6', result) + def test_executable_info(self): + result = deps.executable_info() + + self.assertEqual('Executable', result['name']) + self.assertIn(sys.argv[0], result['version']) + def test_platform_info(self): result = deps.platform_info() From 6adeea6009343402507f7d2c7d6c5d80c27d3cd1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 15 Mar 2015 11:29:07 +0100 Subject: [PATCH 184/314] core: Correctly handle missing duration in seek. Seeks will now fail when the duration is None, this is an approximation to if the track is seekable or not. This check is need as otherwise seeking a radio stream will trigger the next track. If the track truly isn't seekable despite having a duration we should still fail as GStreamer will reject the seek. --- mopidy/core/playback.py | 3 +++ mopidy/models.py | 2 +- mopidy/mpd/translator.py | 2 ++ tests/core/test_playback.py | 11 +++++++++++ tests/mpd/test_translator.py | 2 ++ 5 files changed, 19 insertions(+), 1 deletion(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index e92563dd..84ffecb4 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -361,6 +361,9 @@ class PlaybackController(object): if not self.core.tracklist.tracks: return False + if self.current_track and self.current_track.length is None: + return False + if self.get_state() == PlaybackState.STOPPED: self.play() diff --git a/mopidy/models.py b/mopidy/models.py index 4d6ed27d..47f17b6b 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -325,7 +325,7 @@ class Track(ImmutableObject): :param date: track release date (YYYY or YYYY-MM-DD) :type date: string :param length: track length in milliseconds - :type length: integer + :type length: integer or :class:`None` if there is no duration :param bitrate: bitrate in kbit/s :type bitrate: integer :param comment: track comment diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 77adecd0..8359f86b 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -34,6 +34,8 @@ def track_to_mpd_format(track, position=None, stream_title=None): result = [ ('file', track.uri or ''), + # TODO: only show length if not none, see: + # https://github.com/mopidy/mopidy/issues/923#issuecomment-79584110 ('Time', track.length and (track.length // 1000) or 0), ('Artist', artists_to_mpd_format(track.artists)), ('Title', track.name or ''), diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 8911978a..2a28be4d 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -38,6 +38,7 @@ class CorePlaybackTest(unittest.TestCase): Track(uri='dummy2:a', length=40000), Track(uri='dummy3:a', length=40000), # Unplayable Track(uri='dummy1:b', length=40000), + Track(uri='dummy1:c', length=None), # No duration ] self.core = core.Core(mixer=None, backends=[ @@ -46,6 +47,7 @@ class CorePlaybackTest(unittest.TestCase): self.tl_tracks = self.core.tracklist.tl_tracks self.unplayable_tl_track = self.tl_tracks[2] + self.duration_less_tl_track = self.tl_tracks[4] def test_get_current_tl_track_none(self): self.core.playback.set_current_tl_track(None) @@ -478,6 +480,15 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback1.seek.called) self.assertFalse(self.playback2.seek.called) + def test_seek_fails_for_track_without_duration(self): + self.core.playback.current_tl_track = self.duration_less_tl_track + self.core.playback.state = core.PlaybackState.PLAYING + success = self.core.playback.seek(1000) + + self.assertFalse(success) + self.assertFalse(self.playback1.seek.called) + self.assertFalse(self.playback2.seek.called) + def test_seek_play_stay_playing(self): self.core.playback.play(self.tl_tracks[0]) self.core.playback.state = core.PlaybackState.PLAYING diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index 027ce28f..527cfef8 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -34,6 +34,8 @@ class TrackMpdFormatTest(unittest.TestCase): mtime.undo_fake() def test_track_to_mpd_format_for_empty_track(self): + # TODO: this is likely wrong, see: + # https://github.com/mopidy/mopidy/issues/923#issuecomment-79584110 result = translator.track_to_mpd_format(Track()) self.assertIn(('file', ''), result) self.assertIn(('Time', 0), result) From 28d047e1d22e48e15507d5636c8ea3d8a17d604f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 15 Mar 2015 11:42:01 +0100 Subject: [PATCH 185/314] core: Only emit stream title changed for streams This is done by checking for the presence of the organization tag typically set by web streams. This might be a bit to strict and a bad heuristic, but it's currently better than wrongly emitting stream titles for non streams IMO. --- mopidy/core/actor.py | 12 ++++++++---- tests/core/test_playback.py | 5 +++++ tests/dummy_audio.py | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index ed1c33ab..671517ca 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -121,12 +121,16 @@ class Core( return tags = self.audio.get_current_tags().get() - if not tags or 'title' not in tags or not tags['title']: + if not tags: return - title = tags['title'][0] - self.playback._stream_title = title - CoreListener.send('stream_title_changed', title=title) + # TODO: this limits us to only streams that set organization, this is + # a hack to make sure we don't emit stream title changes for plain + # tracks. We need a better way to decide if something is a stream. + if 'title' in tags and tags['title'] and 'organization' in tags: + title = tags['title'][0] + self.playback._stream_title = title + CoreListener.send('stream_title_changed', title=title) class Backends(list): diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 8911978a..809c1385 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -582,6 +582,7 @@ class TestStream(unittest.TestCase): def test_get_stream_title_during_playback_with_tags_change(self): self.core.playback.play() + self.audio.trigger_fake_tags_changed({'organization': ['baz']}) self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() self.replay_audio_events() @@ -589,6 +590,7 @@ class TestStream(unittest.TestCase): def test_get_stream_title_after_next(self): self.core.playback.play() + self.audio.trigger_fake_tags_changed({'organization': ['baz']}) self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() self.core.playback.next() @@ -597,8 +599,10 @@ class TestStream(unittest.TestCase): def test_get_stream_title_after_next_with_tags_change(self): self.core.playback.play() + self.audio.trigger_fake_tags_changed({'organization': ['baz']}) self.audio.trigger_fake_tags_changed({'title': ['foo']}).get() self.core.playback.next() + self.audio.trigger_fake_tags_changed({'organization': ['baz']}) self.audio.trigger_fake_tags_changed({'title': ['bar']}).get() self.replay_audio_events() @@ -606,6 +610,7 @@ class TestStream(unittest.TestCase): def test_get_stream_title_after_stop(self): self.core.playback.play() + self.audio.trigger_fake_tags_changed({'organization': ['baz']}) self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() self.core.playback.stop() diff --git a/tests/dummy_audio.py b/tests/dummy_audio.py index b73946cb..dcf90ffa 100644 --- a/tests/dummy_audio.py +++ b/tests/dummy_audio.py @@ -110,7 +110,7 @@ class DummyAudio(pykka.ThreadingActor): self._state_change_result = False def trigger_fake_tags_changed(self, tags): - self._tags = tags + self._tags.update(tags) audio.AudioListener.send('tags_changed', tags=self._tags.keys()) def get_about_to_finish_callback(self): From 7aee5059431372b74472889409d9f2cc2ab8856c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 16 Mar 2015 23:53:42 +0100 Subject: [PATCH 186/314] docs: Add development environment guide Fixes #994 --- docs/devenv.rst | 593 ++++++++++++++++++++++++++++++++++++ docs/devtools.rst | 43 --- docs/index.rst | 1 + docs/installation/index.rst | 5 +- 4 files changed, 597 insertions(+), 45 deletions(-) create mode 100644 docs/devenv.rst diff --git a/docs/devenv.rst b/docs/devenv.rst new file mode 100644 index 00000000..48a7bc30 --- /dev/null +++ b/docs/devenv.rst @@ -0,0 +1,593 @@ +.. _devenv: + +*********************** +Development environment +*********************** + +This page describes a common development setup for working with Mopidy and +Mopidy extensions. Of course, there may be other ways that work better for you +and the tools you use, but here's one recommended way to do it. + +.. contents:: + :local: + + +Initial setup +============= + +The following steps help you get a good initial setup. They build on each other +to some degree, so if you're not very familiar with Python development it might +be wise to proceed in the order laid out here. + +.. contents:: + :local: + + +Install Mopidy the regular way +------------------------------ + +Install Mopidy the regular way. Mopidy has some non-Python dependencies which +may be tricky to install. Thus we recommend to always start with a full regular +Mopidy install, as described in :ref:`installation`. That is, if you're running +e.g. Debian, start with installing Mopidy from Debian packages. + + +Make a development workspace +---------------------------- + +Make a directory to be used as a workspace for all your Mopidy development:: + + mkdir ~/mopidy-dev + +It will contain all the Git repositories you'll check out when working on +Mopidy and extensions. + + +Make a virtualenv +----------------- + +Make a Python `virtualenv `_ for Mopidy +development. The virtualenv will wall of Mopidy and its dependencies from the +rest of your system. All development and installation of Python dependencies +versions of Mopidy and extensions are done inside the virtualenv. This way your +regular Mopidy install, which you set up in the first step, is unaffected by +your hacking and will always be working. + +Most of us use the `virtualenvwrapper +`_ to ease working with +virtualenvs, so that's what we'll be using for the examples here. First, +install and setup virtualenvwrapper as described in their docs. + +To create a virtualenv named ``mopidy`` which use Python 2.7, allows access to +system-wide packages like GStreamer, and use the Mopidy workspace directory as +the "project path", run:: + + mkvirtualenv -a ~/mopidy-dev --python `which python2.7` \ + --system-site-packages mopidy + +Now, each time you open a terminal and want to activate the ``mopidy`` +virtualenv, run:: + + workon mopidy + +This will both activate the ``mopidy`` virtualenv, and change the current +working directory to ``~/mopidy-dev``. + + +Clone the repo from GitHub +-------------------------- + +Once inside the virtualenv, it's time to clone the ``mopidy/mopidy`` Git repo +from GitHub:: + + git clone https://github.com/mopidy/mopidy.git + +When you've cloned the ``mopidy`` Git repo, ``cd`` into it:: + + cd ~/mopidy-dev/mopidy/ + +With a fresh clone of the Git repo, you should start out on the ``develop`` +branch. This is where all features for the next feature release lands. To +confirm that you're on the right branch, run:: + + git branch + + +Install development tools +------------------------- + +We use a number of Python development tools. The :file:`dev-requirements.txt` +file has comments describing what we use each dependency for, so we might just +as well show include the file verbatim here: + +.. literalinclude:: ../dev-requirements.txt + +You probably won't use all of these development tools, but at least a +majority of them. Install them all into the active virtualenv by running `pip +`_:: + + pip install --upgrade -r dev-requirements.txt + +To upgrade the tools in the future, just rerun the exact same command. + + +Install Mopidy from the Git repo +-------------------------------- + +Next up, we'll want to run Mopidy from the Git repo. There's two reasons for +this: First of all, it lets you easily change the source code, restart Mopidy, +and see the change take effect. Second, it's a convenient way to keep at the +bleeding edge, testing the latest developments in Mopidy itself or test some +extension against the latest Mopidy changes. + +Assuming you're still inside the Git repo, use pip to install Mopidy from the +Git repo in an "editable" form:: + + pip install --editable . + +This will not copy the source code into the virtualenv's ``site-packages`` +directory, but instead create a link there pointing to the Git repo. Using +``cdsitepackages`` from virtualenvwrapper, we can quickly show that the +installed :file:`Mopidy.egg-link` file points back to the Git repo:: + + $ cdsitepackages + $ cat Mopidy.egg-link + /home/user/mopidy-dev/mopidy + .% + $ + +It will also create a ``mopidy`` executable inside the virtualenv that will +always run the latest code from the Git repo. Using another +virtualenvwrapper command, ``cdvirtualenv``, we can show that too:: + + $ cdvirtualenv + $ cat bin/mopidy + ... + +The executable should contain something like this, using :mod:`pkg_resources` +to look up Mopidy's "console script" entry point:: + + #!/home/user/virtualenvs/mopidy/bin/python2 + # EASY-INSTALL-ENTRY-SCRIPT: 'Mopidy==0.19.5','console_scripts','mopidy' + __requires__ = 'Mopidy==0.19.5' + import sys + from pkg_resources import load_entry_point + + if __name__ == '__main__': + sys.exit( + load_entry_point('Mopidy==0.19.5', 'console_scripts', 'mopidy')() + ) + +.. note:: + + It still works to run ``python mopidy`` directly on the + :file:`~/mopidy-dev/mopidy/mopidy/` Python package directory, but if + you don't run the ``pip install`` command above, the extensions bundled + with Mopidy will not be registered with :mod:`pkg_resources`, making Mopidy + quite useless. + +Third, the ``pip install`` command will register the bundled Mopidy +extensions so that Mopidy may find them through :mod:`pkg_resources`. The +result of this can be seen in the Git repo, in a new directory called +:file:`Mopidy.egg-info`, which is ignored by Git. The +:file:`Mopidy.egg-info/entry_points.txt` file is of special interest as it +shows both how the above executable and the bundled extensions are connected to +the Mopidy source code: + +.. code-block:: ini + + [console_scripts] + mopidy = mopidy.__main__:main + + [mopidy.ext] + http = mopidy.http:Extension + local = mopidy.local:Extension + mpd = mopidy.mpd:Extension + softwaremixer = mopidy.softwaremixer:Extension + stream = mopidy.stream:Extension + +.. warning:: + + It's not uncommon to clean up in the Git repo now and then, e.g. by running + ``git clean``. + + If you do this, then the :file:`Mopidy.egg-info` directory will be removed, + and :mod:`pkg_resources` will no longer know how to locate the "console + script" entry point or the bundled Mopidy extensions. + + The fix is simply to run the install command again:: + + pip install --editable . + +Finally, we can go back to the workspace, again using a virtualenvwrapper +tool:: + + cdproject + + +.. _running-from-git: + +Running Mopidy from Git +======================= + +As long as the virtualenv is activated, you can start Mopidy from any +directory. Simply run:: + + mopidy + +To stop it again, press :kbd:`Ctrl+C`. + +Every time you change code in Mopidy or an extension and want to see it +live, you must restart Mopidy. + +If you wan't to iterate quickly while developing, it may sound a bit tedious to +restart Mopidy for every minor change. Then it's useful to have tests to +exercise your code... + + +.. _running-tests: + +Running tests +============= + +Mopidy has quite good test coverage, and we would like all new code going into +Mopidy to come with tests. + +.. contents:: + :local: + + +Test it all +----------- + +You need to know at least one command; the one that runs all the tests:: + + tox + +This will run exactly the same tests as `Travis CI +`_ runs for all our branches and pull +requests. If this command turns green, you can be quite confident that your +pull request will get the green flag from Travis as well, which is a +requirement for it to be merged. + +As this is the ultimate test command, it's also the one taking the most time to +run; up to a minute, depending on your system. But, if you have patience, this +is all you need to know. Always run this command before pushing your changes to +GitHub. + +If you take a look at the tox config file, :file:`tox.ini`, you'll see that tox +runs tests in multiple environments, including a ``flake8`` environment that +lints the source code for issues and a ``docs`` environment that tests that the +documentation can be built. You can also limit tox to just test specific +environments using the ``-e`` option, e.g. to run just unit tests:: + + tox -e py27 + +To learn more, see the `tox documentation `_ . + + +Running unit tests +------------------ + +Under the hood, ``tox -e py27`` will use `pytest `_ as the +test runner. We can also use it directly to run all tests:: + + py.test + +py.test has lots of possibilities, so you'll have to dive into their docs and +plugins to get full benefit from it. To get you interested, here are some +examples. + +We can limit to just tests in a single directory to save time:: + + py.test tests/http/ + +With the help of the pytest-xdist plugin, we can run tests with four processes +in parallel, which usually cuts the test time in half or more:: + + py.test -n 4 + +Another useful feature from pytest-xdist, is the possiblity to stop on the +first test failure, watch the file system for changes, and then rerun the +tests. This makes for a very quick code-test cycle:: + + py.test -f # or --looponfail + +With the help of the pytest-cov plugin, we can get a report on what parts of +the given module, ``mopidy`` in this example, is covered by the test suite:: + + py.test --cov=mopidy --cov-report=term-missing + +.. note:: + + Up to date test coverage statistics can also be viewed online at + `coveralls.io `_. + +If we want to speed up the test suite, we can even get a list of the ten +slowest tests:: + + py.test --durations=10 + +By now, you should be convinced that running py.test directly during +development can be very useful. + + +Continuous integration +---------------------- + +Mopidy uses the free service `Travis CI `_ +for automatically running the test suite when code is pushed to GitHub. This +works both for the main Mopidy repo, but also for any forks. This way, any +contributions to Mopidy through GitHub will automatically be tested by Travis +CI, and the build status will be visible in the GitHub pull request interface, +making it easier to evaluate the quality of pull requests. + +For each success build, Travis submits code coverage data to `coveralls.io +`_. If you're out of work, coveralls might +help you find areas in the code which could need better test coverage. + +In addition, we run a Jenkins CI server at https://ci.mopidy.com/ that runs all +test on multiple platforms (Ubuntu, OS X, x86, arm) for every commit we push to +the ``develop`` branch in the main Mopidy repo on GitHub. Thus, new code isn't +tested by Jenkins before it is merged into the ``develop`` branch, which is a +bit late, but good enough to get broad testing before new code is released. + + +.. _code-linting: + +Style checking and linting +-------------------------- + +We're quite pedantic about :ref:`codestyle` and try hard to keep the Mopidy +code base a very clean and nice place to work in. + +Luckily, you can get very far by using the `flake8 +`_ linter to check your code for issues before +submitting a pull request. Mopidy passes all of flake8's checks, with only a +very few exceptions configured in :file:`setup.cfg`. You can either run the +``flake8`` tox environment, like Travis CI will do on your pull request:: + + tox -e flake8 + +Or you can run flake8 directly:: + + flake8 + +If successful, the command will not print anything at all. + +.. note:: + + In some rare cases it doesn't make sense to listen to flake8's warnings. In + those cases, ignore the check by appending ``# noqa: `` to + the source line that triggers the warning. The ``# noqa`` part will make + flake8 skip all checks on the line, while the warning code will help other + developers lookup what you are ignoring. + + +.. _writing-docs: + +Writing documentation +===================== + +To write documentation, we use `Sphinx `_. See their +site for lots of documentation on how to use Sphinx. + +.. note:: + + To generate a few graphs which are part of the documentation, you need some + additional dependencies. You can install them from APT with:: + + sudo apt-get install python-pygraphviz graphviz + +To build the documentation, go into the :file:`docs/` directory:: + + cd ~/mopidy-dev/mopidy/docs/ + +Then, to see all available build targets, run:: + + make + +To generate an HTML version of the documentation, run:: + + make html + +The generated HTML will be available at :file:`_build/html/index.html`. To open +it in a browser you can run either of the following commands, depending on your +OS:: + + xdg-open _build/html/index.html # Linux + open _build/html/index.html # OS X + +The documentation at https://docs.mopidy.com/ is hosted by `Read the Docs +`_, which automatically updates the documentation +when a change is pushed to the ``mopidy/mopidy`` repo at GitHub. + + +Working on extensions +===================== + +Much of the above also applies to Mopidy extensions, though they're often a bit +simpler. They don't have documentation sites and their test suites are either +small and fast, or sadly missing entirely. Most of them use tox and flake8, and +py.test can be used to run their test suites. + +.. contents:: + :local: + + +Installing extensions +--------------------- + +As always, the ``mopidy`` virtualenv should be active when working on +extensions:: + + workon mopidy + +Just like with non-development Mopidy installations, you can install extensions +using pip:: + + pip install Mopidy-Scrobbler + +Installing an extension from its Git repo works the same way as with Mopidy +itself. First, go to the Mopidy workspace:: + + cdproject # or cd ~/mopidy-dev/ + +Clone the desired Mopidy extension:: + + git clone https://github.com/mopidy/mopidy-spotify.git + +Change to the newly created extension directory:: + + cd mopidy-spotify/ + +Then, install the extension in "editable" mode, so that it can be imported from +anywhere inside the virtualenv and the extension is registered and discoverable +through :mod:`pkg_resources`:: + + pip install --editable . + +Every extension will have a ``README.rst`` file. It may contain information +about extra dependencies required, development process, etc. Extensions usually +have a changelog in the readme file. + + +Upgrading extensions +-------------------- + +Extensions often have a much quicker life cycle than Mopidy itself, often with +daily releases in periods of active development. To find outdated extensions in +your virtualenv, you can run:: + + pip search mopidy + +This will list all available Mopidy extensions and compare the installed +versions with the latest available ones. + +To upgrade an extension installed with pip, simply use pip:: + + pip install --upgrade Mopidy-Scrobbler + +To upgrade an extension installed from a Git repo, it's usually enough to pull +the new changes in:: + + cd ~/mopidy-dev/mopidy-spotify/ + git pull + +Of course, if you have local modifications, you'll need to stash these away on +a branch or similar first. + +Depending on the changes to the extension, it may be necessary to update the +metadata about the extension package by installing it in "editable" mode +again:: + + pip install --editable . + + +Contribution workflow +===================== + +Before you being, make sure you've read the :ref:`contributing` page and the +guidelines there. This section will focus more on the practical workflow. + +For the examples, we're making a change to Mopidy. Approximately the same +workflow should work for most Mopidy extensions too. + +.. contents:: + :local: + + +Setting up Git remotes +---------------------- + +Assuming we already have a local Git clone of the upstream Git repo in +:file:`~/mopidy-dev/mopidy/`, we can run ``git remote -v`` to list the +configured remotes of the repo:: + + $ git remote -v + origin https://github.com/mopidy/mopidy.git (fetch) + origin https://github.com/mopidy/mopidy.git (push) + +For clarity, we can rename the ``origin`` remote to ``upstream``:: + + $ git remote rename origin upstream + $ git remote -v + upstream https://github.com/mopidy/mopidy.git (fetch) + upstream https://github.com/mopidy/mopidy.git (push) + +If you haven't already, `fork the repository +`_ to your own GitHub account. + +Then, add the new fork as a remote to your local clone:: + + git remote add myuser git@github.com:myuser/mopidy.git + +The end result is that you have both the upstream repo and your own fork as +remotes:: + + $ git remote -v + myuser git@github.com:myuser/mopidy.git (fetch) + myuser git@github.com:myuser/mopidy.git (push) + upstream https://github.com/mopidy/mopidy.git (fetch) + upstream https://github.com/mopidy/mopidy.git (push) + + +Creating a branch +----------------- + +Fetch the latest data from all remotes without affecting your working +directory:: + + git remote update + +Now, we are ready to create and checkout a new branch off of the upstream +``develop`` branch for our work:: + + git checkout -b fix/666-crash-on-foo upstream/develop + +Do the work, while remembering to adhere to code style, test the changes, make +necessary updates to the documentation, and making small commits with good +commit messages. All as described in :ref:`contributing` and elsewhere in +the :ref:`devenv` guide. + + +Creating a pull request +----------------------- + +When everything is done and committed, push the branch to your fork on GitHub:: + + git push myuser fix/666-crash-on-foo + +Go to the repository on GitHub where you want the change merged, in this case +https://github.com/mopidy/mopidy, and `create a pull request +`_. + + +Updating a pull request +----------------------- + +When the pull request is created, `Travis CI +`__ will run all tests on it. If something +fails, you'll get notified by email. You might as well just fix the issues +right away, as we won't merge a pull request without a green Travis build. See +:ref:`running-tests` on how to run the same tests locally as Travis CI runs on +your pull request. + +When you've fixed the issues, you can update the pull request simply by pushing +more commits to the same branch in your fork:: + + git push myuser fix/666-crash-on-foo + +Likewise, when you get review comments from other developers on your pull +request, you're expected to create additional commits which addresses the +comments. Push them to your branch so that the pull request is updated. + +.. note:: + + Setup the remote as the default push target for your branch:: + + git branch --set-upstream-to myuser/fix/666-crash-on-foo + + Then you can push more commits without specifying the remote:: + + git push diff --git a/docs/devtools.rst b/docs/devtools.rst index 93798071..ec80c543 100644 --- a/docs/devtools.rst +++ b/docs/devtools.rst @@ -5,49 +5,6 @@ Development tools Here you'll find description of the development tools we use. -Continuous integration -====================== - -Mopidy uses the free service `Travis CI `_ -for automatically running the test suite when code is pushed to GitHub. This -works both for the main Mopidy repo, but also for any forks. This way, any -contributions to Mopidy through GitHub will automatically be tested by Travis -CI, and the build status will be visible in the GitHub pull request interface, -making it easier to evaluate the quality of pull requests. - -In addition, we run a Jenkins CI server at http://ci.mopidy.com/ that runs all -test on multiple platforms (Ubuntu, OS X, x86, arm) for every commit we push to -the ``develop`` branch in the main Mopidy repo on GitHub. Thus, new code isn't -tested by Jenkins before it is merged into the ``develop`` branch, which is a -bit late, but good enough to get broad testing before new code is released. - -In addition to running tests, the Jenkins CI server also gathers coverage -statistics and uses flake8 to check for errors and possible improvements in our -code. So, if you're out of work, the code coverage and flake8 data at the CI -server should give you a place to start. - - -Documentation writing -===================== - -To write documentation, we use `Sphinx `_. See their -site for lots of documentation on how to use Sphinx. To generate HTML from the -documentation files, you need some additional dependencies. - -You can install them through Debian/Ubuntu package management:: - - sudo apt-get install python-sphinx python-pygraphviz graphviz - -Then, to generate docs:: - - cd docs/ - make # For help on available targets - make html # To generate HTML docs - -The documentation at http://docs.mopidy.com/ is automatically updated when a -documentation update is pushed to ``mopidy/mopidy`` at GitHub. - - Creating releases ================= diff --git a/docs/index.rst b/docs/index.rst index 395e683e..bb16239c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -135,6 +135,7 @@ Development :maxdepth: 1 contributing + devenv devtools codestyle extensiondev diff --git a/docs/installation/index.rst b/docs/installation/index.rst index c8deae59..dba1fb3a 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -7,8 +7,9 @@ Installation There are several ways to install Mopidy. What way is best depends upon your OS and/or distribution. -If you want to contribute to the development of Mopidy, you should first read -the general installation instructions, then have a look at :ref:`run-from-git`. +If you want to contribute to the development of Mopidy, you should first follow +the instructions here to install a regular install of Mopidy, then continue +with reading :ref:`contributing` and :ref:`devenv`. .. toctree:: From e981caf5e9e067ba61cedb0b4a8c35e8e88f5909 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 00:00:35 +0100 Subject: [PATCH 187/314] docs: New contribution guidelines Fixes #830 --- docs/contributing.rst | 198 +++++++++++++++++------------------------- 1 file changed, 79 insertions(+), 119 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index f30e16bd..64f8d74b 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -4,44 +4,81 @@ Contributing ************ -If you are thinking about making Mopidy better, or you just want to hack on it, -that’s great. Here are some tips to get you started. +If you want to contribute to Mopidy, here are some tips to get you started. -Getting started -=============== +.. _asking-questions: -#. Make sure you have a `GitHub account `_. +Asking questions +================ -#. If a ticket does not already exist `submit a ticket - `_ for your issue. - Make sure to clearly describe the issue, and if it is a bug: include steps - to reproduce. +Please use one of these channels for requesting help with Mopidy and its +extensions: -#. Fork the repository on GitHub. +- Our discussion forum: `discuss.mopidy.com `_. + Just sign in and fire away. + +- Our IRC channel: ``#mopidy`` on `irc.freenode.net `_, + with public `searchable logs `_. Be + prepared to hang around for a while, as we're not always around to answer + straight away. + +Before asking for help, it might be worth your time to read the +:ref:`troubleshooting` page, both so you might find a solution to your problem +but also to be able to provide useful details when asking for help. -Making changes -============== +Helping users +============= -#. Clone your fork on GitHub to your computer. +If you want to contribute to Mopidy, a great place to start is by helping other +users on IRC and in the discussion forum. This is a contribution we value +highly. As more people help with user support, new users get faster and better +help. For your own benefit, you'll quickly learn what users find confusing, +difficult or lacking, giving you some ideas for where you may contribute +improvements, either to code or documentation. Lastly, this may also free up +time for other contributors to spend more time on fixing bugs or implementing +new features. -#. Consider making a Python `virtualenv `_ for - Mopidy development to wall of Mopidy and it's dependencies from the rest of - your system. If you do so, create the virtualenv with the - ``--system-site-packages`` flag so that Mopidy can use globally installed - dependencies like GStreamer. If you don't use a virtualenv, you may need to - run the following ``pip`` and ``python setup.py`` commands with ``sudo`` to - install stuff globally on your computer. -#. Install dependencies as described in the :ref:`installation` section. +.. _issue-guidelines: -#. Install additional development dependencies:: +Issue guidelines +================ - pip install -r dev-requirements.txt +#. If you need help, see :ref:`asking-questions` above. The GitHub issue + tracker is not a support forum. -#. Checkout a new branch (usually based on ``develop``) and name it accordingly - to what you intend to do. +#. If you are not sure if is what you're experiencing is a bug or not, post in + the `discussion forum `__ first to verify that + its a bug. + +#. If you are sure that you've found a bug or have a feature request, check if + there's already an issue in the `issue tracker + `_. If there is, see if there is + anything you can add to help reproduce or fix the issue. + +#. If there is no exising issue matching your bug or feature request, create a + `new issue `_. Please include + as much relevant information as possible. If its a bug, including how to + reproduce the bug and any relevant logs or error messages. + + +Pull request guidelines +======================= + +#. Before spending any time on making a pull request: + + - If its a bug, :ref:`file an issue `. + + - If its an enhancement, discuss it with other Mopidy developers first, + either in a GitHub issue, on the discussion forum, or on IRC. Making sure + your ideas and solutions are aligned with other contributors greatly + increase the odds of your pull request being quickly accepted. + +#. Create a new branch, based on the ``develop`` branch, for every feature or + bug fix. Keep branches small and on topic, as that makes them far easier to + review. We often use the following naming convention for branches: - Features get the prefix ``feature/`` @@ -49,105 +86,28 @@ Making changes - Improvements to the documentation get the prefix ``docs/`` +#. Follow the :ref:`code style `, especially make sure the + ``flake8`` linter does not complain about anything. Travis CI will check + that your pull request is "flake8 clean". See :ref:`code-linting`. -.. _run-from-git: +#. Include tests for any new feature or substantial bug fix. See + :ref:`running-tests`. -Running Mopidy from Git -======================= +#. Include documentation for any new feature. See :ref:`writing-docs`. -If you want to hack on Mopidy, you should run Mopidy directly from the Git -repo. +#. Feel free to include a changelog entry in your pull request. The changelog + is in :file:`docs/changelog.rst`. -#. Go to the Git repo root:: +#. Write good commit messages. Here's three blog posts on how to do it right: - cd mopidy/ + - `Writing Git commit messages + `_ -#. To get a ``mopidy`` executable and register all bundled extensions with - setuptools, run:: + - `A Note About Git Commit Messages + `_ - python setup.py develop + - `On commit messages + `_ - It still works to run ``python mopidy`` directly on the ``mopidy`` Python - package directory, but if you have never run ``python setup.py develop`` the - extensions bundled with Mopidy isn't registered with setuptools, so Mopidy - will start without any frontends or backends, making it quite useless. - -#. Now you can run the Mopidy command, and it will run using the code - in the Git repo:: - - mopidy - - If you do any changes to the code, you'll just need to restart ``mopidy`` - to see the changes take effect. - - -Testing -======= - -Mopidy has quite good test coverage, and we would like all new code going into -Mopidy to come with tests. - -#. To run all tests, go to the project directory and run:: - - py.test - - To run tests with test coverage statistics:: - - py.test --cov=mopidy --cov-report=term-missing - - Test coverage statistics can also be viewed online at - `coveralls.io `_. - -#. Always check the code for errors and style issues using flake8:: - - flake8 - - If successful, the command will not print anything at all. Ignore the rare - cases you need to ignore a check use `# noqa: ` so we can lookup what - you are ignoring. - -#. Finally, there is the ultimate but a bit slower command. To run both tests, - docs build, and flake8 linting, run:: - - tox - - This will run exactly the same tests as `Travis CI - `_ runs for all our branches and pull - requests. If this command turns green, you can be quite confident that your - pull request will get the green flag from Travis as well, which is a - requirement for it to be merged. - - -Submitting changes -================== - -- One branch per feature or fix. Keep branches small and on topic. - -- Follow the :ref:`code style `, especially make sure ``flake8`` - does not complain about anything. - -- Write good commit messages. Here's three blog posts on how to do it right: - - - `Writing Git commit messages - `_ - - - `A Note About Git Commit Messages - `_ - - - `On commit messages - `_ - -- Send a pull request to the ``develop`` branch. See the `GitHub pull request - docs `_ for help. - - -Additional resources -==================== - -- IRC channel: ``#mopidy`` at `irc.freenode.net `_ - -- `Issue tracker `_ - -- `Mailing List `_ - -- `GitHub documentation `_ +#. Send a pull request to the ``develop`` branch. See the `GitHub pull request + docs `_ for help. From b2b7b8708198a87d307266aa2a19f1cbe0757320 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 01:01:15 +0100 Subject: [PATCH 188/314] docs: Make devtools a pure release procedure page --- docs/index.rst | 2 +- docs/{devtools.rst => releasing.rst} | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) rename docs/{devtools.rst => releasing.rst} (93%) diff --git a/docs/index.rst b/docs/index.rst index bb16239c..06af9dcd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -136,7 +136,7 @@ Development contributing devenv - devtools + releasing codestyle extensiondev diff --git a/docs/devtools.rst b/docs/releasing.rst similarity index 93% rename from docs/devtools.rst rename to docs/releasing.rst index ec80c543..8a12cf7d 100644 --- a/docs/devtools.rst +++ b/docs/releasing.rst @@ -1,8 +1,10 @@ -***************** -Development tools -***************** +****************** +Release procedures +****************** -Here you'll find description of the development tools we use. +Here we try to keep an up to date record of how Mopidy releases are made. This +documentation serves both as a checklist, to reduce the project's dependency on +key individuals, and as a stepping stone to more automation. Creating releases From c90f08d8ea66301d55a3e249a406b2c31daf4571 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 01:13:25 +0100 Subject: [PATCH 189/314] docs: Show another level of the about section in the ToC --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index 06af9dcd..e91c491c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -132,7 +132,7 @@ Development =========== .. toctree:: - :maxdepth: 1 + :maxdepth: 2 contributing devenv From b6b872b9c2dde4049ef0d8c936b38329fd7361ce Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 12:20:35 +0100 Subject: [PATCH 190/314] docs: Fix typos from review Co-Authored-By: Nick Steel --- docs/contributing.rst | 14 +++++++------- docs/devenv.rst | 42 +++++++++++++++++++++--------------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 64f8d74b..1b3b1330 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -49,9 +49,9 @@ Issue guidelines #. If you need help, see :ref:`asking-questions` above. The GitHub issue tracker is not a support forum. -#. If you are not sure if is what you're experiencing is a bug or not, post in - the `discussion forum `__ first to verify that - its a bug. +#. If you are not sure if what you're experiencing is a bug or not, post in the + `discussion forum `__ first to verify that it's + a bug. #. If you are sure that you've found a bug or have a feature request, check if there's already an issue in the `issue tracker @@ -60,7 +60,7 @@ Issue guidelines #. If there is no exising issue matching your bug or feature request, create a `new issue `_. Please include - as much relevant information as possible. If its a bug, including how to + as much relevant information as possible. If it's a bug, including how to reproduce the bug and any relevant logs or error messages. @@ -69,12 +69,12 @@ Pull request guidelines #. Before spending any time on making a pull request: - - If its a bug, :ref:`file an issue `. + - If it's a bug, :ref:`file an issue `. - - If its an enhancement, discuss it with other Mopidy developers first, + - If it's an enhancement, discuss it with other Mopidy developers first, either in a GitHub issue, on the discussion forum, or on IRC. Making sure your ideas and solutions are aligned with other contributors greatly - increase the odds of your pull request being quickly accepted. + increases the odds of your pull request being quickly accepted. #. Create a new branch, based on the ``develop`` branch, for every feature or bug fix. Keep branches small and on topic, as that makes them far easier to diff --git a/docs/devenv.rst b/docs/devenv.rst index 48a7bc30..c67426f7 100644 --- a/docs/devenv.rst +++ b/docs/devenv.rst @@ -47,19 +47,19 @@ Make a virtualenv ----------------- Make a Python `virtualenv `_ for Mopidy -development. The virtualenv will wall of Mopidy and its dependencies from the -rest of your system. All development and installation of Python dependencies -versions of Mopidy and extensions are done inside the virtualenv. This way your -regular Mopidy install, which you set up in the first step, is unaffected by -your hacking and will always be working. +development. The virtualenv will wall off Mopidy and its dependencies from the +rest of your system. All development and installation of Python dependencies, +versions of Mopidy, and extensions are done inside the virtualenv. This way +your regular Mopidy install, which you set up in the first step, is unaffected +by your hacking and will always be working. Most of us use the `virtualenvwrapper `_ to ease working with virtualenvs, so that's what we'll be using for the examples here. First, install and setup virtualenvwrapper as described in their docs. -To create a virtualenv named ``mopidy`` which use Python 2.7, allows access to -system-wide packages like GStreamer, and use the Mopidy workspace directory as +To create a virtualenv named ``mopidy`` which uses Python 2.7, allows access to +system-wide packages like GStreamer, and uses the Mopidy workspace directory as the "project path", run:: mkvirtualenv -a ~/mopidy-dev --python `which python2.7` \ @@ -87,7 +87,7 @@ When you've cloned the ``mopidy`` Git repo, ``cd`` into it:: cd ~/mopidy-dev/mopidy/ With a fresh clone of the Git repo, you should start out on the ``develop`` -branch. This is where all features for the next feature release lands. To +branch. This is where all features for the next feature release land. To confirm that you're on the right branch, run:: git branch @@ -98,12 +98,11 @@ Install development tools We use a number of Python development tools. The :file:`dev-requirements.txt` file has comments describing what we use each dependency for, so we might just -as well show include the file verbatim here: +as well include the file verbatim here: .. literalinclude:: ../dev-requirements.txt -You probably won't use all of these development tools, but at least a -majority of them. Install them all into the active virtualenv by running `pip +Install them all into the active virtualenv by running `pip `_:: pip install --upgrade -r dev-requirements.txt @@ -115,7 +114,7 @@ Install Mopidy from the Git repo -------------------------------- Next up, we'll want to run Mopidy from the Git repo. There's two reasons for -this: First of all, it lets you easily change the source code, restart Mopidy, +this: first of all, it lets you easily change the source code, restart Mopidy, and see the change take effect. Second, it's a convenient way to keep at the bleeding edge, testing the latest developments in Mopidy itself or test some extension against the latest Mopidy changes. @@ -220,7 +219,7 @@ To stop it again, press :kbd:`Ctrl+C`. Every time you change code in Mopidy or an extension and want to see it live, you must restart Mopidy. -If you wan't to iterate quickly while developing, it may sound a bit tedious to +If you want to iterate quickly while developing, it may sound a bit tedious to restart Mopidy for every minor change. Then it's useful to have tests to exercise your code... @@ -282,8 +281,8 @@ We can limit to just tests in a single directory to save time:: py.test tests/http/ -With the help of the pytest-xdist plugin, we can run tests with four processes -in parallel, which usually cuts the test time in half or more:: +With the help of the pytest-xdist plugin, we can run tests with four Python +processes in parallel, which usually cuts the test time in half or more:: py.test -n 4 @@ -294,7 +293,7 @@ tests. This makes for a very quick code-test cycle:: py.test -f # or --looponfail With the help of the pytest-cov plugin, we can get a report on what parts of -the given module, ``mopidy`` in this example, is covered by the test suite:: +the given module, ``mopidy`` in this example, are covered by the test suite:: py.test --cov=mopidy --cov-report=term-missing @@ -322,15 +321,16 @@ contributions to Mopidy through GitHub will automatically be tested by Travis CI, and the build status will be visible in the GitHub pull request interface, making it easier to evaluate the quality of pull requests. -For each success build, Travis submits code coverage data to `coveralls.io +For each successful build, Travis submits code coverage data to `coveralls.io `_. If you're out of work, coveralls might help you find areas in the code which could need better test coverage. In addition, we run a Jenkins CI server at https://ci.mopidy.com/ that runs all -test on multiple platforms (Ubuntu, OS X, x86, arm) for every commit we push to -the ``develop`` branch in the main Mopidy repo on GitHub. Thus, new code isn't -tested by Jenkins before it is merged into the ``develop`` branch, which is a -bit late, but good enough to get broad testing before new code is released. +tests on multiple platforms (Ubuntu, OS X, x86, arm) for every commit we push +to the ``develop`` branch in the main Mopidy repo on GitHub. Thus, new code +isn't tested by Jenkins before it is merged into the ``develop`` branch, which +is a bit late, but good enough to get broad testing before new code is +released. .. _code-linting: From 8a0bf3c25f2a07f012443337c3793a099306c742 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 13:16:39 +0100 Subject: [PATCH 191/314] docs: Include commit message tips in the guidelines --- docs/contributing.rst | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 1b3b1330..ecfaea90 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -98,7 +98,19 @@ Pull request guidelines #. Feel free to include a changelog entry in your pull request. The changelog is in :file:`docs/changelog.rst`. -#. Write good commit messages. Here's three blog posts on how to do it right: +#. Write good commit messages. + + - Follow the template "topic: description" for the first line of the commit + message, e.g. "mpd: Switch list command to using list_distinct". See the + commit history for inspiration. + + - Use the rest of the commit message to explain anything you feel isn't + obvious. It's better to have the details here than in the pull request + description, since the commit message will live forever. + + - Write in the imperative, present tense: "add" not "added". + + For more inspiration, read these blog posts: - `Writing Git commit messages `_ From b90d18c8ac4be31d404aabc74b171f0a89dfcfe9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 17 Mar 2015 20:56:16 +0100 Subject: [PATCH 192/314] audio: Reduce most buffering message to trace level --- mopidy/audio/actor.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 63b0eebe..e137b944 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -343,15 +343,18 @@ class _Handler(object): self._audio._playbin, gst.DEBUG_GRAPH_SHOW_ALL, 'mopidy') def on_buffering(self, percent): - gst_logger.debug('Got buffering message: percent=%d%%', percent) - + level = logging.getLevelName('TRACE') if percent < 10 and not self._audio._buffering: self._audio._playbin.set_state(gst.STATE_PAUSED) self._audio._buffering = True + level = logging.DEBUG if percent == 100: self._audio._buffering = False if self._audio._target_state == gst.STATE_PLAYING: self._audio._playbin.set_state(gst.STATE_PLAYING) + level = logging.DEBUG + + gst_logger.log(level, 'Got buffering message: percent=%d%%', percent) def on_end_of_stream(self): gst_logger.debug('Got end-of-stream message.') From 8983608992c6c36d10e8da4f6902f8b9a19213c1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 17 Mar 2015 20:56:58 +0100 Subject: [PATCH 193/314] audio: Never buffer live sources as they would stall --- mopidy/audio/actor.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index e137b944..4805e617 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -279,7 +279,7 @@ class _Handler(object): if msg.type == gst.MESSAGE_STATE_CHANGED and msg.src == self._element: self.on_playbin_state_changed(*msg.parse_state_changed()) elif msg.type == gst.MESSAGE_BUFFERING: - self.on_buffering(msg.parse_buffering()) + self.on_buffering(msg.parse_buffering(), msg.structure) elif msg.type == gst.MESSAGE_EOS: self.on_end_of_stream() elif msg.type == gst.MESSAGE_ERROR: @@ -342,7 +342,11 @@ class _Handler(object): gst.DEBUG_BIN_TO_DOT_FILE( self._audio._playbin, gst.DEBUG_GRAPH_SHOW_ALL, 'mopidy') - def on_buffering(self, percent): + def on_buffering(self, percent, structure=None): + if structure and structure.has_field('buffering-mode'): + if structure['buffering-mode'] == gst.BUFFERING_LIVE: + return # Live sources stall in paused. + level = logging.getLevelName('TRACE') if percent < 10 and not self._audio._buffering: self._audio._playbin.set_state(gst.STATE_PAUSED) From b1448f584f0d90a697a1978e458729a7175c1449 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 17 Mar 2015 21:08:20 +0100 Subject: [PATCH 194/314] audio: Remove download flag from audio (fixes #1041) This should resolve the issue where Mopidy tries and download way to much of a remote track before playing it. --- mopidy/audio/actor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 4805e617..788fbab4 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -48,8 +48,10 @@ MB = 1 << 20 # GST_PLAY_FLAG_DEINTERLACE (1<<9) # GST_PLAY_FLAG_SOFT_COLORBALANCE (1<<10) -# Default flags to use for playbin: AUDIO, SOFT_VOLUME, DOWNLOAD -PLAYBIN_FLAGS = (1 << 1) | (1 << 4) | (1 << 7) +# Default flags to use for playbin: AUDIO, SOFT_VOLUME +# TODO: consider removing soft volume when we do multi outputs and handling it +# ourselves. +PLAYBIN_FLAGS = (1 << 1) | (1 << 4) class _Signals(object): From bdee9478893936e4e51b12d2258ec6f585f8aa23 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 21:24:32 +0100 Subject: [PATCH 195/314] docs: Fix review comments --- docs/conf.py | 2 +- docs/contributing.rst | 17 ++++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 88ea49f0..22ecb6fa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -84,7 +84,7 @@ def setup(app): # -- General configuration ---------------------------------------------------- -needs_sphinx = '1.0' +needs_sphinx = '1.3' extensions = [ 'sphinx.ext.autodoc', diff --git a/docs/contributing.rst b/docs/contributing.rst index ecfaea90..b5230b18 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -12,13 +12,14 @@ If you want to contribute to Mopidy, here are some tips to get you started. Asking questions ================ -Please use one of these channels for requesting help with Mopidy and its -extensions: +Please get in touch with us in one of these ways when requesting help with +Mopidy and its extensions: - Our discussion forum: `discuss.mopidy.com `_. Just sign in and fire away. -- Our IRC channel: ``#mopidy`` on `irc.freenode.net `_, +- Our IRC channel: `#mopidy `_ + on `irc.freenode.net `_, with public `searchable logs `_. Be prepared to hang around for a while, as we're not always around to answer straight away. @@ -80,11 +81,13 @@ Pull request guidelines bug fix. Keep branches small and on topic, as that makes them far easier to review. We often use the following naming convention for branches: - - Features get the prefix ``feature/`` + - Features get the prefix ``feature/``, e.g. + ``feature/track-last-modified-as-ms``. - - Bug fixes get the prefix ``fix/`` + - Bug fixes get the prefix ``fix/``, e.g. ``fix/902-consume-track-on-next``. - - Improvements to the documentation get the prefix ``docs/`` + - Improvements to the documentation get the prefix ``docs/``, e.g. + ``docs/add-ext-mopidy-spotify-tunigo``. #. Follow the :ref:`code style `, especially make sure the ``flake8`` linter does not complain about anything. Travis CI will check @@ -110,7 +113,7 @@ Pull request guidelines - Write in the imperative, present tense: "add" not "added". - For more inspiration, read these blog posts: + For more inspiration, feel free to read these blog posts: - `Writing Git commit messages `_ From 3559e61d75ef2a32f63e3cb54c9ad1e276b3b8dd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 21:34:20 +0100 Subject: [PATCH 196/314] docs: Don't require Sphinx 1.3 to build --- docs/conf.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 22ecb6fa..71813ad7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -84,7 +84,7 @@ def setup(app): # -- General configuration ---------------------------------------------------- -needs_sphinx = '1.3' +needs_sphinx = '1.0' extensions = [ 'sphinx.ext.autodoc', @@ -114,7 +114,10 @@ modindex_common_prefix = ['mopidy.'] # -- Options for HTML output -------------------------------------------------- -html_theme = 'sphinx_rtd_theme' +# 'sphinx_rtd_theme' is bundled with Sphinx 1.3, which we don't have when +# building the docs as part of the Debian packages on e.g. Debian wheezy. +#html_theme = 'sphinx_rtd_theme' +html_theme = 'default' html_theme_path = ['_themes'] html_static_path = ['_static'] From 4972d1da57a1ecbc0a4f8c39567aee8b71d809ef Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 22:02:34 +0100 Subject: [PATCH 197/314] Decode all strerror-based exception messages I reviewed all instances of: - EnvironmentError - OSError - IOError - socket.error In most cases, we already used encoding.locale_decode(). The case fixed in mopidy/utils/network.py fixes #971. The case fixed in mopidy/utils/path.py might be triggered during a local library scan. --- mopidy/utils/network.py | 3 ++- mopidy/utils/path.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index ce02ef0e..f55649e3 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -199,7 +199,8 @@ class Connection(object): except socket.error as e: if e.errno in (errno.EWOULDBLOCK, errno.EINTR): return data - self.stop('Unexpected client error: %s' % e) + self.stop( + 'Unexpected client error: %s' % encoding.locale_decode(e)) return b'' def enable_timeout(self): diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 0c0d6676..8bca275d 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -12,6 +12,7 @@ import glib from mopidy import compat, exceptions from mopidy.compat import queue +from mopidy.utils import encoding logger = logging.getLogger(__name__) @@ -157,7 +158,8 @@ def _find_worker(relative, follow, done, work, results, errors): errors[path] = exceptions.FindError('Not a file or directory.') except OSError as e: - errors[path] = exceptions.FindError(e.strerror, e.errno) + errors[path] = exceptions.FindError( + encoding.locale_decode(e.strerror), e.errno) finally: work.task_done() From aae545f2fee44a5da7e0dc3f1e4ef82e0caff52b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 22:07:55 +0100 Subject: [PATCH 198/314] docs: Update changelog for PR#1044 --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 81d8a2f1..33bfc9f7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -144,6 +144,10 @@ v0.20.0 (UNRELEASED) - Start setting the ``Name`` field with the stream title when listening to radio streams. (Fixes: :issue:`944`, PR: :issue:`1030`) +- Fix crash on socket error when using a locale causing the exception's error + message to contain characters not in ASCII. (Fixes: issue:`971`, PR: + :issue:`1044`) + **HTTP frontend** - **Deprecated:** Deprecated the :confval:`http/static_dir` config. Please make From fdc84c3905dc7b0d69414b9a1adcab6587b60fa7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 17 Mar 2015 23:41:09 +0100 Subject: [PATCH 199/314] core: Add uris argument to library.lookup (Fixes #1008) For now this doesn't add any corresponding APIs to backends, or for that matter tracklist.add(uris). This is just to get the API in for clients in 0.20. --- docs/changelog.rst | 3 +++ mopidy/core/library.py | 35 +++++++++++++++++++++++++++-------- tests/core/test_library.py | 11 +++++++++++ 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 33bfc9f7..33468f60 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -31,6 +31,9 @@ v0.20.0 (UNRELEASED) - Add :class:`mopidy.core.MixerController` which keeps track of volume and mute. (Fixes: :issue:`962`) +- Add ``uris`` argument to :method:`mopidy.core.LibraryController.lookup` + which allows for simpler lookup of multiple URIs. (Fixes: :issue:`1008`) + - **Deprecated:** The old methods on :class:`mopidy.core.PlaybackController` for volume and mute management have been deprecated. (Fixes: :issue:`962`) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 49a4a796..906618d8 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -162,7 +162,7 @@ class LibraryController(object): in self._get_backends_to_uris(uris).items()] return [result for result in pykka.get_all(futures) if result] - def lookup(self, uri): + def lookup(self, uri=None, uris=None): """ Lookup the given URI. @@ -170,14 +170,33 @@ class LibraryController(object): them all. :param uri: track URI - :type uri: string - :rtype: list of :class:`mopidy.models.Track` + :type uri: string or :class:`None` + :param uris: track URIs + :type uris: list of string or :class:`None` + :rtype: list of :class:`mopidy.models.Track` if uri was set or a + ``{uri: list of tracks}`` if uris was set. """ - backend = self._get_backend(uri) - if backend: - return backend.library.lookup(uri).get() - else: - return [] + none_set = uri is None and uris is None + both_set = uri is not None and uris is not None + + if none_set or both_set: + raise ValueError("One of 'uri' or 'uris' must be set") + + futures = {} + result = {} + backends = self._get_backends_to_uris([uri] if uri else uris) + + # TODO: lookup(uris) to backend APIs + for backend, backend_uris in backends.items(): + for u in backend_uris or []: + futures[u] = backend.library.lookup(u) + + for u, future in futures.items(): + result[u] = future.get() + + if uri: + return result.get(uri, []) + return result def refresh(self, uri=None): """ diff --git a/tests/core/test_library.py b/tests/core/test_library.py index ccf1b349..b71e5de5 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -157,6 +157,17 @@ class CoreLibraryTest(unittest.TestCase): self.assertFalse(self.library1.lookup.called) self.library2.lookup.assert_called_once_with('dummy2:a') + def test_lookup_fails_with_uri_and_uris_set(self): + with self.assertRaises(ValueError): + self.core.library.lookup('dummy1:a', ['dummy2:a']) + + def test_lookup_can_handle_uris(self): + self.library1.lookup().get.return_value = [1234] + self.library2.lookup().get.return_value = [5678] + + result = self.core.library.lookup(uris=['dummy1:a', 'dummy2:a']) + self.assertEqual(result, {'dummy2:a': [5678], 'dummy1:a': [1234]}) + def test_lookup_returns_nothing_for_dummy3_track(self): result = self.core.library.lookup('dummy3:a') From 08bdf5c14bc7790be31586cc1c32852fedb6a777 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 18 Mar 2015 00:10:45 +0100 Subject: [PATCH 200/314] core: Update library.lookup() docstring --- mopidy/core/library.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 906618d8..ec94dccf 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -173,8 +173,13 @@ class LibraryController(object): :type uri: string or :class:`None` :param uris: track URIs :type uris: list of string or :class:`None` - :rtype: list of :class:`mopidy.models.Track` if uri was set or a - ``{uri: list of tracks}`` if uris was set. + :rtype: {uri: list of :class:`mopidy.models.Track`} + + .. versionadded:: 0.20 + The ``uris`` argument. + + .. deprecated:: 0.20 + The ``uri`` argument. Use ``uris`` instead. """ none_set = uri is None and uris is None both_set = uri is not None and uris is not None From 65c5242b14eed8303831dffe78ef4224a7868a29 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 23:37:31 +0100 Subject: [PATCH 201/314] backend: Remove default impl of PlaylistProvider.playlists The default was insane. For one, because overriding e.g. just the getter would make the property have a pair of working getter and setter that are entirely disconnected. --- mopidy/backend.py | 7 ++----- mopidy/local/playlists.py | 10 ++++++++++ tests/backend/test_backend.py | 12 +++++++++++- tests/dummy_backend.py | 14 ++++++++++++++ 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index f7808ac8..7e020b77 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -1,7 +1,5 @@ from __future__ import absolute_import, unicode_literals -import copy - from mopidy import listener, models @@ -263,7 +261,6 @@ class PlaylistsProvider(object): def __init__(self, backend): self.backend = backend - self._playlists = [] # TODO Replace playlists property with a get_playlists() method which # returns playlist Ref's instead of the gigantic data structures we @@ -277,11 +274,11 @@ class PlaylistsProvider(object): Read/write. List of :class:`mopidy.models.Playlist`. """ - return copy.copy(self._playlists) + return [] @playlists.setter # noqa def playlists(self, playlists): - self._playlists = playlists + raise NotImplementedError def create(self, name): """ diff --git a/mopidy/local/playlists.py b/mopidy/local/playlists.py index ba4dbf02..f2b712c5 100644 --- a/mopidy/local/playlists.py +++ b/mopidy/local/playlists.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, division, unicode_literals +import copy import glob import logging import operator @@ -20,8 +21,17 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): super(LocalPlaylistsProvider, self).__init__(*args, **kwargs) self._media_dir = self.backend.config['local']['media_dir'] self._playlists_dir = self.backend.config['local']['playlists_dir'] + self._playlists = [] self.refresh() + @property + def playlists(self): + return copy.copy(self._playlists) + + @playlists.setter + def playlists(self, playlists): + self._playlists = playlists + def create(self, name): playlist = self._save_m3u(Playlist(name=name)) old_playlist = self.lookup(playlist.uri) diff --git a/tests/backend/test_backend.py b/tests/backend/test_backend.py index 7c6cc82b..c72633fb 100644 --- a/tests/backend/test_backend.py +++ b/tests/backend/test_backend.py @@ -2,7 +2,7 @@ from __future__ import absolute_import, unicode_literals import unittest -from mopidy import models +from mopidy import backend, models from tests import dummy_backend @@ -28,3 +28,13 @@ class LibraryTest(unittest.TestCase): expected = {'trackuri': []} self.assertEqual(library.get_images(['trackuri']), expected) + + +class PlaylistsTest(unittest.TestCase): + def test_playlists_default_impl(self): + playlists = backend.PlaylistsProvider(backend=None) + + self.assertEqual(playlists.playlists, []) + + with self.assertRaises(NotImplementedError): + playlists.playlists = [] diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index 9c5a8c0c..d0816096 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -6,6 +6,8 @@ used in tests of the frontends. from __future__ import absolute_import, unicode_literals +import copy + import pykka from mopidy import backend @@ -85,6 +87,18 @@ class DummyPlaybackProvider(backend.PlaybackProvider): class DummyPlaylistsProvider(backend.PlaylistsProvider): + def __init__(self, backend): + super(DummyPlaylistsProvider, self).__init__(backend) + self._playlists = [] + + @property + def playlists(self): + return copy.copy(self._playlists) + + @playlists.setter + def playlists(self, playlists): + self._playlists = playlists + def create(self, name): playlist = Playlist(name=name, uri='dummy:%s' % name) self._playlists.append(playlist) From accc1e72491a3a4375d1e299880bd9d00beb0963 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 17 Mar 2015 23:41:18 +0100 Subject: [PATCH 202/314] docs: Update changelog for PR#1046 --- docs/changelog.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 33bfc9f7..e0415005 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -52,6 +52,12 @@ v0.20.0 (UNRELEASED) :meth:`mopidy.core.PlaybackController.get_stream_title` for letting clients know about the current song in streams. (PR: :issue:`938`, :issue:`1030`) +**Backend API** + +- Remove default implementation of + :attr:`mopidy.backend.PlaylistsProvider.playlists`. This is potentially + backwards incompatible. (PR: :issue:`1046`) + **Commands** - Make the ``mopidy`` command print a friendly error message if the From 93afea50a20e9c6e1f5d38a88a79bb6ba4ba86dd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 18 Mar 2015 21:58:58 +0100 Subject: [PATCH 203/314] docs: Change next release from 0.20 to 1.0 --- docs/changelog.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e0415005..37716e96 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,8 +5,8 @@ Changelog This changelog is used to track all major changes to Mopidy. -v0.20.0 (UNRELEASED) -==================== +v1.0.0 (UNRELEASED) +=================== **Models** From a05c0971063902ed544d38c93dc6f77a026c2815 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 18 Mar 2015 21:59:58 +0100 Subject: [PATCH 204/314] docs: Change deprecated-in from 0.20 to 1.0 Fixes #1051 --- mopidy/audio/actor.py | 2 +- mopidy/core/actor.py | 4 ++-- mopidy/core/playback.py | 20 ++++++++++---------- mopidy/core/playlists.py | 2 +- mopidy/core/tracklist.py | 16 ++++++++-------- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 788fbab4..b4c78ecb 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -602,7 +602,7 @@ class Audio(pykka.ThreadingActor): We will get a GStreamer message when the stream playback reaches the token, and can then do any end-of-stream related tasks. - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`emit_data` with a :class:`None` buffer instead. """ self._appsrc.push(None) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 671517ca..32070684 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -69,7 +69,7 @@ class Core( uri_schemes = deprecated_property(get_uri_schemes) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_uri_schemes` instead. """ @@ -79,7 +79,7 @@ class Core( version = deprecated_property(get_version) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_version` instead. """ diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index d2e4a1c2..86bc54c0 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -51,7 +51,7 @@ class PlaybackController(object): current_tl_track = deprecated_property( get_current_tl_track, set_current_tl_track) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_current_tl_track` instead. """ @@ -69,7 +69,7 @@ class PlaybackController(object): current_track = deprecated_property(get_current_track) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_current_track` instead. """ @@ -106,7 +106,7 @@ class PlaybackController(object): state = deprecated_property(get_state, set_state) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_state` and :meth:`set_state` instead. """ @@ -120,13 +120,13 @@ class PlaybackController(object): time_position = deprecated_property(get_time_position) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_time_position` instead. """ def get_volume(self): """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`core.mixer.get_volume() ` instead. """ @@ -136,7 +136,7 @@ class PlaybackController(object): def set_volume(self, volume): """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`core.mixer.set_volume() ` instead. """ @@ -146,7 +146,7 @@ class PlaybackController(object): volume = deprecated_property(get_volume, set_volume) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`core.mixer.get_volume() ` and :meth:`core.mixer.set_volume() @@ -155,7 +155,7 @@ class PlaybackController(object): def get_mute(self): """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`core.mixer.get_mute() ` instead. """ @@ -164,7 +164,7 @@ class PlaybackController(object): def set_mute(self, mute): """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`core.mixer.set_mute() ` instead. """ @@ -173,7 +173,7 @@ class PlaybackController(object): mute = deprecated_property(get_mute, set_mute) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`core.mixer.get_mute() ` and :meth:`core.mixer.set_mute() diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 3d368c29..5680c018 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -32,7 +32,7 @@ class PlaylistsController(object): playlists = deprecated_property(get_playlists) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_playlists` instead. """ diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 08d08329..ad8e61d0 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -32,7 +32,7 @@ class TracklistController(object): tl_tracks = deprecated_property(get_tl_tracks) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_tl_tracks` instead. """ @@ -42,7 +42,7 @@ class TracklistController(object): tracks = deprecated_property(get_tracks) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_tracks` instead. """ @@ -52,7 +52,7 @@ class TracklistController(object): length = deprecated_property(get_length) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_length` instead. """ @@ -72,7 +72,7 @@ class TracklistController(object): version = deprecated_property(get_version) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_version` instead. """ @@ -100,7 +100,7 @@ class TracklistController(object): consume = deprecated_property(get_consume, set_consume) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_consume` and :meth:`set_consume` instead. """ @@ -132,7 +132,7 @@ class TracklistController(object): random = deprecated_property(get_random, set_random) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_random` and :meth:`set_random` instead. """ @@ -165,7 +165,7 @@ class TracklistController(object): repeat = deprecated_property(get_repeat, set_repeat) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_repeat` and :meth:`set_repeat` instead. """ @@ -195,7 +195,7 @@ class TracklistController(object): single = deprecated_property(get_single, set_single) """ - .. deprecated:: 0.20 + .. deprecated:: 1.0 Use :meth:`get_single` and :meth:`set_single` instead. """ From 26d07b2cfe8cba89915e0e43479ddae0bdbfc88e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 18 Mar 2015 22:10:47 +0100 Subject: [PATCH 205/314] docs: Remove API stability disclaimers Not as if we've had the freedom to break anything for ages anyway. Fixes #1049 --- docs/api/http.rst | 12 ------------ docs/api/index.rst | 7 ++----- docs/api/js.rst | 12 ------------ 3 files changed, 2 insertions(+), 29 deletions(-) diff --git a/docs/api/http.rst b/docs/api/http.rst index 3eff14fd..9a7d56bb 100644 --- a/docs/api/http.rst +++ b/docs/api/http.rst @@ -14,18 +14,6 @@ WebSocket API for use both from browsers and Node.js. The :ref:`http-explore-extension` extension, can also be used to get you familiarized with HTTP based APIs. -.. warning:: API stability - - Since the HTTP JSON-RPC API exposes our internal core API directly it is to - be regarded as **experimental**. We cannot promise to keep any form of - backwards compatibility between releases as we will need to change the core - API while working out how to support new use cases. Thus, if you use this - API, you must expect to do small adjustments to your client for every - release of Mopidy. - - From Mopidy 1.0 and onwards, we intend to keep the core API far more - stable. - .. _http-post-api: diff --git a/docs/api/index.rst b/docs/api/index.rst index 5aac825c..2402186e 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -4,13 +4,10 @@ API reference ************* -.. warning:: API stability +.. note:: What is public? Only APIs documented here are public and open for use by Mopidy - extensions. We will change these APIs, but will keep the changelog up to - date with all breaking changes. - - From Mopidy 1.0 and onwards, we intend to keep these APIs far more stable. + extensions. .. toctree:: diff --git a/docs/api/js.rst b/docs/api/js.rst index fffb40fa..29866d14 100644 --- a/docs/api/js.rst +++ b/docs/api/js.rst @@ -8,18 +8,6 @@ We've made a JavaScript library, Mopidy.js, which wraps the :ref:`websocket-api` and gets you quickly started with working on your client instead of figuring out how to communicate with Mopidy. -.. warning:: API stability - - Since the Mopidy.js API exposes our internal core API directly it is to be - regarded as **experimental**. We cannot promise to keep any form of - backwards compatibility between releases as we will need to change the core - API while working out how to support new use cases. Thus, if you use this - API, you must expect to do small adjustments to your client for every - release of Mopidy. - - From Mopidy 1.0 and onwards, we intend to keep the core API far more - stable. - Getting the library for browser use =================================== From 71e2b21b5204b78d2b1f2f220cbce56037532bd4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 18 Mar 2015 23:09:09 +0100 Subject: [PATCH 206/314] review: Minor fixes and updates --- docs/changelog.rst | 3 ++- mopidy/core/library.py | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 33468f60..f1a33c4b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -32,7 +32,8 @@ v0.20.0 (UNRELEASED) mute. (Fixes: :issue:`962`) - Add ``uris`` argument to :method:`mopidy.core.LibraryController.lookup` - which allows for simpler lookup of multiple URIs. (Fixes: :issue:`1008`) + which allows for simpler lookup of multiple URIs. (Fixes: :issue:`1008`, + PR: :issue:`1047`) - **Deprecated:** The old methods on :class:`mopidy.core.PlaybackController` for volume and mute management have been deprecated. (Fixes: :issue:`962`) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index ec94dccf..f2a8b9bd 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -173,12 +173,13 @@ class LibraryController(object): :type uri: string or :class:`None` :param uris: track URIs :type uris: list of string or :class:`None` - :rtype: {uri: list of :class:`mopidy.models.Track`} + :rtype: list of :class:`mopidy.models.Track` if uri was set or + a {uri: list of :class:`mopidy.models.Track`} if uris was set. - .. versionadded:: 0.20 + .. versionadded:: 1.0 The ``uris`` argument. - .. deprecated:: 0.20 + .. deprecated:: 1.0 The ``uri`` argument. Use ``uris`` instead. """ none_set = uri is None and uris is None From 4692e7305431a2ff533b14fab111f4f0b195fa9f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 18 Mar 2015 23:40:03 +0100 Subject: [PATCH 207/314] docs: Add section on semantic versioning Fixes #1050 --- docs/versioning.rst | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/docs/versioning.rst b/docs/versioning.rst index cc7f58bc..cd428366 100644 --- a/docs/versioning.rst +++ b/docs/versioning.rst @@ -2,22 +2,36 @@ Versioning ********** -Mopidy uses `Semantic Versioning `_, but since we're still -pre-1.0 that doesn't mean much yet. +Mopidy follows `Semantic Versioning `_. In summary this +means that our version numbers have three parts, MAJOR.MINOR.PATCH, which +change according to the following rules: + +- When we *make incompatible API changes*, we increase the MAJOR number. + +- When we *add features* in a backwards-compatible manner, we increase the + MINOR number. + +- When we *fix bugs* in a backwards-compatible manner, we increase the PATCH + number. + +The promise is that if you make a Mopidy extension for Mopidy 1.0, it should +work unchanged with any Mopidy 1.x release, but probably not with 2.0. When a +new major version is released, you must review the incompatible changes and +update your extension accordingly. Release schedule ================ We intend to have about one feature release every month in periods of active -development. The feature releases are numbered 0.x.0. The features added is a -mix of what we feel is most important/requested of the missing features, and -features we develop just because we find them fun to make, even though they may -be useful for very few users or for a limited use case. +development. The features added is a mix of what we feel is most +important/requested of the missing features, and features we develop just +because we find them fun to make, even though they may be useful for very few +users or for a limited use case. -Bugfix releases, numbered 0.x.y, will be released whenever we discover bugs -that are too serious to wait for the next feature release. We will only release -bugfix releases for the last feature release. E.g. when 0.14.0 is released, we -will no longer provide bugfix releases for the 0.13 series. In other words, -there will be just a single supported release at any point in time. This is to -not spread our limited resources too thin. +Bugfix releases will be released whenever we discover bugs that are too serious +to wait for the next feature release. We will only release bugfix releases for +the last feature release. E.g. when 1.2.0 is released, we will no longer +provide bugfix releases for the 1.1.x series. In other words, there will be just +a single supported release at any point in time. This is to not spread our +limited resources too thin. From c93dd34c93a1f0d3cc44a1e92f267d9c09c91328 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 19 Mar 2015 00:02:03 +0100 Subject: [PATCH 208/314] pypi: Up dev status to '5 - Production/Stable' --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 0d29c041..49940c15 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ setup( ], }, classifiers=[ - 'Development Status :: 4 - Beta', + 'Development Status :: 5 - Production/Stable', 'Environment :: No Input/Output (Daemon)', 'Intended Audience :: End Users/Desktop', 'License :: OSI Approved :: Apache Software License', From b28757979353220e48b0f4e15734757ca2250315 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Fri, 20 Mar 2015 08:11:14 +0100 Subject: [PATCH 209/314] docs: Add Mopidy-Local-Images. --- docs/ext/backends.rst | 11 +++++++++++ docs/ext/local_images.jpg | Bin 0 -> 69918 bytes docs/ext/web.rst | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 docs/ext/local_images.jpg diff --git a/docs/ext/backends.rst b/docs/ext/backends.rst index 6f3195ff..17e2a7ca 100644 --- a/docs/ext/backends.rst +++ b/docs/ext/backends.rst @@ -90,6 +90,17 @@ Mopidy-Local Bundled with Mopidy. See :ref:`ext-local`. +Mopidy-Local-Images +=================== + +https://github.com/tkem/mopidy-local-images + +Extension which plugs into Mopidy-Local to allow Web clients access to +album art embedded in local media files. Not to be used on its own, +but acting as a proxy between ``mopidy local scan`` and the actual +local library provider being used. + + Mopidy-Local-SQLite =================== diff --git a/docs/ext/local_images.jpg b/docs/ext/local_images.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a5336c4672155469e948e6655b273258ad2ea901 GIT binary patch literal 69918 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 literal 0 HcmV?d00001 diff --git a/docs/ext/web.rst b/docs/ext/web.rst index b4a9660f..7355dbf9 100644 --- a/docs/ext/web.rst +++ b/docs/ext/web.rst @@ -30,6 +30,24 @@ To install, run:: pip install Mopidy-API-Explorer +Mopidy-Local-Images +=================== + +https://github.com/tkem/mopidy-local-images + +Not a full-featured Web client, but rather a local library and Web +extension which allows other Web clients access to album art embedded +in local media files. + +.. image:: /ext/local_images.jpg + :width: 640 + :height: 480 + +To install, run:: + + pip install Mopidy-Local-Images + + Mopidy-Mobile ============= From c57f3ec9b2f40497bd863cad250daf1aab50af0e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Mar 2015 22:28:38 +0100 Subject: [PATCH 210/314] core: Make tracklist.mark_*() private Fixes #1058 --- docs/changelog.rst | 7 +++++++ mopidy/core/playback.py | 8 ++++---- mopidy/core/tracklist.py | 12 ++++++------ 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index b588011c..803e4373 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -56,6 +56,13 @@ v1.0.0 (UNRELEASED) :meth:`mopidy.core.PlaybackController.get_stream_title` for letting clients know about the current song in streams. (PR: :issue:`938`, :issue:`1030`) +- The following methods were documented as internal. They are now fully private + and unavailable outside the core actor. (Fixes: :issue:`1058`) + + - :meth:`mopidy.core.TracklistController.mark_played` + - :meth:`mopidy.core.TracklistController.mark_playing` + - :meth:`mopidy.core.TracklistController.mark_unplayable` + **Backend API** - Remove default implementation of diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 86bc54c0..c00f86fd 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -220,7 +220,7 @@ class PlaybackController(object): self.stop() self.set_current_tl_track(None) - self.core.tracklist.mark_played(original_tl_track) + self.core.tracklist._mark_played(original_tl_track) def on_tracklist_change(self): """ @@ -255,7 +255,7 @@ class PlaybackController(object): self.stop() self.set_current_tl_track(None) - self.core.tracklist.mark_played(original_tl_track) + self.core.tracklist._mark_played(original_tl_track) def pause(self): """Pause playback.""" @@ -311,12 +311,12 @@ class PlaybackController(object): success = backend and backend.playback.play(tl_track.track).get() if success: - self.core.tracklist.mark_playing(tl_track) + self.core.tracklist._mark_playing(tl_track) self.core.history.add(tl_track.track) # TODO: replace with stream-changed self._trigger_track_playback_started() else: - 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() diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index ad8e61d0..456bddf6 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -499,19 +499,19 @@ class TracklistController(object): """ return self._tl_tracks[start:end] - def mark_playing(self, tl_track): - """Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**""" + def _mark_playing(self, tl_track): + """Internal method for :class:`mopidy.core.PlaybackController`.""" if self.get_random() and tl_track in self._shuffled: self._shuffled.remove(tl_track) - def mark_unplayable(self, tl_track): - """Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**""" + def _mark_unplayable(self, tl_track): + """Internal method for :class:`mopidy.core.PlaybackController`.""" logger.warning('Track is not playable: %s', tl_track.track.uri) if self.get_random() and tl_track in self._shuffled: self._shuffled.remove(tl_track) - def mark_played(self, tl_track): - """Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**""" + def _mark_played(self, tl_track): + """Internal method for :class:`mopidy.core.PlaybackController`.""" if self.consume and tl_track is not None: self.remove(tlid=[tl_track.tlid]) return True From 35a8fecd5dec228751366e34500bf1097d83afa9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Mar 2015 22:39:56 +0100 Subject: [PATCH 211/314] docs: Add PR#1062 to changelog --- docs/changelog.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 803e4373..d63ce7b8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -57,7 +57,8 @@ v1.0.0 (UNRELEASED) know about the current song in streams. (PR: :issue:`938`, :issue:`1030`) - The following methods were documented as internal. They are now fully private - and unavailable outside the core actor. (Fixes: :issue:`1058`) + and unavailable outside the core actor. (Fixes: :issue:`1058`, PR: + :issue:`1062`) - :meth:`mopidy.core.TracklistController.mark_played` - :meth:`mopidy.core.TracklistController.mark_playing` From 861f60e6f10c6bd0d9fcbcba749cb2c0a4fc6cb1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 20 Mar 2015 22:34:36 +0100 Subject: [PATCH 212/314] core: Make history.add() private Instead of changing the signature to add(uri, name) I opted for renaming it to _add_track(track). Since it's internal we may change it whenever we like to. Since you need different logic for extracting an interesting name from a track and from a ref or a stream title, it makes sense to add another method for adding refs/stream titles to the history when that time comes. Fixes #1056 --- docs/changelog.rst | 3 ++- mopidy/core/history.py | 4 +++- mopidy/core/playback.py | 2 +- tests/core/test_history.py | 10 +++++----- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index d63ce7b8..5dc90c17 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,7 +26,8 @@ v1.0.0 (UNRELEASED) the Python API with the WebSocket/JavaScript API. (Fixes: :issue:`952`) - Add :class:`mopidy.core.HistoryController` which keeps track of what tracks - have been played. (Fixes: :issue:`423`, PR: :issue:`803`) + have been played. (Fixes: :issue:`423`, :issue:`1056`, PR: :issue:`803`, + :issue:`1063`) - Add :class:`mopidy.core.MixerController` which keeps track of volume and mute. (Fixes: :issue:`962`) diff --git a/mopidy/core/history.py b/mopidy/core/history.py index 9d7cf59f..f0d5e9d4 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -15,9 +15,11 @@ class HistoryController(object): def __init__(self): self._history = [] - def add(self, track): + def _add_track(self, track): """Add track to the playback history. + Internal method for :class:`mopidy.core.PlaybackController`. + :param track: track to add :type track: :class:`mopidy.models.Track` """ diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index c00f86fd..0b714598 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -312,7 +312,7 @@ class PlaybackController(object): if success: self.core.tracklist._mark_playing(tl_track) - self.core.history.add(tl_track.track) + self.core.history._add_track(tl_track.track) # TODO: replace with stream-changed self._trigger_track_playback_started() else: diff --git a/tests/core/test_history.py b/tests/core/test_history.py index 42922e52..48062aaf 100644 --- a/tests/core/test_history.py +++ b/tests/core/test_history.py @@ -18,24 +18,24 @@ class PlaybackHistoryTest(unittest.TestCase): self.history = HistoryController() def test_add_track(self): - self.history.add(self.tracks[0]) + self.history._add_track(self.tracks[0]) self.assertEqual(self.history.get_length(), 1) - self.history.add(self.tracks[1]) + self.history._add_track(self.tracks[1]) self.assertEqual(self.history.get_length(), 2) - self.history.add(self.tracks[2]) + self.history._add_track(self.tracks[2]) self.assertEqual(self.history.get_length(), 3) def test_non_tracks_are_rejected(self): with self.assertRaises(TypeError): - self.history.add(object()) + self.history._add_track(object()) self.assertEqual(self.history.get_length(), 0) def test_history_entry_contents(self): track = self.tracks[0] - self.history.add(track) + self.history._add_track(track) result = self.history.get_history() (timestamp, ref) = result[0] From bbf52eede9bb503c122225dd16414f33f591cf7f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 21 Mar 2015 00:05:00 +0100 Subject: [PATCH 213/314] backend: Change playback API (breaking change!) While trying to remove traces of stop calls in core to get gapless working I found we had no way to switch to switch tracks without triggering a play. This change fixes this by changing the backends playback provider API. - play() now _only_ starts playback and does not take any arguments. - prepare_change() has been added, this could have been avoided with a kwarg to change_track(track), but that would break more backends. - core has been updated to call prepare_change+change_track+play as needed. - tests have been updated to handle this change. Longer term I hope to completely rework the playback API in backends, as 99% of our backends only use change_track(track) to translate URIs. So we should make simple case simple, and handle mopidy-spotify / appsrc in some other way. Cherry picked from the WIP gapless branch. --- mopidy/backend.py | 29 +++++++++++++++++++++++------ mopidy/core/playback.py | 7 ++++++- tests/core/test_playback.py | 12 +++++++++--- tests/dummy_backend.py | 13 +++++++++++-- tests/local/test_playback.py | 12 ++++++++---- 5 files changed, 57 insertions(+), 16 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index 7e020b77..3852b1d4 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -177,26 +177,40 @@ class PlaybackProvider(object): """ return self.audio.pause_playback().get() - def play(self, track): + def play(self): """ - Play given track. + Start playback. *MAY be reimplemented by subclass.* - :param track: the track to play - :type track: :class:`mopidy.models.Track` :rtype: :class:`True` if successful, else :class:`False` """ - self.audio.prepare_change() - self.change_track(track) return self.audio.start_playback().get() + def prepare_change(self): + """ + Indicate that an URI change is about to happen. + + *MAY be reimplemented by subclass.* + + It is extremely unlikely it makes sense for any backends to override + this. For most practical purposes it should be considered an internal + call between backends and core that backend authors should not touch. + """ + self.audio.prepare_change().get() + def change_track(self, track): """ Swith to provided track. *MAY be reimplemented by subclass.* + This is very likely the *only* thing you need to override as a backend + author. Typically this is where you convert any mopidy specific URIs + to real URIs and then return:: + + return super(MyBackend, self).change_track(track.copy(uri=new_uri)) + :param track: the track to play :type track: :class:`mopidy.models.Track` :rtype: :class:`True` if successful, else :class:`False` @@ -232,6 +246,9 @@ class PlaybackProvider(object): *MAY be reimplemented by subclass.* + Should not be used for tracking if tracks have been played / when we + are done playing them. + :rtype: :class:`True` if successful, else :class:`False` """ return self.audio.stop_playback().get() diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 0b714598..4f51f328 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -308,7 +308,12 @@ class PlaybackController(object): self.set_current_tl_track(tl_track) self.set_state(PlaybackState.PLAYING) backend = self._get_backend() - success = backend and backend.playback.play(tl_track.track).get() + success = False + + if backend: + backend.playback.prepare_change() + backend.playback.change_track(tl_track.track) + success = backend.playback.play().get() if success: self.core.tracklist._mark_playing(tl_track) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index e6dc3ce1..e84e7301 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -100,19 +100,25 @@ class CorePlaybackTest(unittest.TestCase): def test_play_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) - self.playback1.play.assert_called_once_with(self.tracks[0]) + self.playback1.prepare_change.assert_called_once_with() + self.playback1.change_track.assert_called_once_with(self.tracks[0]) + self.playback1.play.assert_called_once_with() self.assertFalse(self.playback2.play.called) def test_play_selects_dummy2_backend(self): self.core.playback.play(self.tl_tracks[1]) self.assertFalse(self.playback1.play.called) - self.playback2.play.assert_called_once_with(self.tracks[1]) + self.playback2.prepare_change.assert_called_once_with() + self.playback2.change_track.assert_called_once_with(self.tracks[1]) + self.playback2.play.assert_called_once_with() def test_play_skips_to_next_on_unplayable_track(self): self.core.playback.play(self.unplayable_tl_track) - self.playback1.play.assert_called_once_with(self.tracks[3]) + self.playback1.prepare_change.assert_called_once_with() + self.playback1.change_track.assert_called_once_with(self.tracks[3]) + self.playback1.play.assert_called_once_with() self.assertFalse(self.playback2.play.called) self.assertEqual( diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index d0816096..d4441673 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -62,15 +62,23 @@ class DummyLibraryProvider(backend.LibraryProvider): class DummyPlaybackProvider(backend.PlaybackProvider): def __init__(self, *args, **kwargs): super(DummyPlaybackProvider, self).__init__(*args, **kwargs) + self._uri = None self._time_position = 0 def pause(self): return True - def play(self, track): + def play(self): + return self._uri and self._uri != 'dummy:error' + + def change_track(self, track): """Pass a track with URI 'dummy:error' to force failure""" + self._uri = track.uri self._time_position = 0 - return track.uri != 'dummy:error' + return True + + def prepare_change(self): + pass def resume(self): return True @@ -80,6 +88,7 @@ class DummyPlaybackProvider(backend.PlaybackProvider): return True def stop(self): + self._uri = None return True def get_time_position(self): diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index 3ccd8d8f..4c4ded24 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -154,7 +154,8 @@ class LocalPlaybackProviderTest(unittest.TestCase): @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] + return_values = [True, False] + self.backend.playback.play = lambda: return_values.pop() self.playback.play() self.assertNotEqual(self.playback.current_track, self.tracks[0]) self.assertEqual(self.playback.current_track, self.tracks[1]) @@ -214,7 +215,8 @@ class LocalPlaybackProviderTest(unittest.TestCase): @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] + return_values = [True, False, True] + self.backend.playback.play = lambda: return_values.pop() self.playback.play(self.tracklist.tl_tracks[2]) self.assertEqual(self.playback.current_track, self.tracks[2]) self.playback.previous() @@ -281,7 +283,8 @@ class LocalPlaybackProviderTest(unittest.TestCase): @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] + return_values = [True, False, True] + self.backend.playback.play = lambda: return_values.pop() self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) self.playback.next() @@ -455,7 +458,8 @@ class LocalPlaybackProviderTest(unittest.TestCase): @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] + return_values = [True, False, True] + self.backend.playback.play = lambda: return_values.pop() self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) self.playback.on_end_of_track() From f67e55618cbbd9c121520df5402c7d664ec4e120 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 21 Mar 2015 00:11:15 +0100 Subject: [PATCH 214/314] core: Make lookup(uris=...) return dict with all uris All uris given to lookup should be in the result even if there is no backend to handle the uri, and the lookup result thus is an empty list. As a side effect, future.get() is now called in the order of the URIs in the `uris` list, making it easier to mock out backend.library.lookup() in core layer tests. --- mopidy/core/library.py | 14 ++++++++++---- tests/core/test_library.py | 9 ++++++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index f2a8b9bd..b8018b16 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -188,20 +188,26 @@ class LibraryController(object): if none_set or both_set: raise ValueError("One of 'uri' or 'uris' must be set") + if uri is not None: + uris = [uri] + futures = {} result = {} - backends = self._get_backends_to_uris([uri] if uri else uris) + backends = self._get_backends_to_uris(uris) # TODO: lookup(uris) to backend APIs for backend, backend_uris in backends.items(): for u in backend_uris or []: futures[u] = backend.library.lookup(u) - for u, future in futures.items(): - result[u] = future.get() + for u in uris: + if u in futures: + result[u] = futures[u].get() + else: + result[u] = [] if uri: - return result.get(uri, []) + return result[uri] return result def refresh(self, uri=None): diff --git a/tests/core/test_library.py b/tests/core/test_library.py index b71e5de5..9eacd1a2 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -168,13 +168,20 @@ class CoreLibraryTest(unittest.TestCase): result = self.core.library.lookup(uris=['dummy1:a', 'dummy2:a']) self.assertEqual(result, {'dummy2:a': [5678], 'dummy1:a': [1234]}) - def test_lookup_returns_nothing_for_dummy3_track(self): + def test_lookup_uri_returns_empty_list_for_dummy3_track(self): result = self.core.library.lookup('dummy3:a') self.assertEqual(result, []) self.assertFalse(self.library1.lookup.called) self.assertFalse(self.library2.lookup.called) + def test_lookup_uris_returns_empty_list_for_dummy3_track(self): + result = self.core.library.lookup(uris=['dummy3:a']) + + self.assertEqual(result, {'dummy3:a': []}) + self.assertFalse(self.library1.lookup.called) + self.assertFalse(self.library2.lookup.called) + def test_refresh_with_uri_selects_dummy1_backend(self): self.core.library.refresh('dummy1:a') From 2bc3db0d0e95e8f5ba90f5fa39b616009427a0f6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 21 Mar 2015 00:16:22 +0100 Subject: [PATCH 215/314] core: Add uris kwarg to tracklist.core() Fixes #1060 --- docs/changelog.rst | 4 ++++ mopidy/core/tracklist.py | 27 +++++++++++++++++++++------ tests/core/test_tracklist.py | 23 +++++++++++++++++++++-- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index d63ce7b8..589178e0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -35,6 +35,10 @@ v1.0.0 (UNRELEASED) which allows for simpler lookup of multiple URIs. (Fixes: :issue:`1008`, PR: :issue:`1047`) +- Add ``uris`` argument to :method:`mopidy.core.TracklistController.add` + which allows for simpler addition of multiple URIs to the tracklist. (Fixes: + :issue:`1060`, PR: :issue:`1065`) + - **Deprecated:** The old methods on :class:`mopidy.core.PlaybackController` for volume and mute management have been deprecated. (Fixes: :issue:`962`) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 456bddf6..963dcadf 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -299,13 +299,16 @@ class TracklistController(object): return self.get_tl_tracks()[position - 1] - def add(self, tracks=None, at_position=None, uri=None): + def add(self, tracks=None, at_position=None, uri=None, uris=None): """ Add the track or list of tracks to the tracklist. If ``uri`` is given instead of ``tracks``, the URI is looked up in the library and the resulting tracks are added to the tracklist. + If ``uris`` is given instead of ``tracks``, the URIs are looked up in + the library and the resulting tracks are added to the tracklist. + If ``at_position`` is given, the tracks placed at the given position in the tracklist. If ``at_position`` is not given, the tracks are appended to the end of the tracklist. @@ -319,12 +322,24 @@ class TracklistController(object): :param uri: URI for tracks to add :type uri: string :rtype: list of :class:`mopidy.models.TlTrack` - """ - assert tracks is not None or uri is not None, \ - 'tracks or uri must be provided' - if tracks is None and uri is not None: - tracks = self.core.library.lookup(uri) + .. versionadded:: 1.0 + The ``uris`` argument. + + .. deprecated:: 1.0 + The ``tracks`` and ``uri`` arguments. Use ``uris``. + """ + assert tracks is not None or uri is not None or uris is not None, \ + 'tracks, uri or uris must be provided' + + if tracks is None: + if uri is not None: + tracks = self.core.library.lookup(uri=uri) + elif uris is not None: + tracks = [] + track_map = self.core.library.lookup(uris=uris) + for uri in uris: + tracks.extend(track_map[uri]) tl_tracks = [] diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 7b5577f9..415d1fa0 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -26,8 +26,7 @@ class TracklistTest(unittest.TestCase): def test_add_by_uri_looks_up_uri_in_library(self): track = Track(uri='dummy1:x', name='x') - self.library.lookup().get.return_value = [track] - self.library.lookup.reset_mock() + self.library.lookup.return_value.get.return_value = [track] tl_tracks = self.core.tracklist.add(uri='dummy1:x') @@ -36,6 +35,26 @@ class TracklistTest(unittest.TestCase): self.assertEqual(track, tl_tracks[0].track) self.assertEqual(tl_tracks, self.core.tracklist.tl_tracks[-1:]) + def test_add_by_uris_looks_up_uris_in_library(self): + track1 = Track(uri='dummy1:x', name='x') + track2 = Track(uri='dummy1:y1', name='y1') + track3 = Track(uri='dummy1:y2', name='y2') + self.library.lookup.return_value.get.side_effect = [ + [track1], [track2, track3]] + + tl_tracks = self.core.tracklist.add(uris=['dummy1:x', 'dummy1:y']) + + self.library.lookup.assert_has_calls([ + mock.call('dummy1:x'), + mock.call('dummy1:y'), + ]) + self.assertEqual(3, len(tl_tracks)) + self.assertEqual(track1, tl_tracks[0].track) + self.assertEqual(track2, tl_tracks[1].track) + self.assertEqual(track3, tl_tracks[2].track) + self.assertEqual( + tl_tracks, self.core.tracklist.tl_tracks[-len(tl_tracks):]) + def test_remove_removes_tl_tracks_matching_query(self): tl_tracks = self.core.tracklist.remove(name=['foo']) From 8977f714117b9ab90f8ad061223cf84171608484 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 21 Mar 2015 00:59:54 +0100 Subject: [PATCH 216/314] docs: Fix syntax errors in changelog --- docs/changelog.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 3e2d6daa..30c2f7c1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -32,12 +32,12 @@ v1.0.0 (UNRELEASED) - Add :class:`mopidy.core.MixerController` which keeps track of volume and mute. (Fixes: :issue:`962`) -- Add ``uris`` argument to :method:`mopidy.core.LibraryController.lookup` - which allows for simpler lookup of multiple URIs. (Fixes: :issue:`1008`, - PR: :issue:`1047`) +- Add ``uris`` argument to :meth:`mopidy.core.LibraryController.lookup` which + allows for simpler lookup of multiple URIs. (Fixes: :issue:`1008`, PR: + :issue:`1047`) -- Add ``uris`` argument to :method:`mopidy.core.TracklistController.add` - which allows for simpler addition of multiple URIs to the tracklist. (Fixes: +- Add ``uris`` argument to :meth:`mopidy.core.TracklistController.add` which + allows for simpler addition of multiple URIs to the tracklist. (Fixes: :issue:`1060`, PR: :issue:`1065`) - **Deprecated:** The old methods on :class:`mopidy.core.PlaybackController` for From b2f60bc338eb37580932e0a5ccd40a387036e12f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 19 Mar 2015 00:02:52 +0100 Subject: [PATCH 217/314] m3u: Extract new M3U backend from local Fixes #1054 --- docs/changelog.rst | 11 +++ docs/ext/m3u.rst | 55 ++++++++++++ docs/index.rst | 1 + mopidy/local/__init__.py | 2 +- mopidy/local/actor.py | 2 - mopidy/local/ext.conf | 1 - mopidy/local/storage.py | 8 -- mopidy/local/translator.py | 99 --------------------- mopidy/m3u/__init__.py | 30 +++++++ mopidy/m3u/actor.py | 32 +++++++ mopidy/m3u/ext.conf | 3 + mopidy/m3u/library.py | 18 ++++ mopidy/{local => m3u}/playlists.py | 28 +++--- mopidy/m3u/translator.py | 110 ++++++++++++++++++++++++ setup.py | 1 + tests/m3u/__init__.py | 5 ++ tests/{local => m3u}/test_playlists.py | 63 +++++++------- tests/{local => m3u}/test_translator.py | 2 +- 18 files changed, 312 insertions(+), 159 deletions(-) create mode 100644 docs/ext/m3u.rst create mode 100644 mopidy/m3u/__init__.py create mode 100644 mopidy/m3u/actor.py create mode 100644 mopidy/m3u/ext.conf create mode 100644 mopidy/m3u/library.py rename mopidy/{local => m3u}/playlists.py (83%) create mode 100644 mopidy/m3u/translator.py create mode 100644 tests/m3u/__init__.py rename tests/{local => m3u}/test_playlists.py (84%) rename tests/{local => m3u}/test_translator.py (99%) diff --git a/docs/changelog.rst b/docs/changelog.rst index 30c2f7c1..da6e6bd7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -126,6 +126,11 @@ v1.0.0 (UNRELEASED) - Sort local playlists by name. (Fixes: :issue:`1026`, PR: :issue:`1028`) +- Moved playlist support out to a new extension, :ref:`ext-m3u`. + +- *Deprecated:* The config value :confval:`local/playlists_dir` is no longer in + use and can be removed from your config. + **Local library API** - Implementors of :meth:`mopidy.local.Library.lookup` should now return a list @@ -139,6 +144,12 @@ v1.0.0 (UNRELEASED) - Add :meth:`mopidy.local.Library.get_images` for looking up images for local URIs. (Fixes: :issue:`1031`, PR: :issue:`1032` and :issue:`1037`) +**M3U backend** + +- Split the M3U playlist handling out of the local backend. See + :ref:`m3u-migration` for how to migrate your local playlists. (Fixes: + :issue:`1054`, PR: :issue:`1066`) + **MPD frontend** - In stored playlist names, replace "/", which are illegal, with "|" instead of diff --git a/docs/ext/m3u.rst b/docs/ext/m3u.rst new file mode 100644 index 00000000..d05f88f1 --- /dev/null +++ b/docs/ext/m3u.rst @@ -0,0 +1,55 @@ +.. _ext-m3u: + +********** +Mopidy-M3U +********** + +Mopidy-M3U is an extension for reading and writing M3U playlists stored +on disk. It is bundled with Mopidy and enabled by default. + +This backend handles URIs starting with ``m3u:``. + + +.. _m3u-migration: + +Migrating from Mopidy-Local playlists +===================================== + +Mopidy-M3U was split out of the Mopidy-Local extension in Mopidy 1.0. To +migrate your playlists from Mopidy-Local, simply move them from the +:confval:`local/playlists_dir` directory to the :confval:`m3u/playlists_dir` +directory. Assuming you have not changed the default config, run the following +commands to migrate:: + + mkdir -p ~/.local/share/mopidy/m3u/ + mv ~/.local/share/mopidy/local/playlists/* ~/.local/share/mopidy/m3u/ + + +Editing playlists +================= + +There is a core playlist API in place for editing playlists. This is supported +by a few Mopidy clients, but not through Mopidy's MPD server yet. + +It is possible to edit playlists by editing the M3U files located in the +:confval:`m3u/playlists_dir` directory, usually +:file:`~/.local/share/mopidy/m3u/`, by hand with a text editor. See `Wikipedia +`__ for a short description of the quite +simple M3U playlist format. + + +Configuration +============= + +See :ref:`config` for general help on configuring Mopidy. + +.. literalinclude:: ../../mopidy/m3u/ext.conf + :language: ini + +.. confval:: m3u/enabled + + If the M3U extension should be enabled or not. + +.. confval:: m3u/playlists_dir + + Path to directory with M3U files. diff --git a/docs/index.rst b/docs/index.rst index e91c491c..e9775030 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -94,6 +94,7 @@ Extensions :maxdepth: 2 ext/local + ext/m3u ext/stream ext/http ext/mpd diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 542d99f3..dedb8632 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -24,7 +24,7 @@ class Extension(ext.Extension): schema['library'] = config.String() schema['media_dir'] = config.Path() schema['data_dir'] = config.Path() - schema['playlists_dir'] = config.Path() + schema['playlists_dir'] = config.Deprecated() schema['tag_cache_file'] = config.Deprecated() schema['scan_timeout'] = config.Integer( minimum=1000, maximum=1000 * 60 * 60) diff --git a/mopidy/local/actor.py b/mopidy/local/actor.py index f315607a..435d19a5 100644 --- a/mopidy/local/actor.py +++ b/mopidy/local/actor.py @@ -8,7 +8,6 @@ from mopidy import backend from mopidy.local import storage from mopidy.local.library import LocalLibraryProvider from mopidy.local.playback import LocalPlaybackProvider -from mopidy.local.playlists import LocalPlaylistsProvider logger = logging.getLogger(__name__) @@ -36,5 +35,4 @@ class LocalBackend(pykka.ThreadingActor, backend.Backend): logger.warning('Local library %s not found', library_name) self.playback = LocalPlaybackProvider(audio=audio, backend=self) - self.playlists = LocalPlaylistsProvider(backend=self) self.library = LocalLibraryProvider(backend=self, library=library) diff --git a/mopidy/local/ext.conf b/mopidy/local/ext.conf index 535f4806..ebd7962f 100644 --- a/mopidy/local/ext.conf +++ b/mopidy/local/ext.conf @@ -3,7 +3,6 @@ enabled = true library = json media_dir = $XDG_MUSIC_DIR data_dir = $XDG_DATA_DIR/mopidy/local -playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists scan_timeout = 1000 scan_flush_threshold = 1000 scan_follow_symlinks = false diff --git a/mopidy/local/storage.py b/mopidy/local/storage.py index 9cdcd12e..21d278e5 100644 --- a/mopidy/local/storage.py +++ b/mopidy/local/storage.py @@ -20,11 +20,3 @@ def check_dirs_and_files(config): logger.warning( 'Could not create local data dir: %s', encoding.locale_decode(error)) - - # TODO: replace with data dir? - try: - path.get_or_create_dir(config['local']['playlists_dir']) - except EnvironmentError as error: - logger.warning( - 'Could not create local playlists dir: %s', - encoding.locale_decode(error)) diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 6800c478..92b20a7b 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -2,18 +2,12 @@ from __future__ import absolute_import, unicode_literals import logging import os -import re import urllib -import urlparse from mopidy import compat -from mopidy.models import Track -from mopidy.utils.encoding import locale_decode from mopidy.utils.path import path_to_uri, uri_to_path -M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)') - logger = logging.getLogger(__name__) @@ -28,13 +22,6 @@ def local_track_uri_to_path(uri, media_dir): return os.path.join(media_dir, file_path) -def local_playlist_uri_to_path(uri, playlists_dir): - if not uri.startswith('local:playlist:'): - raise ValueError('Invalid URI %s' % uri) - file_path = uri_to_path(uri).split(b':', 1)[1] - return os.path.join(playlists_dir, file_path) - - def path_to_local_track_uri(relpath): """Convert path relative to media_dir to local track URI.""" if isinstance(relpath, compat.text_type): @@ -47,89 +34,3 @@ def path_to_local_directory_uri(relpath): if isinstance(relpath, compat.text_type): relpath = relpath.encode('utf-8') return b'local:directory:%s' % urllib.quote(relpath) - - -def path_to_local_playlist_uri(relpath): - """Convert path relative to playlists_dir to local playlist URI.""" - if isinstance(relpath, compat.text_type): - relpath = relpath.encode('utf-8') - return b'local:playlist:%s' % urllib.quote(relpath) - - -def m3u_extinf_to_track(line): - """Convert extended M3U directive to track template.""" - m = M3U_EXTINF_RE.match(line) - if not m: - logger.warning('Invalid extended M3U directive: %s', line) - return Track() - (runtime, title) = m.groups() - if int(runtime) > 0: - return Track(name=title, length=1000 * int(runtime)) - else: - return Track(name=title) - - -def parse_m3u(file_path, media_dir): - r""" - Convert M3U file list to list of tracks - - Example M3U data:: - - # This is a comment - Alternative\Band - Song.mp3 - Classical\Other Band - New Song.mp3 - Stuff.mp3 - D:\More Music\Foo.mp3 - http://www.example.com:8000/Listen.pls - http://www.example.com/~user/Mine.mp3 - - Example extended M3U data:: - - #EXTM3U - #EXTINF:123, Sample artist - Sample title - Sample.mp3 - #EXTINF:321,Example Artist - Example title - Greatest Hits\Example.ogg - #EXTINF:-1,Radio XMP - http://mp3stream.example.com:8000/ - - - Relative paths of songs should be with respect to location of M3U. - - Paths are normally platform specific. - - Lines starting with # are ignored, except for extended M3U directives. - - Track.name and Track.length are set from extended M3U directives. - - m3u files are latin-1. - """ - # TODO: uris as bytes - tracks = [] - try: - with open(file_path) as m3u: - contents = m3u.readlines() - except IOError as error: - logger.warning('Couldn\'t open m3u: %s', locale_decode(error)) - return tracks - - if not contents: - return tracks - - extended = contents[0].decode('latin1').startswith('#EXTM3U') - - track = Track() - for line in contents: - line = line.strip().decode('latin1') - - if line.startswith('#'): - if extended and line.startswith('#EXTINF'): - track = m3u_extinf_to_track(line) - continue - - if urlparse.urlsplit(line).scheme: - tracks.append(track.copy(uri=line)) - elif os.path.normpath(line) == os.path.abspath(line): - path = path_to_uri(line) - tracks.append(track.copy(uri=path)) - else: - path = path_to_uri(os.path.join(media_dir, line)) - tracks.append(track.copy(uri=path)) - - track = Track() - return tracks diff --git a/mopidy/m3u/__init__.py b/mopidy/m3u/__init__.py new file mode 100644 index 00000000..e0fcf305 --- /dev/null +++ b/mopidy/m3u/__init__.py @@ -0,0 +1,30 @@ +from __future__ import absolute_import, unicode_literals + +import logging +import os + +import mopidy +from mopidy import config, ext + +logger = logging.getLogger(__name__) + + +class Extension(ext.Extension): + + dist_name = 'Mopidy-M3U' + ext_name = 'm3u' + 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['playlists_dir'] = config.Path() + return schema + + def setup(self, registry): + from .actor import M3UBackend + + registry.add('backend', M3UBackend) diff --git a/mopidy/m3u/actor.py b/mopidy/m3u/actor.py new file mode 100644 index 00000000..3908d938 --- /dev/null +++ b/mopidy/m3u/actor.py @@ -0,0 +1,32 @@ +from __future__ import absolute_import, unicode_literals + +import logging + +import pykka + +from mopidy import backend +from mopidy.m3u.library import M3ULibraryProvider +from mopidy.m3u.playlists import M3UPlaylistsProvider +from mopidy.utils import encoding, path + + +logger = logging.getLogger(__name__) + + +class M3UBackend(pykka.ThreadingActor, backend.Backend): + uri_schemes = ['m3u'] + + def __init__(self, config, audio): + super(M3UBackend, self).__init__() + + self._config = config + + try: + path.get_or_create_dir(config['m3u']['playlists_dir']) + except EnvironmentError as error: + logger.warning( + 'Could not create M3U playlists dir: %s', + encoding.locale_decode(error)) + + self.playlists = M3UPlaylistsProvider(backend=self) + self.library = M3ULibraryProvider(backend=self) diff --git a/mopidy/m3u/ext.conf b/mopidy/m3u/ext.conf new file mode 100644 index 00000000..0e828b1b --- /dev/null +++ b/mopidy/m3u/ext.conf @@ -0,0 +1,3 @@ +[m3u] +enabled = true +playlists_dir = $XDG_DATA_DIR/mopidy/m3u diff --git a/mopidy/m3u/library.py b/mopidy/m3u/library.py new file mode 100644 index 00000000..3b5bded1 --- /dev/null +++ b/mopidy/m3u/library.py @@ -0,0 +1,18 @@ +from __future__ import absolute_import, unicode_literals + +import logging + +from mopidy import backend + +logger = logging.getLogger(__name__) + + +class M3ULibraryProvider(backend.LibraryProvider): + """Library for looking up M3U playlists.""" + + def __init__(self, backend): + super(M3ULibraryProvider, self).__init__(backend) + + def lookup(self, uri): + # TODO Lookup tracks in M3U playlist + return [] diff --git a/mopidy/local/playlists.py b/mopidy/m3u/playlists.py similarity index 83% rename from mopidy/local/playlists.py rename to mopidy/m3u/playlists.py index f2b712c5..2dc11628 100644 --- a/mopidy/local/playlists.py +++ b/mopidy/m3u/playlists.py @@ -8,19 +8,18 @@ import os import sys from mopidy import backend +from mopidy.m3u import translator from mopidy.models import Playlist -from .translator import local_playlist_uri_to_path, path_to_local_playlist_uri -from .translator import parse_m3u logger = logging.getLogger(__name__) -class LocalPlaylistsProvider(backend.PlaylistsProvider): +class M3UPlaylistsProvider(backend.PlaylistsProvider): def __init__(self, *args, **kwargs): - super(LocalPlaylistsProvider, self).__init__(*args, **kwargs) - self._media_dir = self.backend.config['local']['media_dir'] - self._playlists_dir = self.backend.config['local']['playlists_dir'] + super(M3UPlaylistsProvider, self).__init__(*args, **kwargs) + + self._playlists_dir = self.backend._config['m3u']['playlists_dir'] self._playlists = [] self.refresh() @@ -49,7 +48,7 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): if not playlist: logger.warn('Trying to delete unknown playlist %s', uri) return - path = local_playlist_uri_to_path(uri, self._playlists_dir) + path = translator.playlist_uri_to_path(uri, self._playlists_dir) if os.path.exists(path): os.remove(path) else: @@ -70,10 +69,10 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): for path in glob.glob(os.path.join(self._playlists_dir, b'*.m3u')): relpath = os.path.basename(path) name = os.path.splitext(relpath)[0].decode(encoding) - uri = path_to_local_playlist_uri(relpath) + uri = translator.path_to_playlist_uri(relpath) tracks = [] - for track in parse_m3u(path, self._media_dir): + for track in translator.parse_m3u(path): tracks.append(track) playlist = Playlist(uri=uri, name=name, tracks=tracks) @@ -82,7 +81,7 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): self.playlists = sorted(playlists, key=operator.attrgetter('name')) logger.info( - 'Loaded %d local playlists from %s', + 'Loaded %d M3U playlists from %s', len(playlists), self._playlists_dir) def save(self, playlist): @@ -99,7 +98,7 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): playlist = self._save_m3u(playlist) if index >= 0 and uri != playlist.uri: - path = local_playlist_uri_to_path(uri, self._playlists_dir) + path = translator.playlist_uri_to_path(uri, self._playlists_dir) if os.path.exists(path): os.remove(path) else: @@ -125,11 +124,12 @@ class LocalPlaylistsProvider(backend.PlaylistsProvider): def _save_m3u(self, playlist, encoding=sys.getfilesystemencoding()): if playlist.name: name = self._sanitize_m3u_name(playlist.name, encoding) - uri = path_to_local_playlist_uri(name.encode(encoding) + b'.m3u') - path = local_playlist_uri_to_path(uri, self._playlists_dir) + uri = translator.path_to_playlist_uri( + name.encode(encoding) + b'.m3u') + path = translator.playlist_uri_to_path(uri, self._playlists_dir) elif playlist.uri: uri = playlist.uri - path = local_playlist_uri_to_path(uri, self._playlists_dir) + path = translator.playlist_uri_to_path(uri, self._playlists_dir) name, _ = os.path.splitext(os.path.basename(path).decode(encoding)) else: raise ValueError('M3U playlist needs name or URI') diff --git a/mopidy/m3u/translator.py b/mopidy/m3u/translator.py new file mode 100644 index 00000000..4eefce9d --- /dev/null +++ b/mopidy/m3u/translator.py @@ -0,0 +1,110 @@ +from __future__ import absolute_import, unicode_literals + +import logging +import os +import re +import urllib +import urlparse + +from mopidy import compat +from mopidy.models import Track +from mopidy.utils.encoding import locale_decode +from mopidy.utils.path import path_to_uri, uri_to_path + + +M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)') + +logger = logging.getLogger(__name__) + + +def playlist_uri_to_path(uri, playlists_dir): + if not uri.startswith('m3u:'): + raise ValueError('Invalid URI %s' % uri) + file_path = uri_to_path(uri) + return os.path.join(playlists_dir, file_path) + + +def path_to_playlist_uri(relpath): + """Convert path relative to playlists_dir to M3U URI.""" + if isinstance(relpath, compat.text_type): + relpath = relpath.encode('utf-8') + return b'm3u:%s' % urllib.quote(relpath) + + +def m3u_extinf_to_track(line): + """Convert extended M3U directive to track template.""" + m = M3U_EXTINF_RE.match(line) + if not m: + logger.warning('Invalid extended M3U directive: %s', line) + return Track() + (runtime, title) = m.groups() + if int(runtime) > 0: + return Track(name=title, length=1000 * int(runtime)) + else: + return Track(name=title) + + +def parse_m3u(file_path, media_dir=None): + r""" + Convert M3U file list to list of tracks + + Example M3U data:: + + # This is a comment + Alternative\Band - Song.mp3 + Classical\Other Band - New Song.mp3 + Stuff.mp3 + D:\More Music\Foo.mp3 + http://www.example.com:8000/Listen.pls + http://www.example.com/~user/Mine.mp3 + + Example extended M3U data:: + + #EXTM3U + #EXTINF:123, Sample artist - Sample title + Sample.mp3 + #EXTINF:321,Example Artist - Example title + Greatest Hits\Example.ogg + #EXTINF:-1,Radio XMP + http://mp3stream.example.com:8000/ + + - Relative paths of songs should be with respect to location of M3U. + - Paths are normally platform specific. + - Lines starting with # are ignored, except for extended M3U directives. + - Track.name and Track.length are set from extended M3U directives. + - m3u files are latin-1. + """ + # TODO: uris as bytes + tracks = [] + try: + with open(file_path) as m3u: + contents = m3u.readlines() + except IOError as error: + logger.warning('Couldn\'t open m3u: %s', locale_decode(error)) + return tracks + + if not contents: + return tracks + + extended = contents[0].decode('latin1').startswith('#EXTM3U') + + track = Track() + for line in contents: + line = line.strip().decode('latin1') + + if line.startswith('#'): + if extended and line.startswith('#EXTINF'): + track = m3u_extinf_to_track(line) + continue + + if urlparse.urlsplit(line).scheme: + tracks.append(track.copy(uri=line)) + elif os.path.normpath(line) == os.path.abspath(line): + path = path_to_uri(line) + tracks.append(track.copy(uri=path)) + elif media_dir is not None: + path = path_to_uri(os.path.join(media_dir, line)) + tracks.append(track.copy(uri=path)) + + track = Track() + return tracks diff --git a/setup.py b/setup.py index 49940c15..a6f3050e 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,7 @@ setup( 'mopidy.ext': [ 'http = mopidy.http:Extension', 'local = mopidy.local:Extension', + 'm3u = mopidy.m3u:Extension', 'mpd = mopidy.mpd:Extension', 'softwaremixer = mopidy.softwaremixer:Extension', 'stream = mopidy.stream:Extension', diff --git a/tests/m3u/__init__.py b/tests/m3u/__init__.py new file mode 100644 index 00000000..702deac5 --- /dev/null +++ b/tests/m3u/__init__.py @@ -0,0 +1,5 @@ +from __future__ import absolute_import, unicode_literals + + +def generate_song(i): + return 'dummy:track:song%s' % i diff --git a/tests/local/test_playlists.py b/tests/m3u/test_playlists.py similarity index 84% rename from tests/local/test_playlists.py rename to tests/m3u/test_playlists.py index 5af0debe..8c2187dc 100644 --- a/tests/local/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -8,30 +8,28 @@ import unittest import pykka from mopidy import core -from mopidy.local import actor -from mopidy.local.translator import local_playlist_uri_to_path +from mopidy.m3u import actor +from mopidy.m3u.translator import playlist_uri_to_path from mopidy.models import Playlist, Track from tests import dummy_audio, path_to_data_dir -from tests.local import generate_song +from tests.m3u import generate_song -class LocalPlaylistsProviderTest(unittest.TestCase): - backend_class = actor.LocalBackend +class M3UPlaylistsProviderTest(unittest.TestCase): + backend_class = actor.M3UBackend config = { - 'local': { - 'media_dir': path_to_data_dir(''), - 'data_dir': path_to_data_dir(''), - 'library': 'json', + 'm3u': { + 'playlists_dir': path_to_data_dir(''), } } def setUp(self): # noqa: N802 - self.config['local']['playlists_dir'] = tempfile.mkdtemp() - self.playlists_dir = self.config['local']['playlists_dir'] + self.config['m3u']['playlists_dir'] = tempfile.mkdtemp() + self.playlists_dir = self.config['m3u']['playlists_dir'] self.audio = dummy_audio.create_proxy() - self.backend = actor.LocalBackend.start( + self.backend = actor.M3UBackend.start( config=self.config, audio=self.audio).proxy() self.core = core.Core(backends=[self.backend]) @@ -42,8 +40,8 @@ class LocalPlaylistsProviderTest(unittest.TestCase): shutil.rmtree(self.playlists_dir) def test_created_playlist_is_persisted(self): - uri = 'local:playlist:test.m3u' - path = local_playlist_uri_to_path(uri, self.playlists_dir) + uri = 'm3u:test.m3u' + path = playlist_uri_to_path(uri, self.playlists_dir) self.assertFalse(os.path.exists(path)) playlist = self.core.playlists.create('test') @@ -54,16 +52,16 @@ class LocalPlaylistsProviderTest(unittest.TestCase): def test_create_sanitizes_playlist_name(self): playlist = self.core.playlists.create('../../test FOO baR') self.assertEqual('test FOO baR', playlist.name) - path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir) + path = playlist_uri_to_path(playlist.uri, self.playlists_dir) self.assertEqual(self.playlists_dir, os.path.dirname(path)) self.assertTrue(os.path.exists(path)) def test_saved_playlist_is_persisted(self): - uri1 = 'local:playlist:test1.m3u' - uri2 = 'local:playlist:test2.m3u' + uri1 = 'm3u:test1.m3u' + uri2 = 'm3u:test2.m3u' - path1 = local_playlist_uri_to_path(uri1, self.playlists_dir) - path2 = local_playlist_uri_to_path(uri2, self.playlists_dir) + path1 = playlist_uri_to_path(uri1, self.playlists_dir) + path2 = playlist_uri_to_path(uri2, self.playlists_dir) playlist = self.core.playlists.create('test1') self.assertEqual('test1', playlist.name) @@ -78,8 +76,8 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.assertTrue(os.path.exists(path2)) def test_deleted_playlist_is_removed(self): - uri = 'local:playlist:test.m3u' - path = local_playlist_uri_to_path(uri, self.playlists_dir) + uri = 'm3u:test.m3u' + path = playlist_uri_to_path(uri, self.playlists_dir) self.assertFalse(os.path.exists(path)) @@ -95,7 +93,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): track = Track(uri=generate_song(1)) playlist = self.core.playlists.create('test') playlist = self.core.playlists.save(playlist.copy(tracks=[track])) - path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir) + path = playlist_uri_to_path(playlist.uri, self.playlists_dir) with open(path) as f: contents = f.read() @@ -106,7 +104,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): track = Track(uri=generate_song(1), name='Test', length=60000) playlist = self.core.playlists.create('test') playlist = self.core.playlists.save(playlist.copy(tracks=[track])) - path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir) + path = playlist_uri_to_path(playlist.uri, self.playlists_dir) with open(path) as f: contents = f.read().splitlines() @@ -114,7 +112,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.assertEqual(contents, ['#EXTM3U', '#EXTINF:60,Test', track.uri]) def test_playlists_are_loaded_at_startup(self): - track = Track(uri='local:track:path2') + track = Track(uri='dummy:track:path2') playlist = self.core.playlists.create('test') playlist = playlist.copy(tracks=[track]) playlist = self.core.playlists.save(playlist) @@ -134,7 +132,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): pass @unittest.SkipTest - def test_playlist_dir_is_created(self): + def test_playlists_dir_is_created(self): pass def test_create_returns_playlist_with_name_set(self): @@ -154,7 +152,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.assert_(not self.core.playlists.playlists) def test_delete_non_existant_playlist(self): - self.core.playlists.delete('local:playlist:unknown') + self.core.playlists.delete('m3u:unknown') def test_delete_playlist_removes_it_from_the_collection(self): playlist = self.core.playlists.create('test') @@ -168,7 +166,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): playlist = self.core.playlists.create('test') self.assertIn(playlist, self.core.playlists.playlists) - path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir) + path = playlist_uri_to_path(playlist.uri, self.playlists_dir) self.assertTrue(os.path.exists(path)) os.remove(path) @@ -244,12 +242,12 @@ class LocalPlaylistsProviderTest(unittest.TestCase): def test_save_playlist_with_new_uri(self): # you *should* not do this - uri = 'local:playlist:test.m3u' + uri = 'm3u:test.m3u' playlist = self.core.playlists.save(Playlist(uri=uri)) self.assertIn(playlist, self.core.playlists.playlists) self.assertEqual(uri, playlist.uri) self.assertEqual('test', playlist.name) - path = local_playlist_uri_to_path(playlist.uri, self.playlists_dir) + path = playlist_uri_to_path(playlist.uri, self.playlists_dir) self.assertTrue(os.path.exists(path)) def test_playlist_with_unknown_track(self): @@ -261,8 +259,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): backend = self.backend_class(config=self.config, audio=self.audio) self.assert_(backend.playlists.playlists) - self.assertEqual( - 'local:playlist:test.m3u', backend.playlists.playlists[0].uri) + self.assertEqual('m3u:test.m3u', backend.playlists.playlists[0].uri) self.assertEqual( playlist.name, backend.playlists.playlists[0].name) self.assertEqual( @@ -282,12 +279,12 @@ class LocalPlaylistsProviderTest(unittest.TestCase): check_order(self.core.playlists.playlists, ['a', 'b', 'c']) - playlist = self.core.playlists.lookup('local:playlist:a.m3u') + playlist = self.core.playlists.lookup('m3u:a.m3u') playlist = playlist.copy(name='d') playlist = self.core.playlists.save(playlist) check_order(self.core.playlists.playlists, ['b', 'c', 'd']) - self.core.playlists.delete('local:playlist:c.m3u') + self.core.playlists.delete('m3u:c.m3u') check_order(self.core.playlists.playlists, ['b', 'd']) diff --git a/tests/local/test_translator.py b/tests/m3u/test_translator.py similarity index 99% rename from tests/local/test_translator.py rename to tests/m3u/test_translator.py index d3ba9e68..fc7fc958 100644 --- a/tests/local/test_translator.py +++ b/tests/m3u/test_translator.py @@ -6,7 +6,7 @@ import os import tempfile import unittest -from mopidy.local import translator +from mopidy.m3u import translator from mopidy.models import Track from mopidy.utils import path From a6ef1bb8d9ac07aa3694ff4e1ed1b3801b87a043 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 21 Mar 2015 11:43:46 +0100 Subject: [PATCH 218/314] backend: Add translate_uri for simpler API for the simple case. change_track(track) simply calls translate_uri(uri) by default now so that 90% of clients with custom URIs should be able to just implement this one method on the backend any ignore everything else. --- mopidy/backend.py | 25 ++++++++++++++++++++----- mopidy/local/playback.py | 12 +++--------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index 3852b1d4..822c484c 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -199,23 +199,38 @@ class PlaybackProvider(object): """ self.audio.prepare_change().get() + def translate_uri(self, uri): + """ + Convert custom URI scheme to real playable uri. + + This is very likely the *only* thing you need to override as a backend + author. Typically this is where you convert any mopidy specific URIs + to real URIs and then return it. + + :param uri: the URI to translate. + :type uri: string + :rtype: string + """ + return uri + def change_track(self, track): """ Swith to provided track. *MAY be reimplemented by subclass.* - This is very likely the *only* thing you need to override as a backend - author. Typically this is where you convert any mopidy specific URIs - to real URIs and then return:: + It is unlikely it makes sense for any backends to override + this. For most practical purposes it should be considered an internal + call between backends and core that backend authors should not touch. - return super(MyBackend, self).change_track(track.copy(uri=new_uri)) + The default implementation will call :method:`translate_uri` which + is what you want to implement. :param track: the track to play :type track: :class:`mopidy.models.Track` :rtype: :class:`True` if successful, else :class:`False` """ - self.audio.set_uri(track.uri).get() + self.audio.set_uri(self.translate_uri(track.uri)).get() return True def resume(self): diff --git a/mopidy/local/playback.py b/mopidy/local/playback.py index 92dc6e15..82f27fdd 100644 --- a/mopidy/local/playback.py +++ b/mopidy/local/playback.py @@ -1,16 +1,10 @@ from __future__ import absolute_import, unicode_literals -import logging - from mopidy import backend from mopidy.local import translator -logger = logging.getLogger(__name__) - - class LocalPlaybackProvider(backend.PlaybackProvider): - def change_track(self, track): - track = track.copy(uri=translator.local_track_uri_to_file_uri( - track.uri, self.backend.config['local']['media_dir'])) - return super(LocalPlaybackProvider, self).change_track(track) + def translate_uri(self, uri): + return translator.local_track_uri_to_file_uri( + uri, self.backend.config['local']['media_dir']) From 87ba52f1246b7e311f3eb25a505ecd3fd4b071a4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 21 Mar 2015 23:12:44 +0100 Subject: [PATCH 219/314] review: Docstring updates --- mopidy/backend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index 822c484c..b41b92c0 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -204,7 +204,7 @@ class PlaybackProvider(object): Convert custom URI scheme to real playable uri. This is very likely the *only* thing you need to override as a backend - author. Typically this is where you convert any mopidy specific URIs + author. Typically this is where you convert any Mopidy specific URIs to real URIs and then return it. :param uri: the URI to translate. @@ -261,7 +261,7 @@ class PlaybackProvider(object): *MAY be reimplemented by subclass.* - Should not be used for tracking if tracks have been played / when we + Should not be used for tracking if tracks have been played or when we are done playing them. :rtype: :class:`True` if successful, else :class:`False` From ebba3a3d14c822257b1d192bf66b0f8b96bd9fa2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 21 Mar 2015 23:16:05 +0100 Subject: [PATCH 220/314] backend: Allow None as return from translate_uri() --- mopidy/backend.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index b41b92c0..ffefe047 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -203,13 +203,16 @@ class PlaybackProvider(object): """ Convert custom URI scheme to real playable uri. + *MAY be reimplemented by subclass.* + This is very likely the *only* thing you need to override as a backend - author. Typically this is where you convert any Mopidy specific URIs - to real URIs and then return it. + author. Typically this is where you convert any Mopidy specific URI + to a real URI and then return it. If you can't convert the URI just + return :class:`None`. :param uri: the URI to translate. :type uri: string - :rtype: string + :rtype: string or :class:`None` if the URI could not be translated. """ return uri @@ -230,7 +233,10 @@ class PlaybackProvider(object): :type track: :class:`mopidy.models.Track` :rtype: :class:`True` if successful, else :class:`False` """ - self.audio.set_uri(self.translate_uri(track.uri)).get() + uri = self.translate_uri(track.uri) + if not uri: + return False + self.audio.set_uri(uri).get() return True def resume(self): From c620e3a00faf8815eb632e72e14b376cc72d2933 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 22 Mar 2015 01:27:07 +0100 Subject: [PATCH 221/314] docs: Add changelog for backend API breakage --- docs/changelog.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5dc90c17..3dd62478 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -71,6 +71,25 @@ v1.0.0 (UNRELEASED) :attr:`mopidy.backend.PlaylistsProvider.playlists`. This is potentially backwards incompatible. (PR: :issue:`1046`) +- Changed the API for :class:`mopidy.backend.PlaybackProvider`, note that this + change is **not** backwards compatible for certain backends. These changes + are crucial to adding gapless in one of the upcoming releases. + (Fixes: :issue:`1052`, PR: :issue:`1064`) + + - :meth:`mopidy.backend.PlaybackProvider.translate_uri` has been added. It is + strongly recommended that all backends migrate to using this API for + translating "Mopidy URIs" to real ones for playback. + + - The semantics and signature of :meth:`mopidy.backend.PlaybackProvider.play` + has changed. The method is now only used to set the playback state to + playing, and no longer takes a track. + + Backends must migrate to + :meth:`mopidy.backend.PlaybackProvider.translate_uri` or + :meth:`mopidy.backend.PlaybackProvider.change_track` to continue working. + + - :meth:`mopidy.backend.PlaybackProvider.prepare_change` has been added. + **Commands** - Make the ``mopidy`` command print a friendly error message if the From 912995559211313f0e70d059830a3d35975daef8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Mar 2015 09:21:11 +0100 Subject: [PATCH 222/314] backend: Minor docstring adjustments Did it myself rather than holding off PR #1064 any longer. --- mopidy/backend.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index ffefe047..bb90cbf4 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -201,7 +201,7 @@ class PlaybackProvider(object): def translate_uri(self, uri): """ - Convert custom URI scheme to real playable uri. + Convert custom URI scheme to real playable URI. *MAY be reimplemented by subclass.* @@ -210,9 +210,9 @@ class PlaybackProvider(object): to a real URI and then return it. If you can't convert the URI just return :class:`None`. - :param uri: the URI to translate. + :param uri: the URI to translate :type uri: string - :rtype: string or :class:`None` if the URI could not be translated. + :rtype: string or :class:`None` if the URI could not be translated """ return uri From f36110983241853eb0f257203f74085a6edccf3a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Mar 2015 09:23:07 +0100 Subject: [PATCH 223/314] setup: Explicitly close file Instead of relying on GC. --- setup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index a6f3050e..9f33236f 100644 --- a/setup.py +++ b/setup.py @@ -6,9 +6,9 @@ from setuptools import find_packages, setup def get_version(filename): - init_py = open(filename).read() - metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", init_py)) - return metadata['version'] + with open(filename) as fh: + metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", fh.read())) + return metadata['version'] setup( From 12649265b18f448d3b07b0cd537b389c07b0ad59 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Mar 2015 09:25:04 +0100 Subject: [PATCH 224/314] Bump version to 1.0.0 So that the development version of extensions can start depending on 1.0.0 and test that they work with the changed APIs. --- mopidy/__init__.py | 2 +- tests/test_version.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 60d7a428..388bb9f0 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -30,4 +30,4 @@ except ImportError: warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.19.5' +__version__ = '1.0.0' diff --git a/tests/test_version.py b/tests/test_version.py index 8c3f9404..932cc639 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -54,5 +54,6 @@ class VersionTest(unittest.TestCase): self.assertVersionLess('0.19.1', '0.19.2') self.assertVersionLess('0.19.2', '0.19.3') self.assertVersionLess('0.19.3', '0.19.4') - self.assertVersionLess('0.19.4', __version__) - self.assertVersionLess(__version__, '0.19.6') + self.assertVersionLess('0.19.4', '0.19.5') + self.assertVersionLess('0.19.5', __version__) + self.assertVersionLess(__version__, '1.0.1') From 15872ca02ae068dd63170efc44bbc46013329863 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Mar 2015 20:26:11 +0100 Subject: [PATCH 225/314] travis: Don't build the debian branch --- .travis.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.travis.yml b/.travis.yml index 8e14280f..2058fcc7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,6 +23,10 @@ script: after_success: - "if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi" +branches: + except: + - debian + notifications: irc: channels: From fe8d6aa4e837421922481442b41f138961bb14a0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Mar 2015 21:28:35 +0100 Subject: [PATCH 226/314] core: Use 'must' instead of 'should' where appropriate --- mopidy/core/playlists.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 5680c018..15d35aa9 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -45,7 +45,7 @@ class PlaylistsController(object): :class:`None` or doesn't match a current backend, the first backend is asked to create the playlist. - All new playlists should be created by calling this method, and **not** + All new playlists must be created by calling this method, and **not** by creating new instances of :class:`mopidy.models.Playlist`. :param name: name of the new playlist @@ -150,14 +150,14 @@ class PlaylistsController(object): Save the playlist. For a playlist to be saveable, it must have the ``uri`` attribute set. - You should not set the ``uri`` atribute yourself, but use playlist + You must not set the ``uri`` atribute yourself, but use playlist objects returned by :meth:`create` or retrieved from :attr:`playlists`, which will always give you saveable playlists. The method returns the saved playlist. The return playlist may differ from the saved playlist. E.g. if the playlist name was changed, the returned playlist may have a different URI. The caller of this method - should throw away the playlist sent to this method, and use the + must throw away the playlist sent to this method, and use the returned playlist instead. If the playlist's URI isn't set or doesn't match the URI scheme of a From 67d4dac8620eb51cd0e6678aebe4c9c4fd211524 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Sun, 22 Mar 2015 21:54:26 +0100 Subject: [PATCH 227/314] m3u: Store by URI internally Based upon tkem's PR #1053 --- mopidy/m3u/playlists.py | 80 ++++++++++++------------------------- tests/m3u/test_playlists.py | 21 +++------- 2 files changed, 31 insertions(+), 70 deletions(-) diff --git a/mopidy/m3u/playlists.py b/mopidy/m3u/playlists.py index 2dc11628..a753f00a 100644 --- a/mopidy/m3u/playlists.py +++ b/mopidy/m3u/playlists.py @@ -1,6 +1,5 @@ from __future__ import absolute_import, division, unicode_literals -import copy import glob import logging import operator @@ -20,65 +19,50 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): super(M3UPlaylistsProvider, self).__init__(*args, **kwargs) self._playlists_dir = self.backend._config['m3u']['playlists_dir'] - self._playlists = [] + self._playlists = {} self.refresh() @property def playlists(self): - return copy.copy(self._playlists) + return sorted( + self._playlists.values(), key=operator.attrgetter('name')) @playlists.setter def playlists(self, playlists): - self._playlists = playlists + self._playlists = {playlist.uri: playlist for playlist in playlists} def create(self, name): playlist = self._save_m3u(Playlist(name=name)) - old_playlist = self.lookup(playlist.uri) - if old_playlist is not None: - index = self._playlists.index(old_playlist) - self._playlists[index] = playlist - else: - self._playlists.append(playlist) - self._playlists.sort(key=operator.attrgetter('name')) + self._playlists[playlist.uri] = playlist logger.info('Created playlist %s', playlist.uri) return playlist def delete(self, uri): - playlist = self.lookup(uri) - if not playlist: - logger.warn('Trying to delete unknown playlist %s', uri) - return - path = translator.playlist_uri_to_path(uri, self._playlists_dir) - if os.path.exists(path): - os.remove(path) + if uri in self._playlists: + path = translator.playlist_uri_to_path(uri, self._playlists_dir) + if os.path.exists(path): + os.remove(path) + else: + logger.warn('Trying to delete missing playlist file %s', path) + del self._playlists[uri] else: - logger.warn('Trying to delete missing playlist file %s', path) - self._playlists.remove(playlist) + logger.warn('Trying to delete unknown playlist %s', uri) def lookup(self, uri): - # TODO: store as {uri: playlist} when get_playlists() gets - # implemented - for playlist in self._playlists: - if playlist.uri == uri: - return playlist + return self._playlists.get(uri) def refresh(self): - playlists = [] + playlists = {} encoding = sys.getfilesystemencoding() for path in glob.glob(os.path.join(self._playlists_dir, b'*.m3u')): relpath = os.path.basename(path) - name = os.path.splitext(relpath)[0].decode(encoding) uri = translator.path_to_playlist_uri(relpath) + name = os.path.splitext(relpath)[0].decode(encoding) + tracks = translator.parse_m3u(path) + playlists[uri] = Playlist(uri=uri, name=name, tracks=tracks) - tracks = [] - for track in translator.parse_m3u(path): - tracks.append(track) - - playlist = Playlist(uri=uri, name=name, tracks=tracks) - playlists.append(playlist) - - self.playlists = sorted(playlists, key=operator.attrgetter('name')) + self._playlists = playlists logger.info( 'Loaded %d M3U playlists from %s', @@ -86,28 +70,14 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): def save(self, playlist): assert playlist.uri, 'Cannot save playlist without URI' + assert playlist.uri in self._playlists, \ + 'Cannot save playlist with unknown URI: %s' % playlist.uri - uri = playlist.uri - # TODO: require existing (created) playlist - currently, this - # is a *should* in https://docs.mopidy.com/en/latest/api/core/ - try: - index = self._playlists.index(self.lookup(uri)) - except ValueError: - logger.warn('Saving playlist with new URI %s', uri) - index = -1 - + original_uri = playlist.uri playlist = self._save_m3u(playlist) - if index >= 0 and uri != playlist.uri: - path = translator.playlist_uri_to_path(uri, self._playlists_dir) - if os.path.exists(path): - os.remove(path) - else: - logger.warn('Trying to delete missing playlist file %s', path) - if index >= 0: - self._playlists[index] = playlist - else: - self._playlists.append(playlist) - self._playlists.sort(key=operator.attrgetter('name')) + if playlist.uri != original_uri and original_uri in self._playlists: + self.delete(original_uri) + self._playlists[playlist.uri] = playlist return playlist def _write_m3u_extinf(self, file_handle, track): diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index 8c2187dc..fd77348c 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -192,14 +192,6 @@ class M3UPlaylistsProviderTest(unittest.TestCase): 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')] @@ -241,14 +233,13 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.assertIn(playlist2, self.core.playlists.playlists) def test_save_playlist_with_new_uri(self): - # you *should* not do this uri = 'm3u:test.m3u' - playlist = self.core.playlists.save(Playlist(uri=uri)) - self.assertIn(playlist, self.core.playlists.playlists) - self.assertEqual(uri, playlist.uri) - self.assertEqual('test', playlist.name) - path = playlist_uri_to_path(playlist.uri, self.playlists_dir) - self.assertTrue(os.path.exists(path)) + + with self.assertRaises(AssertionError): + self.core.playlists.save(Playlist(uri=uri)) + + path = playlist_uri_to_path(uri, self.playlists_dir) + self.assertFalse(os.path.exists(path)) def test_playlist_with_unknown_track(self): track = Track(uri='file:///dev/null') From efe9430c7af630dd36df52677ea6834100c11af0 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 22 Mar 2015 22:12:51 +0100 Subject: [PATCH 228/314] core: Update playback code to take change track into account. This change has us checking the return value of change_track when deciding if the play call was a success or if the track is unplayable. Which ensures that the following can no longer happen: 1) play stream 2) play stream that fails change_track 3) stream 1) continues playing. Correct behavior being the next stream playing instead. --- mopidy/core/playback.py | 4 ++-- tests/core/test_playback.py | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 4f51f328..d5736808 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -312,8 +312,8 @@ class PlaybackController(object): if backend: backend.playback.prepare_change() - backend.playback.change_track(tl_track.track) - success = backend.playback.play().get() + success = (backend.playback.change_track(tl_track.track).get() and + backend.playback.play().get()) if success: self.core.tracklist._mark_playing(tl_track) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index e84e7301..5f305c4e 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -12,6 +12,7 @@ from mopidy.models import Track from tests import dummy_audio as audio +# TODO: split into smaller easier to follow tests. setup is way to complex. class CorePlaybackTest(unittest.TestCase): def setUp(self): # noqa: N802 self.backend1 = mock.Mock() @@ -113,7 +114,7 @@ class CorePlaybackTest(unittest.TestCase): self.playback2.change_track.assert_called_once_with(self.tracks[1]) self.playback2.play.assert_called_once_with() - def test_play_skips_to_next_on_unplayable_track(self): + def test_play_skips_to_next_on_track_without_playback_backend(self): self.core.playback.play(self.unplayable_tl_track) self.playback1.prepare_change.assert_called_once_with() @@ -124,6 +125,22 @@ class CorePlaybackTest(unittest.TestCase): self.assertEqual( self.core.playback.current_tl_track, self.tl_tracks[3]) + def test_play_skips_to_next_on_unplayable_track(self): + """Checks that we handle change track failing.""" + self.playback2.change_track().get.return_value = False + + self.core.tracklist.clear() + self.core.tracklist.add(self.tracks[:2]) + tl_tracks = self.core.tracklist.tl_tracks + + self.core.playback.play(tl_tracks[0]) + self.core.playback.play(tl_tracks[1]) + + # TODO: we really want to check that the track was marked unplayable + # and that next was called. This is just an indirect way of checking + # this :( + self.assertEqual(self.core.playback.state, core.PlaybackState.STOPPED) + @mock.patch( 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) def test_play_when_stopped_emits_events(self, listener_mock): From b8130f03cdf0d877451b9f4787065b0a1c8098b2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Mar 2015 22:18:25 +0100 Subject: [PATCH 229/314] Fix flake8 warning --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 71813ad7..fa75dd79 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -116,7 +116,7 @@ modindex_common_prefix = ['mopidy.'] # 'sphinx_rtd_theme' is bundled with Sphinx 1.3, which we don't have when # building the docs as part of the Debian packages on e.g. Debian wheezy. -#html_theme = 'sphinx_rtd_theme' +# html_theme = 'sphinx_rtd_theme' html_theme = 'default' html_theme_path = ['_themes'] html_static_path = ['_static'] From 08f729de76b266bb0179befb3319612bd78377b2 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Sun, 22 Mar 2015 21:30:50 +0000 Subject: [PATCH 230/314] docs: fix translate_uri method reference --- mopidy/backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index bb90cbf4..0dc656ad 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -226,7 +226,7 @@ class PlaybackProvider(object): this. For most practical purposes it should be considered an internal call between backends and core that backend authors should not touch. - The default implementation will call :method:`translate_uri` which + The default implementation will call :meth:`translate_uri` which is what you want to implement. :param track: the track to play From a3e295026ac312c37e5d456e20a478164191da36 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 22 Mar 2015 22:37:47 +0100 Subject: [PATCH 231/314] docs: Add changelog for core play behaviour change --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index eea122f9..91c83006 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -69,6 +69,10 @@ v1.0.0 (UNRELEASED) - :meth:`mopidy.core.TracklistController.mark_playing` - :meth:`mopidy.core.TracklistController.mark_unplayable` +- Updated :meth:`mopidy.core.PlaybackController.play` to take + :meth:`mopidy.backend.PlaybackProvider.change_track` into account when + determining success. (PR: :issue:`1071`) + **Backend API** - Remove default implementation of From 28f8a9909089f1f65feacb07579fb62d92824fd8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 22 Mar 2015 23:14:29 +0100 Subject: [PATCH 232/314] review: Fixed mock use and docstring --- tests/core/test_playback.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 5f305c4e..3a665d85 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -126,8 +126,8 @@ class CorePlaybackTest(unittest.TestCase): self.core.playback.current_tl_track, self.tl_tracks[3]) def test_play_skips_to_next_on_unplayable_track(self): - """Checks that we handle change track failing.""" - self.playback2.change_track().get.return_value = False + """Checks that we handle backend.change_track failing.""" + self.playback2.change_track.return_value.get.return_value = False self.core.tracklist.clear() self.core.tracklist.add(self.tracks[:2]) From 7ec23429212d819b05defb68793cf568134a9b24 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 22 Mar 2015 23:03:02 +0100 Subject: [PATCH 233/314] core: Normalize search queries This is needed as otherwise each and every backend needs to handle the fact that some "bad" clients might send {'field': 'value'} instead of {'field': ['value']} Though the real problem isn't the clients but our organically grown query API. --- docs/changelog.rst | 4 ++++ mopidy/core/library.py | 20 ++++++++++++++++++-- mopidy/local/search.py | 4 ---- tests/core/test_library.py | 10 ++++++++++ 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 91c83006..7261e3fb 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -73,6 +73,10 @@ v1.0.0 (UNRELEASED) :meth:`mopidy.backend.PlaybackProvider.change_track` into account when determining success. (PR: :issue:`1071`) +- Updated :meth:`mopidy.core.LibraryController.search` and + :meth:`mopidy.core.LibraryController.find_exact` to normalize and warn about + bad queries from clients. (Fixes: :issue:`1067`) + **Backend API** - Remove default implementation of diff --git a/mopidy/core/library.py b/mopidy/core/library.py index b8018b16..ee0c2e64 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,11 +1,14 @@ from __future__ import absolute_import, unicode_literals import collections +import logging import operator import urlparse import pykka +logger = logging.getLogger(__name__) + class LibraryController(object): pykka_traversable = True @@ -155,7 +158,7 @@ class LibraryController(object): :type uris: list of strings or :class:`None` :rtype: list of :class:`mopidy.models.SearchResult` """ - query = query or kwargs + query = _normalize_query(query or kwargs) futures = [ backend.library.find_exact(query=query, uris=backend_uris) for (backend, backend_uris) @@ -263,9 +266,22 @@ class LibraryController(object): :type uris: list of strings or :class:`None` :rtype: list of :class:`mopidy.models.SearchResult` """ - query = query or kwargs + query = _normalize_query(query or kwargs) futures = [ backend.library.search(query=query, uris=backend_uris) for (backend, backend_uris) in self._get_backends_to_uris(uris).items()] return [result for result in pykka.get_all(futures) if result] + + +def _normalize_query(query): + broken_client = False + for (field, values) in query.items(): + if isinstance(values, basestring): + broken_client = True + query[field] = [values] + if broken_client: + logger.warning( + 'Client sent a broken search query, values must be lists. Please ' + 'check which client sent this query and file a bug against them.') + return query diff --git a/mopidy/local/search.py b/mopidy/local/search.py index 9d6edea7..fdbe871c 100644 --- a/mopidy/local/search.py +++ b/mopidy/local/search.py @@ -23,8 +23,6 @@ def find_exact(tracks, query=None, limit=100, offset=0, uris=None): _validate_query(query) for (field, values) in query.items(): - if not hasattr(values, '__iter__'): - values = [values] # FIXME this is bound to be slow for large libraries for value in values: if field == 'track_no': @@ -134,8 +132,6 @@ def search(tracks, query=None, limit=100, offset=0, uris=None): _validate_query(query) for (field, values) in query.items(): - if not hasattr(values, '__iter__'): - values = [values] # FIXME this is bound to be slow for large libraries for value in values: if field == 'track_no': diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 9eacd1a2..9a23d874 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -355,3 +355,13 @@ class CoreLibraryTest(unittest.TestCase): query=dict(any=['a']), uris=None) self.library2.search.assert_called_once_with( query=dict(any=['a']), uris=None) + + def test_search_normalises_bad_queries(self): + self.core.library.search({'any': 'foobar'}) + self.library1.search.assert_called_once_with( + query={'any': ['foobar']}, uris=None) + + def test_find_exact_normalises_bad_queries(self): + self.core.library.find_exact({'any': 'foobar'}) + self.library1.find_exact.assert_called_once_with( + query={'any': ['foobar']}, uris=None) From a74bc24bdc11ee7ffcb0f2de6e7ba9e2e26a6153 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 22 Mar 2015 23:54:37 +0100 Subject: [PATCH 234/314] core: Protect against old clients that implement backend.play --- mopidy/core/playback.py | 9 +++++++-- tests/core/test_playback.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index d5736808..453a07d7 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -312,8 +312,13 @@ class PlaybackController(object): if backend: backend.playback.prepare_change() - success = (backend.playback.change_track(tl_track.track).get() and - backend.playback.play().get()) + try: + success = ( + backend.playback.change_track(tl_track.track).get() and + backend.playback.play().get()) + except TypeError: + logger.error('%s needs to be updated to work with this ' + 'version of Mopidy.', backend) if success: self.core.tracklist._mark_playing(tl_track) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 3a665d85..6f3c3274 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -650,3 +650,15 @@ class TestStream(unittest.TestCase): self.replay_audio_events() self.assertEqual(self.playback.get_stream_title(), None) + + +class CorePlaybackWithOldBackendTest(unittest.TestCase): + def test_type_error_from_old_backend_does_not_crash_core(self): + b = mock.Mock() + b.uri_schemes.get.return_value = ['dummy1'] + b.playback = mock.Mock(spec=backend.PlaybackProvider) + b.playback.play.side_effect = TypeError + + c = core.Core(mixer=None, backends=[b]) + c.tracklist.add([Track(uri='dummy1:a', length=40000)]) + c.playback.play() # No TypeError == test passed. From ca3c40b8bb1b4db97de064c2779ffc5e370abda5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Mar 2015 00:01:45 +0100 Subject: [PATCH 235/314] docs: Add PR #1073 to changelog --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 7261e3fb..5155fc79 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -75,7 +75,7 @@ v1.0.0 (UNRELEASED) - Updated :meth:`mopidy.core.LibraryController.search` and :meth:`mopidy.core.LibraryController.find_exact` to normalize and warn about - bad queries from clients. (Fixes: :issue:`1067`) + bad queries from clients. (Fixes: :issue:`1067`, PR: :issue:`1073`) **Backend API** From 55b1eb73835d67cf08a7401344440df55fcac0a5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Mar 2015 22:16:03 +0100 Subject: [PATCH 236/314] backend: Add playlists.as_list() and playlists.get_items(uri) --- mopidy/backend.py | 30 ++++++++++++++++++++++++++++++ tests/backend/test_backend.py | 19 +++++++++++++++---- tests/dummy_backend.py | 11 +++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index 0dc656ad..c1554c7f 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -318,6 +318,36 @@ class PlaylistsProvider(object): def playlists(self, playlists): raise NotImplementedError + def as_list(self): + """ + Get a list of the currently available playlists. + + Returns a list of :class:`~mopidy.models.Ref` objects referring to the + playlists. In other words, no information about the playlists' content + is given. + + :rtype: list of :class:`mopidy.models.Ref` + + .. versionadded:: 1.0 + """ + raise NotImplementedError + + def get_items(self, uri): + """ + Get the items in a playlist specified by ``uri``. + + Returns a list of :class:`~mopidy.models.Ref` objects referring to the + playlist's items. + + If a playlist with the given ``uri`` doesn't exist, it returns + :class:`None`. + + :rtype: list of :class:`mopidy.models.Ref`, or :class:`None` + + .. versionadded:: 1.0 + """ + raise NotImplementedError + def create(self, name): """ Create a new empty playlist with the given name. diff --git a/tests/backend/test_backend.py b/tests/backend/test_backend.py index c72633fb..23cfedd5 100644 --- a/tests/backend/test_backend.py +++ b/tests/backend/test_backend.py @@ -8,6 +8,7 @@ from tests import dummy_backend class LibraryTest(unittest.TestCase): + def test_default_get_images_impl_falls_back_to_album_image(self): album = models.Album(images=['imageuri']) track = models.Track(uri='trackuri', album=album) @@ -31,10 +32,20 @@ class LibraryTest(unittest.TestCase): class PlaylistsTest(unittest.TestCase): - def test_playlists_default_impl(self): - playlists = backend.PlaylistsProvider(backend=None) - self.assertEqual(playlists.playlists, []) + def setUp(self): # noqa: N802 + self.provider = backend.PlaylistsProvider(backend=None) + + def test_playlists_default_impl(self): + self.assertEqual(self.provider.playlists, []) with self.assertRaises(NotImplementedError): - playlists.playlists = [] + self.provider.playlists = [] + + def test_as_list_default_impl(self): + with self.assertRaises(NotImplementedError): + self.provider.as_list() + + def test_get_items_default_impl(self): + with self.assertRaises(NotImplementedError): + self.provider.get_items('some uri') diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index d4441673..9f4a0986 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -100,6 +100,17 @@ class DummyPlaylistsProvider(backend.PlaylistsProvider): super(DummyPlaylistsProvider, self).__init__(backend) self._playlists = [] + def as_list(self): + return [ + Ref.playlist(uri=pl.uri, name=pl.name) for pl in self._playlists] + + def get_items(self, uri): + playlist = self._playlists.get(uri) + if playlist is None: + return + return [ + Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] + @property def playlists(self): return copy.copy(self._playlists) From 4f3a0839b33221124657e8e82d81190d900fa0fc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Mar 2015 22:51:55 +0100 Subject: [PATCH 237/314] core: Add playlists.as_list() and playlists.get_items(uri) --- docs/changelog.rst | 11 +++++++ mopidy/core/playlists.py | 50 +++++++++++++++++++++++++++---- tests/core/test_playlists.py | 57 +++++++++++++++++++++++++++++------- 3 files changed, 102 insertions(+), 16 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5155fc79..0fdcaa16 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -77,6 +77,17 @@ v1.0.0 (UNRELEASED) :meth:`mopidy.core.LibraryController.find_exact` to normalize and warn about bad queries from clients. (Fixes: :issue:`1067`, PR: :issue:`1073`) +- Add :meth:`mopidy.core.PlaylistsController.as_list`. (Fixes: :issue:`1057`, + PR: :issue:`1075`) + +- Add :meth:`mopidy.core.PlaylistsController.get_items`. (Fixes: :issue:`1057`, + PR: :issue:`1075`) + +- **Deprecated:** :meth:`mopidy.core.PlaylistsController.get_playlists`. Use + :meth:`~mopidy.core.PlaylistsController.as_list` and + :meth:`~mopidy.core.PlaylistsController.get_items` instead. (Fixes: + :issue:`1057`, PR: :issue:`1075`) + **Backend API** - Remove default implementation of diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 15d35aa9..146b8058 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -16,12 +16,52 @@ class PlaylistsController(object): self.backends = backends self.core = core - """ - Get the available playlists. + def as_list(self): + """ + Get a list of the currently available playlists. + + Returns a list of :class:`~mopidy.models.Ref` objects referring to the + playlists. In other words, no information about the playlists' content + is given. + + :rtype: list of :class:`mopidy.models.Ref` + + .. versionadded:: 1.0 + """ + futures = [ + b.playlists.as_list() + for b in self.backends.with_playlists.values()] + results = pykka.get_all(futures) + return list(itertools.chain(*results)) + + def get_items(self, uri): + """ + Get the items in a playlist specified by ``uri``. + + Returns a list of :class:`~mopidy.models.Ref` objects referring to the + playlist's items. + + If a playlist with the given ``uri`` doesn't exist, it returns + :class:`None`. + + :rtype: list of :class:`mopidy.models.Ref`, or :class:`None` + + .. versionadded:: 1.0 + """ + uri_scheme = urlparse.urlparse(uri).scheme + backend = self.backends.with_playlists.get(uri_scheme, None) + if backend: + return backend.playlists.get_items(uri).get() - Returns a list of :class:`mopidy.models.Playlist`. - """ def get_playlists(self, include_tracks=True): + """ + Get the available playlists. + + :rtype: list of :class:`mopidy.models.Playlist` + + .. deprecated:: 1.0 + Use :meth:`as_list` and :meth:`get_items` instead. + """ futures = [b.playlists.playlists for b in self.backends.with_playlists.values()] results = pykka.get_all(futures) @@ -33,7 +73,7 @@ class PlaylistsController(object): playlists = deprecated_property(get_playlists) """ .. deprecated:: 1.0 - Use :meth:`get_playlists` instead. + Use :meth:`as_list` and :meth:`get_items` instead. """ def create(self, name, uri_scheme=None): diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index 55a75767..232631d7 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -5,19 +5,37 @@ import unittest import mock from mopidy import backend, core -from mopidy.models import Playlist, Track +from mopidy.models import Playlist, Ref, Track class PlaylistsTest(unittest.TestCase): def setUp(self): # noqa: N802 + self.plr1a = Ref.playlist(name='A', uri='dummy1:pl:a') + self.plr1b = Ref.playlist(name='B', uri='dummy1:pl:b') + self.plr2a = Ref.playlist(name='A', uri='dummy2:pl:a') + self.plr2b = Ref.playlist(name='B', uri='dummy2:pl:b') + + self.pl1a = Playlist(name='A', tracks=[Track(uri='dummy1:t:a')]) + self.pl1b = Playlist(name='B', tracks=[Track(uri='dummy1:t:b')]) + self.pl2a = Playlist(name='A', tracks=[Track(uri='dummy2:t:a')]) + self.pl2b = Playlist(name='B', tracks=[Track(uri='dummy2:t:b')]) + + self.sp1 = mock.Mock(spec=backend.PlaylistsProvider) + self.sp1.as_list.return_value.get.return_value = [ + self.plr1a, self.plr1b] + self.sp1.playlists.get.return_value = [self.pl1a, self.pl1b] + + self.sp2 = mock.Mock(spec=backend.PlaylistsProvider) + self.sp2.as_list.return_value.get.return_value = [ + self.plr2a, self.plr2b] + self.sp2.playlists.get.return_value = [self.pl2a, self.pl2b] + self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] - self.sp1 = mock.Mock(spec=backend.PlaylistsProvider) self.backend1.playlists = self.sp1 self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2'] - self.sp2 = mock.Mock(spec=backend.PlaylistsProvider) self.backend2.playlists = self.sp2 # A backend without the optional playlists provider @@ -26,17 +44,34 @@ class PlaylistsTest(unittest.TestCase): self.backend3.has_playlists().get.return_value = False self.backend3.playlists = None - self.pl1a = Playlist(name='A', tracks=[Track(uri='dummy1:a')]) - self.pl1b = Playlist(name='B', tracks=[Track(uri='dummy1:b')]) - self.sp1.playlists.get.return_value = [self.pl1a, self.pl1b] - - self.pl2a = Playlist(name='A', tracks=[Track(uri='dummy2:a')]) - self.pl2b = Playlist(name='B', tracks=[Track(uri='dummy2:b')]) - self.sp2.playlists.get.return_value = [self.pl2a, self.pl2b] - self.core = core.Core(mixer=None, backends=[ self.backend3, self.backend1, self.backend2]) + def test_as_list_combines_result_from_backends(self): + result = self.core.playlists.as_list() + + self.assertIn(self.plr1a, result) + self.assertIn(self.plr1b, result) + self.assertIn(self.plr2a, result) + self.assertIn(self.plr2b, result) + + def test_get_items_selects_the_matching_backend(self): + ref = Ref.track() + self.sp2.get_items.return_value.get.return_value = [ref] + + result = self.core.playlists.get_items('dummy2:pl:a') + + self.assertEqual([ref], result) + self.assertFalse(self.sp1.get_items.called) + self.sp2.get_items.assert_called_once_with('dummy2:pl:a') + + def test_get_items_with_unknown_uri_scheme_does_nothing(self): + result = self.core.playlists.get_items('unknown:a') + + self.assertIsNone(result) + self.assertFalse(self.sp1.delete.called) + self.assertFalse(self.sp2.delete.called) + def test_get_playlists_combines_result_from_backends(self): result = self.core.playlists.playlists From bd2e4f7af0cabeaa0d271aaca1cd6a8b0e7077fa Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 22 Mar 2015 23:43:57 +0100 Subject: [PATCH 238/314] core: Reimplement get_playlists() using new backend API --- mopidy/core/playlists.py | 16 +++++++++------- tests/core/test_playlists.py | 6 +++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 146b8058..715e5870 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -6,6 +6,7 @@ import urlparse import pykka from mopidy.core import listener +from mopidy.models import Playlist from mopidy.utils.deprecation import deprecated_property @@ -62,13 +63,14 @@ class PlaylistsController(object): .. deprecated:: 1.0 Use :meth:`as_list` and :meth:`get_items` instead. """ - futures = [b.playlists.playlists - for b in self.backends.with_playlists.values()] - results = pykka.get_all(futures) - playlists = list(itertools.chain(*results)) - if not include_tracks: - playlists = [p.copy(tracks=[]) for p in playlists] - return playlists + playlist_refs = self.as_list() + + if include_tracks: + playlists = [self.lookup(r.uri) for r in playlist_refs] + return [pl for pl in playlists if pl is not None] + else: + return [ + Playlist(uri=r.uri, name=r.name) for r in playlist_refs] playlists = deprecated_property(get_playlists) """ diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index 232631d7..fecbbdcb 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -23,12 +23,12 @@ class PlaylistsTest(unittest.TestCase): self.sp1 = mock.Mock(spec=backend.PlaylistsProvider) self.sp1.as_list.return_value.get.return_value = [ self.plr1a, self.plr1b] - self.sp1.playlists.get.return_value = [self.pl1a, self.pl1b] + self.sp1.lookup.return_value.get.side_effect = [self.pl1a, self.pl1b] self.sp2 = mock.Mock(spec=backend.PlaylistsProvider) self.sp2.as_list.return_value.get.return_value = [ self.plr2a, self.plr2b] - self.sp2.playlists.get.return_value = [self.pl2a, self.pl2b] + self.sp2.lookup.return_value.get.side_effect = [self.pl2a, self.pl2b] self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] @@ -73,7 +73,7 @@ class PlaylistsTest(unittest.TestCase): self.assertFalse(self.sp2.delete.called) def test_get_playlists_combines_result_from_backends(self): - result = self.core.playlists.playlists + result = self.core.playlists.get_playlists() self.assertIn(self.pl1a, result) self.assertIn(self.pl1b, result) From 5693b454eefbcb7cf44c71de8ccae1072756c7c4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Mar 2015 00:15:34 +0100 Subject: [PATCH 239/314] m3u: Use lookup() instead of playlists prop in tests --- tests/m3u/test_playlists.py | 44 ++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index fd77348c..443cfb9e 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -120,12 +120,10 @@ class M3UPlaylistsProviderTest(unittest.TestCase): backend = self.backend_class(config=self.config, audio=self.audio) self.assert_(backend.playlists.playlists) - self.assertEqual( - playlist.uri, 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) + result = backend.playlists.lookup(playlist.uri) + self.assertEqual(playlist.uri, result.uri) + self.assertEqual(playlist.name, result.name) + self.assertEqual(track.uri, result.tracks[0].uri) @unittest.SkipTest def test_santitising_of_playlist_filenames(self): @@ -156,15 +154,15 @@ class M3UPlaylistsProviderTest(unittest.TestCase): def test_delete_playlist_removes_it_from_the_collection(self): playlist = self.core.playlists.create('test') - self.assertIn(playlist, self.core.playlists.playlists) + self.assertEqual(playlist, self.core.playlists.lookup(playlist.uri)) self.core.playlists.delete(playlist.uri) - self.assertNotIn(playlist, self.core.playlists.playlists) + self.assertIsNone(self.core.playlists.lookup(playlist.uri)) def test_delete_playlist_without_file(self): playlist = self.core.playlists.create('test') - self.assertIn(playlist, self.core.playlists.playlists) + self.assertEqual(playlist, self.core.playlists.lookup(playlist.uri)) path = playlist_uri_to_path(playlist.uri, self.playlists_dir) self.assertTrue(os.path.exists(path)) @@ -173,11 +171,11 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.assertFalse(os.path.exists(path)) self.core.playlists.delete(playlist.uri) - self.assertNotIn(playlist, self.core.playlists.playlists) + self.assertIsNone(self.core.playlists.lookup(playlist.uri)) def test_filter_without_criteria(self): self.assertEqual( - self.core.playlists.playlists, self.core.playlists.filter()) + self.core.playlists.get_playlists(), self.core.playlists.filter()) def test_filter_with_wrong_criteria(self): self.assertEqual([], self.core.playlists.filter(name='foo')) @@ -188,13 +186,14 @@ class M3UPlaylistsProviderTest(unittest.TestCase): 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] + playlist = Playlist(uri='m3u:b', name='b') + self.backend.playlists.playlists = [ + Playlist(uri='m3u:a', name='a'), playlist] self.assertEqual([playlist], self.core.playlists.filter(name='b')) def test_filter_by_name_returns_no_matches(self): self.backend.playlists.playlists = [ - Playlist(name='a'), Playlist(name='b')] + Playlist(uri='m3u:a', name='a'), Playlist(uri='m3u:b', name='b')] self.assertEqual([], self.core.playlists.filter(name='c')) def test_lookup_finds_playlist_by_uri(self): @@ -206,31 +205,32 @@ class M3UPlaylistsProviderTest(unittest.TestCase): def test_refresh(self): playlist = self.core.playlists.create('test') - self.assertIn(playlist, self.core.playlists.playlists) + self.assertEqual(playlist, self.core.playlists.lookup(playlist.uri)) self.core.playlists.refresh() - self.assertIn(playlist, self.core.playlists.playlists) + self.assertEqual(playlist, self.core.playlists.lookup(playlist.uri)) def test_save_replaces_existing_playlist_with_updated_playlist(self): playlist1 = self.core.playlists.create('test1') - self.assertIn(playlist1, self.core.playlists.playlists) + self.assertEqual(playlist1, self.core.playlists.lookup(playlist1.uri)) 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) + self.assertIsNone(self.core.playlists.lookup(playlist1.uri)) + self.assertEqual(playlist2, self.core.playlists.lookup(playlist2.uri)) def test_create_replaces_existing_playlist_with_updated_playlist(self): track = Track(uri=generate_song(1)) playlist1 = self.core.playlists.create('test') playlist1 = self.core.playlists.save(playlist1.copy(tracks=[track])) - self.assertIn(playlist1, self.core.playlists.playlists) + self.assertEqual(playlist1, self.core.playlists.lookup(playlist1.uri)) playlist2 = self.core.playlists.create('test') self.assertEqual(playlist1.uri, playlist2.uri) - self.assertNotIn(playlist1, self.core.playlists.playlists) - self.assertIn(playlist2, self.core.playlists.playlists) + self.assertNotEqual( + playlist1, self.core.playlists.lookup(playlist1.uri)) + self.assertEqual(playlist2, self.core.playlists.lookup(playlist1.uri)) def test_save_playlist_with_new_uri(self): uri = 'm3u:test.m3u' From 4bae9c874c44dedaae64f26dbe19ec165636bab8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Mar 2015 00:16:13 +0100 Subject: [PATCH 240/314] m3u: Add playlists.as_list() --- mopidy/m3u/playlists.py | 8 +++++++- tests/m3u/test_playlists.py | 23 +++++++++++------------ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/mopidy/m3u/playlists.py b/mopidy/m3u/playlists.py index a753f00a..d9eb341e 100644 --- a/mopidy/m3u/playlists.py +++ b/mopidy/m3u/playlists.py @@ -8,7 +8,7 @@ import sys from mopidy import backend from mopidy.m3u import translator -from mopidy.models import Playlist +from mopidy.models import Playlist, Ref logger = logging.getLogger(__name__) @@ -22,6 +22,12 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): self._playlists = {} self.refresh() + def as_list(self): + refs = [ + Ref.playlist(uri=pl.uri, name=pl.name) + for pl in self._playlists.values()] + return sorted(refs, key=operator.attrgetter('name')) + @property def playlists(self): return sorted( diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index 443cfb9e..83dec321 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -146,8 +146,8 @@ class M3UPlaylistsProviderTest(unittest.TestCase): 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_as_list_empty_to_start_with(self): + self.assertEqual(len(self.core.playlists.as_list()), 0) def test_delete_non_existant_playlist(self): self.core.playlists.delete('m3u:unknown') @@ -249,12 +249,11 @@ class M3UPlaylistsProviderTest(unittest.TestCase): backend = self.backend_class(config=self.config, audio=self.audio) - self.assert_(backend.playlists.playlists) - self.assertEqual('m3u:test.m3u', 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) + self.assertEqual(len(backend.playlists.as_list()), 1) + result = backend.playlists.lookup('m3u:test.m3u') + self.assertEqual('m3u:test.m3u', result.uri) + self.assertEqual(playlist.name, result.name) + self.assertEqual(track.uri, result.tracks[0].uri) def test_playlist_sort_order(self): def check_order(playlists, names): @@ -264,18 +263,18 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.core.playlists.create('a') self.core.playlists.create('b') - check_order(self.core.playlists.playlists, ['a', 'b', 'c']) + check_order(self.core.playlists.as_list(), ['a', 'b', 'c']) self.core.playlists.refresh() - check_order(self.core.playlists.playlists, ['a', 'b', 'c']) + check_order(self.core.playlists.as_list(), ['a', 'b', 'c']) playlist = self.core.playlists.lookup('m3u:a.m3u') playlist = playlist.copy(name='d') playlist = self.core.playlists.save(playlist) - check_order(self.core.playlists.playlists, ['b', 'c', 'd']) + check_order(self.core.playlists.as_list(), ['b', 'c', 'd']) self.core.playlists.delete('m3u:c.m3u') - check_order(self.core.playlists.playlists, ['b', 'd']) + check_order(self.core.playlists.as_list(), ['b', 'd']) From e3f2e368c7ebca2cc05d324b3bc360075ffe2b29 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Mar 2015 00:28:49 +0100 Subject: [PATCH 241/314] m3u: Add playlists.get_items() --- mopidy/m3u/playlists.py | 6 ++++++ tests/m3u/test_playlists.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/mopidy/m3u/playlists.py b/mopidy/m3u/playlists.py index d9eb341e..d5f2b1e9 100644 --- a/mopidy/m3u/playlists.py +++ b/mopidy/m3u/playlists.py @@ -28,6 +28,12 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): for pl in self._playlists.values()] return sorted(refs, key=operator.attrgetter('name')) + def get_items(self, uri): + playlist = self._playlists.get(uri) + if playlist is None: + return None + return [Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] + @property def playlists(self): return sorted( diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index 83dec321..be94ed2f 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -278,3 +278,20 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.core.playlists.delete('m3u:c.m3u') check_order(self.core.playlists.as_list(), ['b', 'd']) + + def test_get_items_returns_item_refs(self): + track = Track(uri='dummy:a', name='A', length=60000) + playlist = self.core.playlists.create('test') + playlist = self.core.playlists.save(playlist.copy(tracks=[track])) + + item_refs = self.core.playlists.get_items(playlist.uri) + + self.assertEqual(len(item_refs), 1) + self.assertEqual(item_refs[0].type, 'track') + self.assertEqual(item_refs[0].uri, 'dummy:a') + self.assertEqual(item_refs[0].name, 'A') + + def test_get_items_of_unknown_playlist_returns_none(self): + item_refs = self.core.playlists.get_items('dummy:unknown') + + self.assertIsNone(item_refs) From d37bd62bb1a921360370eb6bb9b2a2b475fc0b55 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Mar 2015 00:32:19 +0100 Subject: [PATCH 242/314] backend: Remove playlists.playlists property --- docs/changelog.rst | 12 +++++++++++- mopidy/backend.py | 18 ------------------ tests/backend/test_backend.py | 6 ------ 3 files changed, 11 insertions(+), 25 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0fdcaa16..5d68143d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -94,7 +94,7 @@ v1.0.0 (UNRELEASED) :attr:`mopidy.backend.PlaylistsProvider.playlists`. This is potentially backwards incompatible. (PR: :issue:`1046`) -- Changed the API for :class:`mopidy.backend.PlaybackProvider`, note that this +- Changed the API for :class:`mopidy.backend.PlaybackProvider`. Note that this change is **not** backwards compatible for certain backends. These changes are crucial to adding gapless in one of the upcoming releases. (Fixes: :issue:`1052`, PR: :issue:`1064`) @@ -113,6 +113,16 @@ v1.0.0 (UNRELEASED) - :meth:`mopidy.backend.PlaybackProvider.prepare_change` has been added. +- Changed the API for :class:`mopidy.backend.PlaylistsProvider`. Note that this + change is **not** backwards compatible. These changes are important to reduce + the Mopidy startup time. (Fixes: :issue:`1057`, PR: :issue:`1075`) + + - Add :meth:`mopidy.backend.PlaylistsProvider.as_list`. + + - Add :meth:`mopidy.backend.PlaylistsProvider.get_items`. + + - Remove :attr:`mopidy.backend.PlaylistsProvider.playlists` property. + **Commands** - Make the ``mopidy`` command print a friendly error message if the diff --git a/mopidy/backend.py b/mopidy/backend.py index c1554c7f..02a624d9 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -300,24 +300,6 @@ class PlaylistsProvider(object): def __init__(self, backend): self.backend = backend - # TODO Replace playlists property with a get_playlists() method which - # returns playlist Ref's instead of the gigantic data structures we - # currently make available. lookup() should be used for getting full - # playlists with all details. - - @property - def playlists(self): - """ - Currently available playlists. - - Read/write. List of :class:`mopidy.models.Playlist`. - """ - return [] - - @playlists.setter # noqa - def playlists(self, playlists): - raise NotImplementedError - def as_list(self): """ Get a list of the currently available playlists. diff --git a/tests/backend/test_backend.py b/tests/backend/test_backend.py index 23cfedd5..e6aac76f 100644 --- a/tests/backend/test_backend.py +++ b/tests/backend/test_backend.py @@ -36,12 +36,6 @@ class PlaylistsTest(unittest.TestCase): def setUp(self): # noqa: N802 self.provider = backend.PlaylistsProvider(backend=None) - def test_playlists_default_impl(self): - self.assertEqual(self.provider.playlists, []) - - with self.assertRaises(NotImplementedError): - self.provider.playlists = [] - def test_as_list_default_impl(self): with self.assertRaises(NotImplementedError): self.provider.as_list() From df604bb3e54edb5de5a770775cc03b032e09801e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Mar 2015 00:49:56 +0100 Subject: [PATCH 243/314] core: Deprecated playlists.filter() --- docs/changelog.rst | 3 +++ mopidy/core/playlists.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5d68143d..4a8464c6 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -88,6 +88,9 @@ v1.0.0 (UNRELEASED) :meth:`~mopidy.core.PlaylistsController.get_items` instead. (Fixes: :issue:`1057`, PR: :issue:`1075`) +- **Deprecated:** :meth:`mopidy.core.PlaylistsController.filter`. Use + :meth:`~mopidy.core.PlaylistsController.as_list` and filter yourself. + **Backend API** - Remove default implementation of diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 715e5870..0262deaa 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -141,6 +141,9 @@ class PlaylistsController(object): :param criteria: one or more criteria to match by :type criteria: dict :rtype: list of :class:`mopidy.models.Playlist` + + .. deprecated:: 1.0 + Use :meth:`as_list` and filter yourself. """ criteria = criteria or kwargs matches = self.playlists From ca02dbb676989023dddafc2383b2a8bf92234b85 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Mar 2015 00:11:59 +0100 Subject: [PATCH 244/314] core: Make change_track internal as it going away in 1.x --- mopidy/core/playback.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 453a07d7..96c5e7da 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -183,7 +183,7 @@ class PlaybackController(object): # Methods # TODO: remove this. - def change_track(self, tl_track, on_error_step=1): + def _change_track(self, tl_track, on_error_step=1): """ Change to the given track, keeping the current playback state. @@ -215,7 +215,7 @@ class PlaybackController(object): next_tl_track = self.core.tracklist.eot_track(original_tl_track) if next_tl_track: - self.change_track(next_tl_track) + self._change_track(next_tl_track) else: self.stop() self.set_current_tl_track(None) @@ -250,7 +250,7 @@ class PlaybackController(object): # TODO: switch to: # backend.play(track) # wait for state change? - self.change_track(next_tl_track) + self._change_track(next_tl_track) else: self.stop() self.set_current_tl_track(None) @@ -344,7 +344,7 @@ class PlaybackController(object): # TODO: switch to: # self.play(....) # wait for state change? - self.change_track( + self._change_track( self.core.tracklist.previous_track(tl_track), on_error_step=-1) def resume(self): From fd04cd918fd5cd6c17ae1cde9d0f77e672117c9d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Mar 2015 00:15:56 +0100 Subject: [PATCH 245/314] core: Remove on_error_step from play arguments --- mopidy/core/playback.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 96c5e7da..aeb5edbf 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -197,7 +197,7 @@ class PlaybackController(object): self.stop() self.set_current_tl_track(tl_track) if old_state == PlaybackState.PLAYING: - self.play(on_error_step=on_error_step) + self._play(on_error_step=on_error_step) elif old_state == PlaybackState.PAUSED: self.pause() @@ -267,20 +267,17 @@ class PlaybackController(object): self.set_state(PlaybackState.PAUSED) self._trigger_track_playback_paused() - def play(self, tl_track=None, on_error_step=1): + def play(self, tl_track=None): """ Play the given track, or if the given track is :class:`None`, play the currently active track. :param tl_track: track to play :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` - :param on_error_step: direction to step at play error, 1 for next - track (default), -1 for previous track. **INTERNAL** - :type on_error_step: int, -1 or 1 """ + self._play(tl_track, 1) - assert on_error_step in (-1, 1) - + def _play(self, tl_track=None, on_error_step=1): if tl_track is None: if self.get_state() == PlaybackState.PAUSED: return self.resume() From 07f0453c6ee1d8865b366f500f91143ae6b86c08 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Mar 2015 00:37:50 +0100 Subject: [PATCH 246/314] core: Make event triggers internal --- mopidy/core/actor.py | 4 +-- mopidy/core/playback.py | 6 ++-- mopidy/core/tracklist.py | 2 +- tests/core/test_playback.py | 9 ++++-- tests/local/test_playback.py | 53 +++++++++++++++++++----------------- 5 files changed, 40 insertions(+), 34 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 32070684..b21e9e20 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -84,10 +84,10 @@ class Core( """ def reached_end_of_stream(self): - self.playback.on_end_of_track() + self.playback._on_end_of_track() def stream_changed(self, uri): - self.playback.on_stream_changed(uri) + self.playback._on_stream_changed(uri) def state_changed(self, old_state, new_state, target_state): # XXX: This is a temporary fix for issue #232 while we wait for a more diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index aeb5edbf..e97d5c5e 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -202,7 +202,7 @@ class PlaybackController(object): self.pause() # TODO: this is not really end of track, this is on_need_next_track - def on_end_of_track(self): + def _on_end_of_track(self): """ Tell the playback controller that end of track is reached. @@ -222,7 +222,7 @@ class PlaybackController(object): self.core.tracklist._mark_played(original_tl_track) - def on_tracklist_change(self): + def _on_tracklist_change(self): """ Tell the playback controller that the current playlist has changed. @@ -233,7 +233,7 @@ class PlaybackController(object): self.stop() self.set_current_tl_track(None) - def on_stream_changed(self, uri): + def _on_stream_changed(self, uri): self._stream_title = None def next(self): diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 963dcadf..9186ae42 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -67,7 +67,7 @@ class TracklistController(object): def _increase_version(self): self._version += 1 - self.core.playback.on_tracklist_change() + self.core.playback._on_tracklist_change() self._trigger_tracklist_changed() version = deprecated_property(get_version) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 6f3c3274..972b6dea 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -50,6 +50,9 @@ class CorePlaybackTest(unittest.TestCase): self.unplayable_tl_track = self.tl_tracks[2] self.duration_less_tl_track = self.tl_tracks[4] + def trigger_end_of_track(self): + self.core.playback._on_end_of_track() + def test_get_current_tl_track_none(self): self.core.playback.set_current_tl_track(None) @@ -419,7 +422,7 @@ class CorePlaybackTest(unittest.TestCase): tl_track = self.tl_tracks[0] self.core.playback.play(tl_track) - self.core.playback.on_end_of_track() + self.trigger_end_of_track() self.assertIn(tl_track, self.core.tracklist.tl_tracks) @@ -428,7 +431,7 @@ class CorePlaybackTest(unittest.TestCase): self.core.playback.play(tl_track) self.core.tracklist.consume = True - self.core.playback.on_end_of_track() + self.trigger_end_of_track() self.assertNotIn(tl_track, self.core.tracklist.tl_tracks) @@ -438,7 +441,7 @@ class CorePlaybackTest(unittest.TestCase): self.core.playback.play(self.tl_tracks[0]) listener_mock.reset_mock() - self.core.playback.on_end_of_track() + self.trigger_end_of_track() self.assertListEqual( listener_mock.send.mock_calls, diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index 4c4ded24..6ea82f2d 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -39,6 +39,9 @@ class LocalPlaybackProviderTest(unittest.TestCase): track = Track(uri=uri, length=4464) self.tracklist.add([track]) + def trigger_end_of_track(self): + self.playback._on_end_of_track() + def setUp(self): # noqa: N802 self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( @@ -163,7 +166,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): @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.trigger_end_of_track() self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) @@ -406,7 +409,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): old_position = self.tracklist.index(tl_track) old_uri = tl_track.track.uri - self.playback.on_end_of_track() + self.trigger_end_of_track() tl_track = self.playback.current_tl_track self.assertEqual( @@ -416,11 +419,11 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_end_of_track_return_value(self): self.playback.play() - self.assertEqual(self.playback.on_end_of_track(), None) + self.assertEqual(self.trigger_end_of_track(), None) @populate_tracklist def test_end_of_track_does_not_trigger_playback(self): - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_tracklist @@ -433,7 +436,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): tl_track = self.playback.current_tl_track self.assertEqual(self.tracklist.index(tl_track), i) - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.state, PlaybackState.STOPPED) @@ -442,7 +445,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.playback.play() for _ in self.tracks: - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.current_track, None) self.assertEqual(self.playback.state, PlaybackState.STOPPED) @@ -452,7 +455,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): 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.trigger_end_of_track() self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_tracklist @@ -462,7 +465,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.backend.playback.play = lambda: return_values.pop() self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertNotEqual(self.playback.current_track, self.tracks[1]) self.assertEqual(self.playback.current_track, self.tracks[2]) @@ -482,7 +485,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_end_of_track_track_after_previous(self): self.playback.play() - self.playback.on_end_of_track() + self.trigger_end_of_track() self.playback.previous() tl_track = self.playback.current_tl_track self.assertEqual( @@ -496,7 +499,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): 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.trigger_end_of_track() tl_track = self.playback.current_tl_track self.assertEqual(self.tracklist.next_track(tl_track), None) @@ -505,7 +508,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.repeat = True self.playback.play() for _ in self.tracks[1:]: - self.playback.on_end_of_track() + self.trigger_end_of_track() tl_track = self.playback.current_tl_track self.assertEqual( self.tracklist.next_track(tl_track), self.tl_tracks[0]) @@ -524,7 +527,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): def test_end_of_track_with_consume(self): self.tracklist.consume = True self.playback.play() - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertNotIn(self.tracks[0], self.tracklist.tracks) @populate_tracklist @@ -535,7 +538,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.random = True self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[-1]) - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.current_track, self.tracks[-2]) @populate_tracklist @@ -654,19 +657,19 @@ class LocalPlaybackProviderTest(unittest.TestCase): @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.trigger_end_of_track() tl_track = self.playback.current_tl_track self.assertEqual(self.tracklist.index(tl_track), None) def test_on_tracklist_change_gets_called(self): - callback = self.playback.on_tracklist_change + callback = self.playback._on_tracklist_change def wrapper(): wrapper.called = True return callback() wrapper.called = False - self.playback.on_tracklist_change = wrapper + self.playback._on_tracklist_change = wrapper self.tracklist.add([Track()]) self.assert_(wrapper.called) @@ -917,7 +920,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.consume = True self.playback.play() for _ in range(len(self.tracklist.tracks)): - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(len(self.tracklist.tracks), 0) @populate_tracklist @@ -944,7 +947,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_end_of_song_starts_next_track(self): self.playback.play() - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.current_track, self.tracks[1]) @populate_tracklist @@ -953,7 +956,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.repeat = True self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.current_track, self.tracks[0]) @populate_tracklist @@ -963,7 +966,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.random = True self.playback.play() current_track = self.playback.current_track - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.current_track, current_track) @populate_tracklist @@ -971,7 +974,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.single = True self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.current_track, None) self.assertEqual(self.playback.state, PlaybackState.STOPPED) @@ -980,14 +983,14 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.single = True self.tracklist.random = True self.playback.play() - self.playback.on_end_of_track() + self.trigger_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]) - self.playback.on_end_of_track() + self.trigger_end_of_track() self.assertEqual(self.playback.state, PlaybackState.STOPPED) def test_repeat_off_by_default(self): @@ -1013,7 +1016,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.random = True self.playback.play() for _ in self.tracks[1:]: - self.playback.on_end_of_track() + self.trigger_end_of_track() tl_track = self.playback.current_tl_track self.assertEqual(self.tracklist.eot_track(tl_track), None) @@ -1034,7 +1037,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.random = True self.playback.play() for _ in self.tracks: - self.playback.on_end_of_track() + self.trigger_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) From 6d22c4fd5970430f96e937432862b0cf23d18004 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Mar 2015 00:48:48 +0100 Subject: [PATCH 247/314] core: Remove set_current_tl_track --- mopidy/core/playback.py | 15 +++++++-------- tests/core/test_playback.py | 17 ++++++++++------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index e97d5c5e..8d9b7777 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -41,15 +41,14 @@ class PlaybackController(object): """ return self._current_tl_track - def set_current_tl_track(self, value): + def _set_current_tl_track(self, value): """Set the currently playing or selected track. *Internal:* This is only for use by Mopidy's test suite. """ self._current_tl_track = value - current_tl_track = deprecated_property( - get_current_tl_track, set_current_tl_track) + current_tl_track = deprecated_property(get_current_tl_track) """ .. deprecated:: 1.0 Use :meth:`get_current_tl_track` instead. @@ -195,7 +194,7 @@ class PlaybackController(object): """ old_state = self.get_state() self.stop() - self.set_current_tl_track(tl_track) + self._set_current_tl_track(tl_track) if old_state == PlaybackState.PLAYING: self._play(on_error_step=on_error_step) elif old_state == PlaybackState.PAUSED: @@ -218,7 +217,7 @@ class PlaybackController(object): self._change_track(next_tl_track) else: self.stop() - self.set_current_tl_track(None) + self._set_current_tl_track(None) self.core.tracklist._mark_played(original_tl_track) @@ -231,7 +230,7 @@ class PlaybackController(object): tracklist = self.core.tracklist.get_tl_tracks() if self.get_current_tl_track() not in tracklist: self.stop() - self.set_current_tl_track(None) + self._set_current_tl_track(None) def _on_stream_changed(self, uri): self._stream_title = None @@ -253,7 +252,7 @@ class PlaybackController(object): self._change_track(next_tl_track) else: self.stop() - self.set_current_tl_track(None) + self._set_current_tl_track(None) self.core.tracklist._mark_played(original_tl_track) @@ -302,7 +301,7 @@ class PlaybackController(object): if self.get_state() == PlaybackState.PLAYING: self.stop() - self.set_current_tl_track(tl_track) + self._set_current_tl_track(tl_track) self.set_state(PlaybackState.PLAYING) backend = self._get_backend() success = False diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 972b6dea..7c4db0d6 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -53,8 +53,11 @@ class CorePlaybackTest(unittest.TestCase): def trigger_end_of_track(self): self.core.playback._on_end_of_track() + def set_current_tl_track(self, tl_track): + self.core.playback._set_current_tl_track(tl_track) + def test_get_current_tl_track_none(self): - self.core.playback.set_current_tl_track(None) + self.set_current_tl_track(None) self.assertEqual( self.core.playback.get_current_tl_track(), None) @@ -217,7 +220,7 @@ class CorePlaybackTest(unittest.TestCase): self.playback2.pause.assert_called_once_with() def test_pause_changes_state_even_if_track_is_unplayable(self): - self.core.playback.current_tl_track = self.unplayable_tl_track + self.set_current_tl_track(self.unplayable_tl_track) self.core.playback.pause() self.assertEqual(self.core.playback.state, core.PlaybackState.PAUSED) @@ -260,7 +263,7 @@ class CorePlaybackTest(unittest.TestCase): self.playback2.resume.assert_called_once_with() def test_resume_does_nothing_if_track_is_unplayable(self): - self.core.playback.current_tl_track = self.unplayable_tl_track + self.set_current_tl_track(self.unplayable_tl_track) self.core.playback.state = core.PlaybackState.PAUSED self.core.playback.resume() @@ -303,7 +306,7 @@ class CorePlaybackTest(unittest.TestCase): self.playback2.stop.assert_called_once_with() def test_stop_changes_state_even_if_track_is_unplayable(self): - self.core.playback.current_tl_track = self.unplayable_tl_track + self.set_current_tl_track(self.unplayable_tl_track) self.core.playback.state = core.PlaybackState.PAUSED self.core.playback.stop() @@ -498,7 +501,7 @@ class CorePlaybackTest(unittest.TestCase): self.playback2.seek.assert_called_once_with(10000) def test_seek_fails_for_unplayable_track(self): - self.core.playback.current_tl_track = self.unplayable_tl_track + self.set_current_tl_track(self.unplayable_tl_track) self.core.playback.state = core.PlaybackState.PLAYING success = self.core.playback.seek(1000) @@ -507,7 +510,7 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback2.seek.called) def test_seek_fails_for_track_without_duration(self): - self.core.playback.current_tl_track = self.duration_less_tl_track + self.set_current_tl_track(self.duration_less_tl_track) self.core.playback.state = core.PlaybackState.PLAYING success = self.core.playback.seek(1000) @@ -557,7 +560,7 @@ class CorePlaybackTest(unittest.TestCase): self.playback2.get_time_position.assert_called_once_with() def test_time_position_returns_0_if_track_is_unplayable(self): - self.core.playback.current_tl_track = self.unplayable_tl_track + self.set_current_tl_track(self.unplayable_tl_track) result = self.core.playback.time_position From 6815868e241400e9ae2144fa434ae7b3a507de1e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Mar 2015 13:22:50 +0100 Subject: [PATCH 248/314] core: Doc Playlist.last_modified not being set ...if get_playlists() is called with include_tracks=False --- mopidy/core/playlists.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 0262deaa..54797abe 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -60,6 +60,11 @@ class PlaylistsController(object): :rtype: list of :class:`mopidy.models.Playlist` + .. versionchanged:: 1.0 + If you call the method with ``include_tracks=False``, the + :attr:`~mopidy.models.Playlist.last_modified` field of the returned + playlists is no longer set. + .. deprecated:: 1.0 Use :meth:`as_list` and :meth:`get_items` instead. """ From dbe4165a0f3663bfe9b77faa6f47edff5c1563df Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Mar 2015 13:31:25 +0100 Subject: [PATCH 249/314] m3u: Only test through core actor --- tests/m3u/test_playlists.py | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index be94ed2f..07ffc0a3 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -28,10 +28,10 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.config['m3u']['playlists_dir'] = tempfile.mkdtemp() self.playlists_dir = self.config['m3u']['playlists_dir'] - self.audio = dummy_audio.create_proxy() - self.backend = actor.M3UBackend.start( - config=self.config, audio=self.audio).proxy() - self.core = core.Core(backends=[self.backend]) + audio = dummy_audio.create_proxy() + backend = actor.M3UBackend.start( + config=self.config, audio=audio).proxy() + self.core = core.Core(backends=[backend]) def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() @@ -117,10 +117,8 @@ class M3UPlaylistsProviderTest(unittest.TestCase): 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) - result = backend.playlists.lookup(playlist.uri) + self.assertEqual(len(self.core.playlists.as_list()), 1) + result = self.core.playlists.lookup(playlist.uri) self.assertEqual(playlist.uri, result.uri) self.assertEqual(playlist.name, result.name) self.assertEqual(track.uri, result.tracks[0].uri) @@ -186,14 +184,15 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.assertEqual([playlist], playlists) def test_filter_by_name_returns_single_match(self): - playlist = Playlist(uri='m3u:b', name='b') - self.backend.playlists.playlists = [ - Playlist(uri='m3u:a', name='a'), playlist] + self.core.playlists.create('a') + playlist = self.core.playlists.create('b') + self.assertEqual([playlist], self.core.playlists.filter(name='b')) def test_filter_by_name_returns_no_matches(self): - self.backend.playlists.playlists = [ - Playlist(uri='m3u:a', name='a'), Playlist(uri='m3u:b', name='b')] + self.core.playlists.create('a') + self.core.playlists.create('b') + self.assertEqual([], self.core.playlists.filter(name='c')) def test_lookup_finds_playlist_by_uri(self): @@ -247,10 +246,8 @@ class M3UPlaylistsProviderTest(unittest.TestCase): playlist = playlist.copy(tracks=[track]) playlist = self.core.playlists.save(playlist) - backend = self.backend_class(config=self.config, audio=self.audio) - - self.assertEqual(len(backend.playlists.as_list()), 1) - result = backend.playlists.lookup('m3u:test.m3u') + self.assertEqual(len(self.core.playlists.as_list()), 1) + result = self.core.playlists.lookup('m3u:test.m3u') self.assertEqual('m3u:test.m3u', result.uri) self.assertEqual(playlist.name, result.name) self.assertEqual(track.uri, result.tracks[0].uri) From c0f99466c3d6aa1bea3c1e98e2dbd72fe89078b2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Mar 2015 13:31:42 +0100 Subject: [PATCH 250/314] m3u: Remove playlists property --- mopidy/m3u/playlists.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/mopidy/m3u/playlists.py b/mopidy/m3u/playlists.py index d5f2b1e9..1fc5b4c3 100644 --- a/mopidy/m3u/playlists.py +++ b/mopidy/m3u/playlists.py @@ -34,15 +34,6 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): return None return [Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] - @property - def playlists(self): - return sorted( - self._playlists.values(), key=operator.attrgetter('name')) - - @playlists.setter - def playlists(self, playlists): - self._playlists = {playlist.uri: playlist for playlist in playlists} - def create(self, name): playlist = self._save_m3u(Playlist(name=name)) self._playlists[playlist.uri] = playlist From 97fd102fa2520d2e992e6e4be3fcb19f363db0fe Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Mar 2015 15:02:25 +0100 Subject: [PATCH 251/314] docs: Add core API cleanup to changelog --- docs/changelog.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5155fc79..40d707a9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -77,6 +77,18 @@ v1.0.0 (UNRELEASED) :meth:`mopidy.core.LibraryController.find_exact` to normalize and warn about bad queries from clients. (Fixes: :issue:`1067`, PR: :issue:`1073`) +- Reduced API surface of core. (Fixes: :issue:`1070`, PR: :issue:`1076`) + + - Made ``mopidy.core.PlaybackController.change_track`` internal. + - Removed ``on_error_step`` from :meth:`mopidy.core.PlaybackController.play` + - Made the following event triggers internal: + + - ``mopidy.core.PlaybackController.on_end_of_track`` + - ``mopidy.core.PlaybackController.on_stream_changed`` + - ``mopidy.core.PlaybackController.on_tracklist_changed`` + + - Made ``mopidy.core.PlaybackController.set_current_tl_track`` internal. + **Backend API** - Remove default implementation of From f4452b22db064b7843fd8111d94103521cf9a2e6 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Mar 2015 15:02:37 +0100 Subject: [PATCH 252/314] core: Minor readability improvement --- mopidy/core/playback.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 8d9b7777..61bbc60c 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -274,7 +274,7 @@ class PlaybackController(object): :param tl_track: track to play :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` """ - self._play(tl_track, 1) + self._play(tl_track, on_error_step=1) def _play(self, tl_track=None, on_error_step=1): if tl_track is None: From 81f2e5c6f06318d1649aae25e704470085f8a168 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Mar 2015 23:09:31 +0100 Subject: [PATCH 253/314] core: Deprecate empty queries (Fixes #1072) --- mopidy/core/library.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index ee0c2e64..2904d451 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -124,8 +124,10 @@ class LibraryController(object): """ Search the library for tracks where ``field`` is ``values``. - If the query is empty, and the backend can support it, all available - tracks are returned. + .. deprecated:: 1.0 + Previously, if the query was empty, and the backend could support + it, all available tracks were returned. This has not changed, but + it is strongly discouraged. No new code should rely on this If ``uris`` is given, the search is limited to results from within the URI roots. For example passing ``uris=['file:']`` will limit the search @@ -233,8 +235,11 @@ class LibraryController(object): """ Search the library for tracks where ``field`` contains ``values``. - If the query is empty, and the backend can support it, all available - tracks are returned. + .. deprecated:: 1.0 + Previously, if the query was empty, and the backend could support + it, all available tracks were returned. This has not changed, but + it is strongly discouraged. No new code should rely on this + behavior. If ``uris`` is given, the search is limited to results from within the URI roots. For example passing ``uris=['file:']`` will limit the search @@ -282,6 +287,12 @@ def _normalize_query(query): query[field] = [values] if broken_client: logger.warning( - 'Client sent a broken search query, values must be lists. Please ' - 'check which client sent this query and file a bug against them.') + 'A client or frontend made a broken library search. Values in ' + 'queries must be lists of strings, not a string. Please check what' + ' sent this query and file a bug. Query: %s', query) + if not query: + logger.warning( + 'A client or frontend made a library search with an empty query. ' + 'This is strongly discouraged. Please check what sent this query ' + 'and file a bug.') return query From 636d8f11157d7f2b492e5fa2d24b58307c4c2f15 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Mar 2015 23:16:35 +0100 Subject: [PATCH 254/314] core: Add verionadded annotations to LibraryController methods --- mopidy/core/library.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 2904d451..2ab90bef 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -63,6 +63,8 @@ class LibraryController(object): :param string uri: URI to browse :rtype: list of :class:`mopidy.models.Ref` + + .. versionadded:: 0.18 """ if uri is None: backends = self.backends.with_library_browse.values() @@ -88,6 +90,8 @@ class LibraryController(object): :param dict query: Query to use for limiting results, see :meth:`search` for details about the query format. :rtype: set of values corresponding to the requested field type. + + .. versionadded:: 1.0 """ futures = [b.library.get_distinct(field, query) for b in self.backends.with_library.values()] @@ -108,6 +112,8 @@ class LibraryController(object): :param list uris: list of URIs to find images for :rtype: {uri: tuple of :class:`mopidy.models.Image`} + + .. versionadded:: 1.0 """ futures = [ backend.library.get_images(backend_uris) From 24fe242d561803d105e07457b762446d97db7866 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Mar 2015 23:55:03 +0100 Subject: [PATCH 255/314] core/backend: Remove find_exact from backends Functionality has been replaced with an `exact` param in the search method. Backends that still implement find_exact will continue being called via the old method for now. --- docs/changelog.rst | 5 +++ mopidy/backend.py | 14 ++----- mopidy/core/library.py | 19 +++++++-- tests/core/test_library.py | 84 +++++++++++++++++++++++--------------- 4 files changed, 75 insertions(+), 47 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 09bf743a..94f35433 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -138,6 +138,11 @@ v1.0.0 (UNRELEASED) - Remove :attr:`mopidy.backend.PlaylistsProvider.playlists` property. +- Removed ``find_exact`` from :class:`mopidy.backend.LibraryProvider` and + added an ``exact`` param to :meth:`mopidy.backend.LibraryProvider.search` + to replace the old code path. Core will continue supporting backends that + have not upgraded for now. + **Commands** - Make the ``mopidy`` command print a friendly error message if the diff --git a/mopidy/backend.py b/mopidy/backend.py index 02a624d9..63184853 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -119,15 +119,6 @@ class LibraryProvider(object): result[uri] = [models.Image(uri=u) for u in image_uris] return result - # TODO: replace with search(query, exact=True, ...) - def find_exact(self, query=None, uris=None): - """ - See :meth:`mopidy.core.LibraryController.find_exact`. - - *MAY be implemented by subclass.* - """ - pass - def lookup(self, uri): """ See :meth:`mopidy.core.LibraryController.lookup`. @@ -144,11 +135,14 @@ class LibraryProvider(object): """ pass - def search(self, query=None, uris=None): + def search(self, query=None, uris=None, exact=False): """ See :meth:`mopidy.core.LibraryController.search`. *MAY be implemented by subclass.* + + .. versionadded:: 1.0 + The ``exact`` param which replaces the old ``find_exact``. """ pass diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 2ab90bef..80c61bbb 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -160,6 +160,12 @@ class LibraryController(object): {'any': ['a']}, uris=['file:///media/music', 'spotify:']) find_exact(any=['a'], uris=['file:///media/music', 'spotify:']) + .. versionchanged:: 1.0 + This method now calls + :meth:`~mopidy.backend.LibraryProvider.search` on the backends + instead of the deprecated ``find_exact``. If the backend still + implements ``find_exact`` we will continue to use it for now. + :param query: one or more queries to search for :type query: dict :param uris: zero or more URI roots to limit the search to @@ -167,10 +173,15 @@ class LibraryController(object): :rtype: list of :class:`mopidy.models.SearchResult` """ query = _normalize_query(query or kwargs) - futures = [ - backend.library.find_exact(query=query, uris=backend_uris) - for (backend, backend_uris) - in self._get_backends_to_uris(uris).items()] + futures = [] + for backend, backend_uris in self._get_backends_to_uris(uris).items(): + if hasattr(backend.library, 'find_exact'): + futures.append(backend.library.find_exact( + query=query, uris=backend_uris)) + else: + futures.append(backend.library.search( + query=query, uris=backend_uris, exact=True)) + return [result for result in pykka.get_all(futures) if result] def lookup(self, uri=None, uris=None): diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 9a23d874..98b25f38 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -212,54 +212,50 @@ class CoreLibraryTest(unittest.TestCase): result1 = SearchResult(tracks=[track1]) result2 = SearchResult(tracks=[track2]) - self.library1.find_exact().get.return_value = result1 - self.library1.find_exact.reset_mock() - self.library2.find_exact().get.return_value = result2 - self.library2.find_exact.reset_mock() + self.library1.search.return_value.get.return_value = result1 + self.library2.search.return_value.get.return_value = result2 result = self.core.library.find_exact(any=['a']) self.assertIn(result1, result) self.assertIn(result2, result) - self.library1.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=None) - self.library2.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=None) + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) + self.library2.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) def test_find_exact_with_uris_selects_dummy1_backend(self): self.core.library.find_exact( any=['a'], uris=['dummy1:', 'dummy1:foo', 'dummy3:']) - self.library1.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo']) - self.assertFalse(self.library2.find_exact.called) + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], exact=True) + self.assertFalse(self.library2.search.called) def test_find_exact_with_uris_selects_both_backends(self): self.core.library.find_exact( any=['a'], uris=['dummy1:', 'dummy1:foo', 'dummy2:']) - self.library1.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo']) - self.library2.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=['dummy2:']) + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], exact=True) + self.library2.search.assert_called_once_with( + query=dict(any=['a']), uris=['dummy2:'], exact=True) def test_find_exact_filters_out_none(self): track1 = Track(uri='dummy1:a') result1 = SearchResult(tracks=[track1]) - self.library1.find_exact().get.return_value = result1 - self.library1.find_exact.reset_mock() - self.library2.find_exact().get.return_value = None - self.library2.find_exact.reset_mock() + self.library1.search.return_value.get.return_value = result1 + self.library2.search.return_value.get.return_value = None result = self.core.library.find_exact(any=['a']) self.assertIn(result1, result) self.assertNotIn(None, result) - self.library1.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=None) - self.library2.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=None) + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) + self.library2.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) def test_find_accepts_query_dict_instead_of_kwargs(self): track1 = Track(uri='dummy1:a') @@ -267,19 +263,17 @@ class CoreLibraryTest(unittest.TestCase): result1 = SearchResult(tracks=[track1]) result2 = SearchResult(tracks=[track2]) - self.library1.find_exact().get.return_value = result1 - self.library1.find_exact.reset_mock() - self.library2.find_exact().get.return_value = result2 - self.library2.find_exact.reset_mock() + self.library1.search.return_value.get.return_value = result1 + self.library2.search.return_value.get.return_value = result2 result = self.core.library.find_exact(dict(any=['a'])) self.assertIn(result1, result) self.assertIn(result2, result) - self.library1.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=None) - self.library2.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=None) + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) + self.library2.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) def test_search_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') @@ -363,5 +357,29 @@ class CoreLibraryTest(unittest.TestCase): def test_find_exact_normalises_bad_queries(self): self.core.library.find_exact({'any': 'foobar'}) - self.library1.find_exact.assert_called_once_with( - query={'any': ['foobar']}, uris=None) + self.library1.search.assert_called_once_with( + query={'any': ['foobar']}, uris=None, exact=True) + + +class LegacyLibraryProvider(backend.LibraryProvider): + def find_exact(self, query=None, uris=None): + pass + + +class LegacyCoreLibraryTest(unittest.TestCase): + def test_backend_with_find_exact_still_works(self): + b1 = mock.Mock() + b1.uri_schemes.get.return_value = ['dummy1'] + b1.library = mock.Mock(spec=LegacyLibraryProvider) + + b2 = mock.Mock() + b2.uri_schemes.get.return_value = ['dummy2'] + b2.library = mock.Mock(spec=backend.LibraryProvider) + + c = core.Core(mixer=None, backends=[b1, b2]) + c.library.find_exact(query={'any': ['a']}) + + b1.library.find_exact.assert_called_once_with( + query=dict(any=['a']), uris=None) + b2.library.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) From 4e30fb2f488a8a6c8f1a9f4f4468a982c55dd8c2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Mar 2015 00:40:55 +0100 Subject: [PATCH 256/314] core: Make get_playlists() maintain folder hierarchy --- mopidy/core/playlists.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 54797abe..e791380f 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -71,8 +71,12 @@ class PlaylistsController(object): playlist_refs = self.as_list() if include_tracks: - playlists = [self.lookup(r.uri) for r in playlist_refs] - return [pl for pl in playlists if pl is not None] + playlists = {r.uri: self.lookup(r.uri) for r in playlist_refs} + # Use the playlist name from as_list() because it knows about any + # playlist folder hierarchy, which lookup() does not. + return [ + playlists[r.uri].copy(name=r.name) + for r in playlist_refs if playlists[r.uri] is not None] else: return [ Playlist(uri=r.uri, name=r.name) for r in playlist_refs] From e06c7708a7059433fc3036eb5d0332f239d3a594 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Mar 2015 01:04:26 +0100 Subject: [PATCH 257/314] utils: Add time_logger context manager --- mopidy/utils/timer.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 mopidy/utils/timer.py diff --git a/mopidy/utils/timer.py b/mopidy/utils/timer.py new file mode 100644 index 00000000..b8dcb30d --- /dev/null +++ b/mopidy/utils/timer.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals + +import contextlib +import logging +import time + + +logger = logging.getLogger(__name__) +TRACE = logging.getLevelName('TRACE') + + +@contextlib.contextmanager +def time_logger(name, level=TRACE): + start = time.time() + yield + logger.log(level, '%s took %dms', name, (time.time() - start) * 1000) From f48a8ad938ada0c563c6a493854a3456ef85daec Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Mar 2015 01:15:53 +0100 Subject: [PATCH 258/314] mpd: Move playlist.lookup() out of helper --- mopidy/mpd/dispatcher.py | 4 ++-- mopidy/mpd/protocol/music_db.py | 3 ++- mopidy/mpd/protocol/stored_playlists.py | 9 ++++++--- mopidy/mpd/uri_mapper.py | 9 +++------ 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index eece86d9..d156b891 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -245,11 +245,11 @@ class MpdContext(object): self.subscriptions = set() self._uri_map = uri_map - def lookup_playlist_from_name(self, name): + def lookup_playlist_uri_from_name(self, name): """ Helper function to retrieve a playlist from its unique MPD name. """ - return self._uri_map.playlist_from_name(name) + return self._uri_map.playlist_uri_from_name(name) def lookup_playlist_name_from_uri(self, uri): """ diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 62147b7d..a942abf5 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -465,7 +465,8 @@ def searchaddpl(context, *args): return results = context.core.library.search(**query).get() - playlist = context.lookup_playlist_from_name(playlist_name) + uri = context.lookup_playlist_uri_from_name(playlist_name) + playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: playlist = context.core.playlists.create(playlist_name).get() tracks = list(playlist.tracks) + _get_tracks(results) diff --git a/mopidy/mpd/protocol/stored_playlists.py b/mopidy/mpd/protocol/stored_playlists.py index f273e9b9..c24b2f6e 100644 --- a/mopidy/mpd/protocol/stored_playlists.py +++ b/mopidy/mpd/protocol/stored_playlists.py @@ -20,7 +20,8 @@ def listplaylist(context, name): file: relative/path/to/file2.ogg file: relative/path/to/file3.mp3 """ - playlist = context.lookup_playlist_from_name(name) + uri = context.lookup_playlist_uri_from_name(name) + playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: raise exceptions.MpdNoExistError('No such playlist') return ['file: %s' % t.uri for t in playlist.tracks] @@ -40,7 +41,8 @@ def listplaylistinfo(context, name): Standard track listing, with fields: file, Time, Title, Date, Album, Artist, Track """ - playlist = context.lookup_playlist_from_name(name) + uri = context.lookup_playlist_uri_from_name(name) + playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: raise exceptions.MpdNoExistError('No such playlist') return translator.playlist_to_mpd_format(playlist) @@ -121,7 +123,8 @@ def load(context, name, playlist_slice=slice(0, None)): - MPD 0.17.1 does not fail if the specified range is outside the playlist, in either or both ends. """ - playlist = context.lookup_playlist_from_name(name) + uri = context.lookup_playlist_uri_from_name(name) + playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: raise exceptions.MpdNoExistError('No such playlist') context.core.tracklist.add(playlist.tracks[playlist_slice]) diff --git a/mopidy/mpd/uri_mapper.py b/mopidy/mpd/uri_mapper.py index 082f1311..eba9191e 100644 --- a/mopidy/mpd/uri_mapper.py +++ b/mopidy/mpd/uri_mapper.py @@ -59,16 +59,13 @@ class MpdUriMapper(object): name = self._invalid_playlist_chars.sub('|', playlist.name) self.insert(name, playlist.uri) - def playlist_from_name(self, name): + def playlist_uri_from_name(self, name): """ - Helper function to retrieve a playlist from its unique MPD name. + Helper function to retrieve a playlist URI from its unique MPD name. """ if not self._uri_from_name: self.refresh_playlists_mapping() - if name not in self._uri_from_name: - return None - uri = self._uri_from_name[name] - return self.core.playlists.lookup(uri).get() + return self._uri_from_name.get(name) def playlist_name_from_uri(self, uri): """ From af727bba4e3a57875b023c29f3f5de4d1510b6f6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Mar 2015 01:25:41 +0100 Subject: [PATCH 259/314] mpd: Use as_list() to build URI-to-MPD-name map --- mopidy/mpd/uri_mapper.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/mopidy/mpd/uri_mapper.py b/mopidy/mpd/uri_mapper.py index eba9191e..08c7f689 100644 --- a/mopidy/mpd/uri_mapper.py +++ b/mopidy/mpd/uri_mapper.py @@ -52,12 +52,11 @@ class MpdUriMapper(object): MPD. """ if self.core is not None: - for playlist in self.core.playlists.playlists.get(): - if not playlist.name: + for playlist_ref in self.core.playlists.as_list().get(): + if not playlist_ref.name: continue - # TODO: add scheme to name perhaps 'foo (spotify)' etc. - name = self._invalid_playlist_chars.sub('|', playlist.name) - self.insert(name, playlist.uri) + name = self._invalid_playlist_chars.sub('|', playlist_ref.name) + self.insert(name, playlist_ref.uri) def playlist_uri_from_name(self, name): """ From 45ce75586ea4814dd0e5ebae85708c93d3ff2c68 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Mar 2015 01:28:14 +0100 Subject: [PATCH 260/314] mpd: Use get_playlists() in listplaylists --- mopidy/mpd/protocol/stored_playlists.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/mpd/protocol/stored_playlists.py b/mopidy/mpd/protocol/stored_playlists.py index c24b2f6e..9d9f66e0 100644 --- a/mopidy/mpd/protocol/stored_playlists.py +++ b/mopidy/mpd/protocol/stored_playlists.py @@ -75,7 +75,7 @@ def listplaylists(context): ignore playlists without names, which isn't very useful anyway. """ result = [] - for playlist in context.core.playlists.playlists.get(): + for playlist in context.core.playlists.get_playlists().get(): if not playlist.name: continue name = context.lookup_playlist_name_from_uri(playlist.uri) From 23e2295c460a20f4e36b213c7a244d136bfbda3a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Mar 2015 01:37:30 +0100 Subject: [PATCH 261/314] dummy: Fix playlists.get_items() bug --- tests/dummy_backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index 9f4a0986..babaf0de 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -105,7 +105,7 @@ class DummyPlaylistsProvider(backend.PlaylistsProvider): Ref.playlist(uri=pl.uri, name=pl.name) for pl in self._playlists] def get_items(self, uri): - playlist = self._playlists.get(uri) + playlist = self.lookup(uri) if playlist is None: return return [ From 3ceb16095d1f9234367f1b996deaa27ce59c2a2a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 24 Mar 2015 08:46:52 +0100 Subject: [PATCH 262/314] utils: Install TRACE log level add module import time. --- mopidy/utils/log.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 6343a866..d2dcca70 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -17,6 +17,7 @@ LOG_LEVELS = { # Custom log level which has even lower priority than DEBUG TRACE_LOG_LEVEL = 5 +logging.addLevelName(TRACE_LOG_LEVEL, 'TRACE') class DelayedHandler(logging.Handler): @@ -46,7 +47,6 @@ def bootstrap_delayed_logging(): def setup_logging(config, verbosity_level, save_debug_log): - logging.addLevelName(TRACE_LOG_LEVEL, 'TRACE') logging.captureWarnings(True) From 3e361d48709e3673140bdc4072f61a25297200ee Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 24 Mar 2015 08:47:32 +0100 Subject: [PATCH 263/314] local: Use the new debug timer instead of our own --- mopidy/local/json.py | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 969049d6..22fcfa5b 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -8,12 +8,11 @@ import os import re import sys import tempfile -import time import mopidy from mopidy import compat, local, models from mopidy.local import search, storage, translator -from mopidy.utils import encoding +from mopidy.utils import encoding, timer logger = logging.getLogger(__name__) @@ -109,20 +108,6 @@ class _BrowseCache(object): return self._cache.get(uri, {}).values() -# TODO: make this available to other code? -class DebugTimer(object): - def __init__(self, msg): - self.msg = msg - self.start = None - - def __enter__(self): - self.start = time.time() - - def __exit__(self, exc_type, exc_value, traceback): - duration = (time.time() - self.start) * 1000 - logger.debug('%s: %dms', self.msg, duration) - - class JsonLibrary(local.Library): name = 'json' @@ -142,10 +127,10 @@ class JsonLibrary(local.Library): def load(self): logger.debug('Loading library: %s', self._json_file) - with DebugTimer('Loading tracks'): + with timer.time_logger('Loading tracks'): library = load_library(self._json_file) self._tracks = dict((t.uri, t) for t in library.get('tracks', [])) - with DebugTimer('Building browse cache'): + with timer.time_logger('Building browse cache'): self._browse_cache = _BrowseCache(sorted(self._tracks.keys())) return len(self._tracks) From 141c14ad45c7c09da481f067f6188f4332deb696 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 24 Mar 2015 09:26:11 +0100 Subject: [PATCH 264/314] core: Add exact to search() and deprecate find_exact() Backends that still implement find_exact will be called without exact as an argument to search, and we will continue to use find_exact. Please remove find_exact from such backends and switch to the new search API. --- docs/changelog.rst | 5 +++ mopidy/core/library.py | 83 ++++++++++++-------------------------- tests/core/test_library.py | 39 +++++++++++++----- 3 files changed, 58 insertions(+), 69 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 94f35433..d3573e89 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -103,6 +103,11 @@ v1.0.0 (UNRELEASED) - **Deprecated:** :meth:`mopidy.core.PlaylistsController.filter`. Use :meth:`~mopidy.core.PlaylistsController.as_list` and filter yourself. +- Add ``exact`` to :meth:`mopidy.core.LibraryController.search`. + +- **Deprecated:** :meth:`mopidy.core.LibraryController.find_exact`. Use + :meth:`mopidy.core.LibraryController.search` with ``exact`` set. + **Backend API** - Remove default implementation of diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 80c61bbb..44375f58 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -127,62 +127,12 @@ class LibraryController(object): return results def find_exact(self, query=None, uris=None, **kwargs): - """ - Search the library for tracks where ``field`` is ``values``. + """Search the library for tracks where ``field`` is ``values``. .. deprecated:: 1.0 - Previously, if the query was empty, and the backend could support - it, all available tracks were returned. This has not changed, but - it is strongly discouraged. No new code should rely on this - - If ``uris`` is given, the search is limited to results from within the - URI roots. For example passing ``uris=['file:']`` will limit the search - to the local backend. - - Examples:: - - # Returns results matching 'a' from any backend - find_exact({'any': ['a']}) - find_exact(any=['a']) - - # Returns results matching artist 'xyz' from any backend - find_exact({'artist': ['xyz']}) - find_exact(artist=['xyz']) - - # Returns results matching 'a' and 'b' and artist 'xyz' from any - # backend - find_exact({'any': ['a', 'b'], 'artist': ['xyz']}) - find_exact(any=['a', 'b'], artist=['xyz']) - - # Returns results matching 'a' if within the given URI roots - # "file:///media/music" and "spotify:" - find_exact( - {'any': ['a']}, uris=['file:///media/music', 'spotify:']) - find_exact(any=['a'], uris=['file:///media/music', 'spotify:']) - - .. versionchanged:: 1.0 - This method now calls - :meth:`~mopidy.backend.LibraryProvider.search` on the backends - instead of the deprecated ``find_exact``. If the backend still - implements ``find_exact`` we will continue to use it for now. - - :param query: one or more queries to search for - :type query: dict - :param uris: zero or more URI roots to limit the search to - :type uris: list of strings or :class:`None` - :rtype: list of :class:`mopidy.models.SearchResult` + Use :meth:`search` with ``exact`` set. """ - query = _normalize_query(query or kwargs) - futures = [] - for backend, backend_uris in self._get_backends_to_uris(uris).items(): - if hasattr(backend.library, 'find_exact'): - futures.append(backend.library.find_exact( - query=query, uris=backend_uris)) - else: - futures.append(backend.library.search( - query=query, uris=backend_uris, exact=True)) - - return [result for result in pykka.get_all(futures) if result] + return self.search(query=query, uris=uris, exact=True, **kwargs) def lookup(self, uri=None, uris=None): """ @@ -248,7 +198,7 @@ class LibraryController(object): for b in self.backends.with_library.values()] pykka.get_all(futures) - def search(self, query=None, uris=None, **kwargs): + def search(self, query=None, uris=None, exact=False, **kwargs): """ Search the library for tracks where ``field`` contains ``values``. @@ -287,12 +237,29 @@ class LibraryController(object): :param uris: zero or more URI roots to limit the search to :type uris: list of strings or :class:`None` :rtype: list of :class:`mopidy.models.SearchResult` + + .. versionadded:: 1.0 + The ``exact`` keyword argument, which replaces :meth:`find_exact`. """ query = _normalize_query(query or kwargs) - futures = [ - backend.library.search(query=query, uris=backend_uris) - for (backend, backend_uris) - in self._get_backends_to_uris(uris).items()] + futures = [] + for backend, backend_uris in self._get_backends_to_uris(uris).items(): + if hasattr(backend.library, 'find_exact'): + # Backends with find_exact probably don't have support for + # search with the exact kwarg, so give them the legacy calls. + if exact: + futures.append(backend.library.find_exact( + query=query, uris=backend_uris)) + else: + futures.append(backend.library.search( + query=query, uris=backend_uris)) + else: + # Assume backends without find_exact are up to date. Worst case + # the exact gets swallowed by the **kwargs and things hopefully + # still work. + futures.append(backend.library.search( + query=query, uris=backend_uris, exact=exact)) + return [result for result in pykka.get_all(futures) if result] diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 98b25f38..50eb834f 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -291,16 +291,16 @@ class CoreLibraryTest(unittest.TestCase): self.assertIn(result1, result) self.assertIn(result2, result) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query=dict(any=['a']), uris=None, exact=False) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query=dict(any=['a']), uris=None, exact=False) def test_search_with_uris_selects_dummy1_backend(self): self.core.library.search( query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo', 'dummy3:']) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo']) + query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], exact=False) self.assertFalse(self.library2.search.called) def test_search_with_uris_selects_both_backends(self): @@ -308,9 +308,9 @@ class CoreLibraryTest(unittest.TestCase): query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo', 'dummy2:']) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo']) + query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], exact=False) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy2:']) + query=dict(any=['a']), uris=['dummy2:'], exact=False) def test_search_filters_out_none(self): track1 = Track(uri='dummy1:a') @@ -326,9 +326,9 @@ class CoreLibraryTest(unittest.TestCase): self.assertIn(result1, result) self.assertNotIn(None, result) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query=dict(any=['a']), uris=None, exact=False) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query=dict(any=['a']), uris=None, exact=False) def test_search_accepts_query_dict_instead_of_kwargs(self): track1 = Track(uri='dummy1:a') @@ -346,14 +346,14 @@ class CoreLibraryTest(unittest.TestCase): self.assertIn(result1, result) self.assertIn(result2, result) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query=dict(any=['a']), uris=None, exact=False) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None) + query=dict(any=['a']), uris=None, exact=False) def test_search_normalises_bad_queries(self): self.core.library.search({'any': 'foobar'}) self.library1.search.assert_called_once_with( - query={'any': ['foobar']}, uris=None) + query={'any': ['foobar']}, uris=None, exact=False) def test_find_exact_normalises_bad_queries(self): self.core.library.find_exact({'any': 'foobar'}) @@ -367,7 +367,7 @@ class LegacyLibraryProvider(backend.LibraryProvider): class LegacyCoreLibraryTest(unittest.TestCase): - def test_backend_with_find_exact_still_works(self): + def test_backend_with_find_exact_gets_find_exact_call(self): b1 = mock.Mock() b1.uri_schemes.get.return_value = ['dummy1'] b1.library = mock.Mock(spec=LegacyLibraryProvider) @@ -383,3 +383,20 @@ class LegacyCoreLibraryTest(unittest.TestCase): query=dict(any=['a']), uris=None) b2.library.search.assert_called_once_with( query=dict(any=['a']), uris=None, exact=True) + + def test_backend_with_find_exact_gets_search_without_exact_arg(self): + b1 = mock.Mock() + b1.uri_schemes.get.return_value = ['dummy1'] + b1.library = mock.Mock(spec=LegacyLibraryProvider) + + b2 = mock.Mock() + b2.uri_schemes.get.return_value = ['dummy2'] + b2.library = mock.Mock(spec=backend.LibraryProvider) + + c = core.Core(mixer=None, backends=[b1, b2]) + c.library.search(query={'any': ['a']}) + + b1.library.search.assert_called_once_with( + query=dict(any=['a']), uris=None) + b2.library.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=False) From 779a399c59837e05ebac96307e2cb443585e5269 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 24 Mar 2015 20:09:17 +0100 Subject: [PATCH 265/314] main: Use timer.time_logger helper --- mopidy/commands.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/mopidy/commands.py b/mopidy/commands.py index 5df8dd5a..ebb2c891 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -2,11 +2,9 @@ from __future__ import absolute_import, print_function, unicode_literals import argparse import collections -import contextlib import logging import os import sys -import time import glib @@ -15,7 +13,7 @@ import gobject from mopidy import config as config_lib, exceptions from mopidy.audio import Audio from mopidy.core import Core -from mopidy.utils import deps, process, versioning +from mopidy.utils import deps, process, timer, versioning logger = logging.getLogger(__name__) @@ -65,13 +63,6 @@ class _HelpAction(argparse.Action): raise _HelpError() -@contextlib.contextmanager -def _startup_timer(name): - start = time.time() - yield - logger.debug('%s startup took %dms', name, (time.time() - start) * 1000) - - class Command(object): """Command parser and runner for building trees of commands. @@ -356,7 +347,7 @@ class RootCommand(Command): backends = [] for backend_class in backend_classes: try: - with _startup_timer(backend_class.__name__): + with timer.time_logger(backend_class.__name__): backend = backend_class.start( config=config, audio=audio).proxy() backends.append(backend) @@ -379,7 +370,7 @@ class RootCommand(Command): for frontend_class in frontend_classes: try: - with _startup_timer(frontend_class.__name__): + with timer.time_logger(frontend_class.__name__): frontend_class.start(config=config, core=core) except exceptions.FrontendError as exc: logger.error( From e0d0e785e06afc3d5bed634f61fc724ceab82419 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Mar 2015 21:35:12 +0100 Subject: [PATCH 266/314] docs: Cleanup v1.0.0 changelog Fixes #1079 --- docs/changelog.rst | 376 ++++++++++++++++++++++++++++----------------- 1 file changed, 237 insertions(+), 139 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index d3573e89..47c30a72 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,22 +8,49 @@ This changelog is used to track all major changes to Mopidy. v1.0.0 (UNRELEASED) =================== -**Models** +Three months after our fifth anniversary, Mopidy 1.0 is finally here! -- Add :class:`mopidy.models.Image` model to be returned by - :meth:`mopidy.core.LibraryController.get_images`. (Part of :issue:`973`) +Since the release of 0.19, we've closed or merged approximately 140 issues and +pull requests through more than 600 commits by a record high 19 extraordinary +people, including seven newcomers. Thanks to everyone that has contributed! -- Change the semantics of :attr:`mopidy.models.Track.last_modified` to be - milliseconds instead of seconds since Unix epoch, or a simple counter, - depending on the source of the track. This makes it match the semantics of - :attr:`mopidy.models.Playlist.last_modified`. (Fixes: :issue:`678`, PR: - :issue:`1036`) +For the longest time, the focus of Mopidy 1.0 was to be another incremental +improvement, to be numbered 0.20. The result is still very much an incremental +improvement, with lots of small and larger improvements across Mopidy's +functionality. -**Core API** +The major features of Mopidy 1.0 are: -- **Deprecated:** Deprecate all properties in the core API. The previously - undocumented getter and setter methods are now the official API. This aligns - the Python API with the WebSocket/JavaScript API. (Fixes: :issue:`952`) +- A promise to follow not break APIs before Mopidy 2.0. A Mopidy extension + working with Mopidy 1.0 should continue to work with all Mopidy 1.x releases. + +- Preparation work to enable gapless playback in the near future. + +TODO: to be continued + +Dependencies +------------ + +Since the previous release there is no changes to Mopidy's dependencies. +However, porting from GStreamer 0.10 to 1.x and support for running Mopidy with +Python 3.4+ is not far off on our roadmap. + +Core API +-------- + +In the API used by all frontends and web extensions there is lots of methods +and arguments that are now deprecated in preparation for the next major +release. With the exception of some internals that leaked out in the playback +controller, no core APIs have been removed in this release. In other words, +most clients should continue to work unchanged when upgrading to Mopidy 1.0. +Though, it is strongly encouraged to review any use of the deprecated parts of +the API as those parts will be removed in Mopidy 2.0. + +- **Deprecated:** Deprecate all Python properties in the core API. The + previously undocumented getter and setter methods are now the official API. + This aligns the Python API with the WebSocket/JavaScript API. Python + frontends needs to be updated. WebSocket/JavaScript API users are not + affected. (Fixes: :issue:`952`) - Add :class:`mopidy.core.HistoryController` which keeps track of what tracks have been played. (Fixes: :issue:`423`, :issue:`1056`, PR: :issue:`803`, @@ -32,68 +59,37 @@ v1.0.0 (UNRELEASED) - Add :class:`mopidy.core.MixerController` which keeps track of volume and mute. (Fixes: :issue:`962`) -- Add ``uris`` argument to :meth:`mopidy.core.LibraryController.lookup` which - allows for simpler lookup of multiple URIs. (Fixes: :issue:`1008`, PR: +Core library controller +~~~~~~~~~~~~~~~~~~~~~~~ + +- **Deprecated:** :meth:`mopidy.core.LibraryController.find_exact`. Use + :meth:`mopidy.core.LibraryController.search` with the ``exact`` keyword + argument set to :class:`True`. + +- **Deprecated:** The ``uri`` argument to + :meth:`mopidy.core.LibraryController.lookup`. Use new ``uris`` keyword + argument instead. + +- Add ``exact`` keyword argument to + :meth:`mopidy.core.LibraryController.search`. + +- Add ``uris`` keyword argument to :meth:`mopidy.core.LibraryController.lookup` + which allows for simpler lookup of multiple URIs. (Fixes: :issue:`1008`, PR: :issue:`1047`) -- Add ``uris`` argument to :meth:`mopidy.core.TracklistController.add` which - allows for simpler addition of multiple URIs to the tracklist. (Fixes: - :issue:`1060`, PR: :issue:`1065`) - -- **Deprecated:** The old methods on :class:`mopidy.core.PlaybackController` for - volume and mute management have been deprecated. (Fixes: :issue:`962`) - -- Remove ``clear_current_track`` keyword argument to - :meth:`mopidy.core.PlaybackController.stop`. It was a leaky internal - abstraction, which was never intended to be used externally. - -- Add :meth:`mopidy.core.LibraryController.get_images` for looking up images - for any URI backends know about. (Fixes :issue:`973`, PR: :issue:`981`, - :issue:`992` and :issue:`1013`) - -- When seeking in paused state, do not change to playing state. (Fixes: - :issue:`939`, PR: :issue:`1018`) +- Updated :meth:`mopidy.core.LibraryController.search` and + :meth:`mopidy.core.LibraryController.find_exact` to normalize and warn about + malformed queries from clients. (Fixes: :issue:`1067`, PR: :issue:`1073`) - Add :meth:`mopidy.core.LibraryController.get_distinct` for getting unique values for a given field. (Fixes: :issue:`913`, PR: :issue:`1022`) -- Add :meth:`mopidy.core.Listener.stream_title_changed` and - :meth:`mopidy.core.PlaybackController.get_stream_title` for letting clients - know about the current song in streams. (PR: :issue:`938`, :issue:`1030`) +- Add :meth:`mopidy.core.LibraryController.get_images` for looking up images + for any URI that is known to the backends. (Fixes :issue:`973`, PR: + :issue:`981`, :issue:`992` and :issue:`1013`) -- The following methods were documented as internal. They are now fully private - and unavailable outside the core actor. (Fixes: :issue:`1058`, PR: - :issue:`1062`) - - - :meth:`mopidy.core.TracklistController.mark_played` - - :meth:`mopidy.core.TracklistController.mark_playing` - - :meth:`mopidy.core.TracklistController.mark_unplayable` - -- Updated :meth:`mopidy.core.PlaybackController.play` to take - :meth:`mopidy.backend.PlaybackProvider.change_track` into account when - determining success. (PR: :issue:`1071`) - -- Updated :meth:`mopidy.core.LibraryController.search` and - :meth:`mopidy.core.LibraryController.find_exact` to normalize and warn about - bad queries from clients. (Fixes: :issue:`1067`, PR: :issue:`1073`) - -- Reduced API surface of core. (Fixes: :issue:`1070`, PR: :issue:`1076`) - - - Made ``mopidy.core.PlaybackController.change_track`` internal. - - Removed ``on_error_step`` from :meth:`mopidy.core.PlaybackController.play` - - Made the following event triggers internal: - - - ``mopidy.core.PlaybackController.on_end_of_track`` - - ``mopidy.core.PlaybackController.on_stream_changed`` - - ``mopidy.core.PlaybackController.on_tracklist_changed`` - - - Made ``mopidy.core.PlaybackController.set_current_tl_track`` internal. - -- Add :meth:`mopidy.core.PlaylistsController.as_list`. (Fixes: :issue:`1057`, - PR: :issue:`1075`) - -- Add :meth:`mopidy.core.PlaylistsController.get_items`. (Fixes: :issue:`1057`, - PR: :issue:`1075`) +Core playlist controller +~~~~~~~~~~~~~~~~~~~~~~~~ - **Deprecated:** :meth:`mopidy.core.PlaylistsController.get_playlists`. Use :meth:`~mopidy.core.PlaylistsController.as_list` and @@ -103,17 +99,105 @@ v1.0.0 (UNRELEASED) - **Deprecated:** :meth:`mopidy.core.PlaylistsController.filter`. Use :meth:`~mopidy.core.PlaylistsController.as_list` and filter yourself. -- Add ``exact`` to :meth:`mopidy.core.LibraryController.search`. +- Add :meth:`mopidy.core.PlaylistsController.as_list`. (Fixes: :issue:`1057`, + PR: :issue:`1075`) -- **Deprecated:** :meth:`mopidy.core.LibraryController.find_exact`. Use - :meth:`mopidy.core.LibraryController.search` with ``exact`` set. +- Add :meth:`mopidy.core.PlaylistsController.get_items`. (Fixes: :issue:`1057`, + PR: :issue:`1075`) -**Backend API** +Core tracklist controller +~~~~~~~~~~~~~~~~~~~~~~~~~ -- Remove default implementation of +- **Removed:** The following methods were documented as internal. They are now + fully private and unavailable outside the core actor. (Fixes: :issue:`1058`, + PR: :issue:`1062`) + + - :meth:`mopidy.core.TracklistController.mark_played` + - :meth:`mopidy.core.TracklistController.mark_playing` + - :meth:`mopidy.core.TracklistController.mark_unplayable` + +- Add ``uris`` argument to :meth:`mopidy.core.TracklistController.add` which + allows for simpler addition of multiple URIs to the tracklist. (Fixes: + :issue:`1060`, PR: :issue:`1065`) + +Core playback controller +~~~~~~~~~~~~~~~~~~~~~~~~ + +- **Removed:** Remove several internal parts that was leaking into the public + API and was never intended to be used externally. (Fixes: :issue:`1070`, PR: + :issue:`1076`) + + - :meth:`mopidy.core.PlaybackController.change_track` is now internal. + + - Removed ``on_error_step`` keyword argument from + :meth:`mopidy.core.PlaybackController.play` + + - Removed ``clear_current_track`` keyword argument to + :meth:`mopidy.core.PlaybackController.stop`. + + - Made the following event triggers internal: + + - :meth:`mopidy.core.PlaybackController.on_end_of_track` + - :meth:`mopidy.core.PlaybackController.on_stream_changed` + - :meth:`mopidy.core.PlaybackController.on_tracklist_changed` + + - :meth:`mopidy.core.PlaybackController.set_current_tl_track` is now + internal. + +- **Deprecated:** The old methods on :class:`mopidy.core.PlaybackController` + for volume and mute management have been deprecated. Use + :class:`mopidy.core.MixerController` instead. (Fixes: :issue:`962`) + +- When seeking while paused, we no longer change to playing. (Fixes: + :issue:`939`, PR: :issue:`1018`) + +- Changed :meth:`mopidy.core.PlaybackController.play` to take the return value + from :meth:`mopidy.backend.PlaybackProvider.change_track` into account when + determining the success of the :meth:`~mopidy.core.PlaybackController.play` + call. (PR: :issue:`1071`) + +- Add :meth:`mopidy.core.Listener.stream_title_changed` and + :meth:`mopidy.core.PlaybackController.get_stream_title` for letting clients + know about the current title in streams. (PR: :issue:`938`, :issue:`1030`) + +Backend API +----------- + +In the API implemented by all backends there have been way fewer but somewhat +more dramatic changes with some methods removed and new ones being required for +certain functionality to continue working. Most backends are already updated to +be compatible with Mopidy 1.0 before the release. New versions of the backends +will be released shortly after Mopidy itself. + +Backend library providers +~~~~~~~~~~~~~~~~~~~~~~~~~ + +- **Removed:** Remove :meth:`mopidy.backend.LibraryProvider.find_exact`. + +- Add an ``exact`` keyword argument to + :meth:`mopidy.backend.LibraryProvider.search` to replace the old + :meth:`~mopidy.backend.LibraryProvider.find_exact` method. + +Backend playlist providers +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- **Removed:** Remove default implementation of :attr:`mopidy.backend.PlaylistsProvider.playlists`. This is potentially backwards incompatible. (PR: :issue:`1046`) +- Changed the API for :class:`mopidy.backend.PlaylistsProvider`. Note that this + change is **not** backwards compatible. These changes are important to reduce + the Mopidy startup time. (Fixes: :issue:`1057`, PR: :issue:`1075`) + + - Add :meth:`mopidy.backend.PlaylistsProvider.as_list`. + + - Add :meth:`mopidy.backend.PlaylistsProvider.get_items`. + + - Remove :attr:`mopidy.backend.PlaylistsProvider.playlists` property. + +Backend playback providers +~~~~~~~~~~~~~~~~~~~~~~~~~~ + - Changed the API for :class:`mopidy.backend.PlaybackProvider`. Note that this change is **not** backwards compatible for certain backends. These changes are crucial to adding gapless in one of the upcoming releases. @@ -133,22 +217,20 @@ v1.0.0 (UNRELEASED) - :meth:`mopidy.backend.PlaybackProvider.prepare_change` has been added. -- Changed the API for :class:`mopidy.backend.PlaylistsProvider`. Note that this - change is **not** backwards compatible. These changes are important to reduce - the Mopidy startup time. (Fixes: :issue:`1057`, PR: :issue:`1075`) +Models +------ - - Add :meth:`mopidy.backend.PlaylistsProvider.as_list`. +- Add :class:`mopidy.models.Image` model to be returned by + :meth:`mopidy.core.LibraryController.get_images`. (Part of :issue:`973`) - - Add :meth:`mopidy.backend.PlaylistsProvider.get_items`. +- Change the semantics of :attr:`mopidy.models.Track.last_modified` to be + milliseconds instead of seconds since Unix epoch, or a simple counter, + depending on the source of the track. This makes it match the semantics of + :attr:`mopidy.models.Playlist.last_modified`. (Fixes: :issue:`678`, PR: + :issue:`1036`) - - Remove :attr:`mopidy.backend.PlaylistsProvider.playlists` property. - -- Removed ``find_exact`` from :class:`mopidy.backend.LibraryProvider` and - added an ``exact`` param to :meth:`mopidy.backend.LibraryProvider.search` - to replace the old code path. Core will continue supporting backends that - have not upgraded for now. - -**Commands** +Commands +-------- - Make the ``mopidy`` command print a friendly error message if the :mod:`gobject` Python module cannot be imported. (Fixes: :issue:`836`) @@ -161,7 +243,8 @@ v1.0.0 (UNRELEASED) deps``. This make it easier to see that a user is using pip-installed Mopidy instead of APT-installed Mopidy without asking for ``which mopidy`` output. -**Configuration** +Configuration +------------- - Add support for the log level value ``all`` to the loglevels configurations. This can be used to show absolutely all log records, including those at @@ -169,7 +252,8 @@ v1.0.0 (UNRELEASED) - Add debug logging of unknown sections. (Fixes: :issue:`694`, PR: :issue:`1002`) -**Logging** +Logging +------- - Add custom log level ``TRACE`` (numerical level 5), which can be used by Mopidy and extensions to log at an even more detailed level than ``DEBUG``. @@ -177,7 +261,8 @@ v1.0.0 (UNRELEASED) - Add support for per logger color overrides. (Fixes: :issue:`808`, PR: :issue:`1005`) -**Local backend** +Local backend +------------- - Improve error logging for scanner. (Fixes: :issue:`856`, PR: :issue:`874`) @@ -204,7 +289,8 @@ v1.0.0 (UNRELEASED) - *Deprecated:* The config value :confval:`local/playlists_dir` is no longer in use and can be removed from your config. -**Local library API** +Local library API +~~~~~~~~~~~~~~~~~ - Implementors of :meth:`mopidy.local.Library.lookup` should now return a list of :class:`~mopidy.models.Track` instead of a single track, just like the @@ -217,70 +303,91 @@ v1.0.0 (UNRELEASED) - Add :meth:`mopidy.local.Library.get_images` for looking up images for local URIs. (Fixes: :issue:`1031`, PR: :issue:`1032` and :issue:`1037`) -**M3U backend** +Stream backend +-------------- -- Split the M3U playlist handling out of the local backend. See - :ref:`m3u-migration` for how to migrate your local playlists. (Fixes: +- Add support for HTTP proxies when doing initial metadata lookup for a stream. + (Fixes :issue:`390`, PR: :issue:`982`) + +- Add basic tests for the stream library provider. + +M3U backend +----------- + +- Mopidy-M3U is a new bundled backend. It is the same M3U support as was + previously part of the local backend. See :ref:`m3u-migration` for how to + migrate your local playlists to work with the M3U backend. (Fixes: :issue:`1054`, PR: :issue:`1066`) -**MPD frontend** - -- In stored playlist names, replace "/", which are illegal, with "|" instead of - a whitespace. Pipes are more similar to forward slash. - -- Enable browsing of artist references, in addition to albums and playlists. - (PR: :issue:`884`) - -- Share a single mapping between names and URIs across all MPD sessions. (Fixes: - :issue:`934`, PR: :issue:`968`) +MPD frontend +------------ - Add support for blacklisting MPD commands. This is used to prevent clients from using ``listall`` and ``listallinfo`` which recursively lookup the entire "database". If you insist on using a client that needs these commands change :confval:`mpd/command_blacklist`. -- Switch the ``list`` command over to using +- Start setting the ``Name`` field with the stream title when listening to + radio streams. (Fixes: :issue:`944`, PR: :issue:`1030`) + +- Enable browsing of artist references, in addition to albums and playlists. + (PR: :issue:`884`) + +- Switch the ``list`` command over to using the new method :meth:`mopidy.core.LibraryController.get_distinct` for increased performance. (Fixes: :issue:`913`) +- In stored playlist names, replace "/", which are illegal, with "|" instead of + a whitespace. Pipes are more similar to forward slash. + +- Share a single mapping between names and URIs across all MPD sessions. (Fixes: + :issue:`934`, PR: :issue:`968`) + - Add support for ``toggleoutput`` command. (PR: :issue:`1015`) - The ``mixrampdb`` and ``mixrampdelay`` commands are now known to Mopidy, but are not implemented. (PR: :issue:`1015`) -- Start setting the ``Name`` field with the stream title when listening to - radio streams. (Fixes: :issue:`944`, PR: :issue:`1030`) - - Fix crash on socket error when using a locale causing the exception's error message to contain characters not in ASCII. (Fixes: issue:`971`, PR: :issue:`1044`) -**HTTP frontend** +HTTP frontend +------------- - **Deprecated:** Deprecated the :confval:`http/static_dir` config. Please make your web clients pip-installable Mopidy extensions to make it easier to install for end users. -- Prevent race condition in WebSocket broadcast from breaking the web server. - (PR: :issue:`1020`) +- Prevent a race condition in WebSocket event broadcasting from crashing the + web server. (PR: :issue:`1020`) -**Mixer** +Mixers +------ - Add support for disabling volume control in Mopidy entirely by setting the configuration :confval:`audio/mixer` to ``none``. (Fixes: :issue:`936`, PR: :issue:`1015`, :issue:`1035`) -**Audio** +Audio +----- + +- **Removed:** Kill support for visualizers and the + :confval:`audio/visualizer` config value. The feature was originally added as + a workaround for all the people asking for ncmpcpp visualizer support, and + since we could get it almost for free thanks to GStreamer. But, this feature + did never make sense for a server such as Mopidy. The only way to find out if + it is in use and will be missed is to go ahead and remove it. - **Deprecated:** Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. Pass a :class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` to end - the stream. + the stream. This should only affect Mopidy-Spotify. -- Kill support for visualizers. Feature was originally added as a workaround for - all the people asking for ncmpcpp visualizer support. And since we could get - it almost for free thanks to GStreamer. But this feature didn't really ever - make sense for a server such as Mopidy. Currently the only way to find out if - it is in use and will be missed is to go ahead and remove it. +- Add :meth:`mopidy.audio.AudioListener.tags_changed`. Notifies core when new + tags are found. + +- Add :meth:`mopidy.audio.Audio.get_current_tags` for looking up the current + tags of the playing media. - Internal code cleanup within audio subsystem: @@ -294,26 +401,18 @@ v1.0.0 (UNRELEASED) - Add internal helper for converting GStreamer data types to Python. - - Move MusicBrainz coverart code out of audio and into local. - - - Reduce scope of audio scanner to just tags + duration. Mtime, uri and min - length handling are now outside of this class. + - Reduce scope of audio scanner to just find tags and duration. Modification + time, URI and minimum length handling are now outside of this class. - Update scanner to operate with milliseconds for duration. - - Update scanner to use a custom src, typefind and decodebin. This allows us - to catch playlists before we try to decode them. + - Update scanner to use a custom source, typefind and decodebin. This allows + us to detect playlists before we try to decode them. - - Refactored scanner to create a new pipeline per song, this is needed as + - Refactored scanner to create a new pipeline per track, this is needed as reseting decodebin is much slower than tearing it down and making a fresh one. -- Add :meth:`mopidy.audio.AudioListener.tags_changed`. Notifies core when new tags - are found. - -- Add :meth:`mopidy.audio.Audio.get_current_tags` for looking up the current - tags of the playing media. - - Move and rename helper for converting tags to tracks. - Ignore albums without a name when converting tags to tracks. @@ -331,14 +430,8 @@ v1.0.0 (UNRELEASED) - Added support for checking if the media is seekable, and getting the initial MIME type guess. (PR: :issue:`1033`) -**Stream backend** - -- Add basic tests for the stream library provider. - -- Add support for proxies when doing initial metadata lookup for stream. - (Fixes :issue:`390`, PR: :issue:`982`) - -**Mopidy.js client library** +Mopidy.js client library +------------------------ This version has been released to npm as Mopidy.js v0.5.0. @@ -351,7 +444,12 @@ This version has been released to npm as Mopidy.js v0.5.0. - Upgrade dependencies. -**Development** +Development +----------- + +- Add new :ref:`contribution guidelines `. + +- Add new :ref:`development guide `. - Speed up event emitting. From 426a56d66b3f68da5a4412d3f42c6d937be22651 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Mar 2015 23:02:43 +0100 Subject: [PATCH 267/314] docs: Fix changelog review comments --- docs/changelog.rst | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 47c30a72..ab7d3f87 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,7 +12,7 @@ Three months after our fifth anniversary, Mopidy 1.0 is finally here! Since the release of 0.19, we've closed or merged approximately 140 issues and pull requests through more than 600 commits by a record high 19 extraordinary -people, including seven newcomers. Thanks to everyone that has contributed! +people, including seven newcomers. Thanks to everyone who has contributed! For the longest time, the focus of Mopidy 1.0 was to be another incremental improvement, to be numbered 0.20. The result is still very much an incremental @@ -21,17 +21,20 @@ functionality. The major features of Mopidy 1.0 are: -- A promise to follow not break APIs before Mopidy 2.0. A Mopidy extension - working with Mopidy 1.0 should continue to work with all Mopidy 1.x releases. +- Semantical versioning. We promise to not break APIs before Mopidy 2.0. A + Mopidy extension working with Mopidy 1.0 should continue to work with all + Mopidy 1.x releases. -- Preparation work to enable gapless playback in the near future. +- Preparation work to ease migration to a cleaned up and leaner core API in + Mopidy 2.0, and to give us some of the benefits of the cleaned up core API + right away. -TODO: to be continued +- Preparation work to enable gapless playback in an upcoming 1.x release. Dependencies ------------ -Since the previous release there is no changes to Mopidy's dependencies. +Since the previous release there are no changes to Mopidy's dependencies. However, porting from GStreamer 0.10 to 1.x and support for running Mopidy with Python 3.4+ is not far off on our roadmap. @@ -123,7 +126,7 @@ Core tracklist controller Core playback controller ~~~~~~~~~~~~~~~~~~~~~~~~ -- **Removed:** Remove several internal parts that was leaking into the public +- **Removed:** Remove several internal parts that were leaking into the public API and was never intended to be used externally. (Fixes: :issue:`1070`, PR: :issue:`1076`) @@ -164,8 +167,8 @@ Backend API ----------- In the API implemented by all backends there have been way fewer but somewhat -more dramatic changes with some methods removed and new ones being required for -certain functionality to continue working. Most backends are already updated to +more drastic changes with some methods removed and new ones being required for +certain functionality to continue working. Most backends were already updated to be compatible with Mopidy 1.0 before the release. New versions of the backends will be released shortly after Mopidy itself. @@ -237,7 +240,7 @@ Commands - Add support for repeating the :option:`-v ` argument four times to set the log level for all loggers to the lowest possible value, including - log records at levels lover than ``DEBUG`` too. + log records at levels lower than ``DEBUG`` too. - Add path to the current ``mopidy`` executable to the output of ``mopidy deps``. This make it easier to see that a user is using pip-installed Mopidy @@ -314,7 +317,7 @@ Stream backend M3U backend ----------- -- Mopidy-M3U is a new bundled backend. It is the same M3U support as was +- Mopidy-M3U is a new bundled backend. It provides the same M3U support as was previously part of the local backend. See :ref:`m3u-migration` for how to migrate your local playlists to work with the M3U backend. (Fixes: :issue:`1054`, PR: :issue:`1066`) @@ -372,12 +375,11 @@ Mixers Audio ----- -- **Removed:** Kill support for visualizers and the - :confval:`audio/visualizer` config value. The feature was originally added as - a workaround for all the people asking for ncmpcpp visualizer support, and - since we could get it almost for free thanks to GStreamer. But, this feature - did never make sense for a server such as Mopidy. The only way to find out if - it is in use and will be missed is to go ahead and remove it. +- **Removed:** Support for visualizers and the :confval:`audio/visualizer` + config value. The feature was originally added as a workaround for all the + people asking for ncmpcpp visualizer support, and since we could get it + almost for free thanks to GStreamer. But, this feature did never make sense + for a server such as Mopidy. - **Deprecated:** Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. Pass a :class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` to end From 08c7f311c4337f8662853c776b2617fa7bcc2015 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Mar 2015 23:08:38 +0100 Subject: [PATCH 268/314] docs: Fix more comments, add refs to relevant docs --- docs/changelog.rst | 9 +++++---- docs/versioning.rst | 2 ++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index ab7d3f87..1f07203b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,7 +12,8 @@ Three months after our fifth anniversary, Mopidy 1.0 is finally here! Since the release of 0.19, we've closed or merged approximately 140 issues and pull requests through more than 600 commits by a record high 19 extraordinary -people, including seven newcomers. Thanks to everyone who has contributed! +people, including seven newcomers. Thanks to :ref:`everyone ` who has +:ref:`contributed `! For the longest time, the focus of Mopidy 1.0 was to be another incremental improvement, to be numbered 0.20. The result is still very much an incremental @@ -21,9 +22,9 @@ functionality. The major features of Mopidy 1.0 are: -- Semantical versioning. We promise to not break APIs before Mopidy 2.0. A - Mopidy extension working with Mopidy 1.0 should continue to work with all - Mopidy 1.x releases. +- :ref:`Semantic Versioning `. We promise to not break APIs before + Mopidy 2.0. A Mopidy extension working with Mopidy 1.0 should continue to + work with all Mopidy 1.x releases. - Preparation work to ease migration to a cleaned up and leaner core API in Mopidy 2.0, and to give us some of the benefits of the cleaned up core API diff --git a/docs/versioning.rst b/docs/versioning.rst index cd428366..bc93275b 100644 --- a/docs/versioning.rst +++ b/docs/versioning.rst @@ -1,3 +1,5 @@ +.. _versioning: + ********** Versioning ********** From a8e6cd26dc58e6a9a64b7d4ace9f70daa64c9e08 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Mar 2015 23:40:46 +0100 Subject: [PATCH 269/314] core: Warn if backend does not implement as_list() Fixes #1080 --- mopidy/core/playlists.py | 24 ++++++++++++++++++------ tests/core/test_playlists.py | 11 +++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index e791380f..669e1f35 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -1,6 +1,6 @@ from __future__ import absolute_import, unicode_literals -import itertools +import logging import urlparse import pykka @@ -10,6 +10,9 @@ from mopidy.models import Playlist from mopidy.utils.deprecation import deprecated_property +logger = logging.getLogger(__name__) + + class PlaylistsController(object): pykka_traversable = True @@ -29,11 +32,20 @@ class PlaylistsController(object): .. versionadded:: 1.0 """ - futures = [ - b.playlists.as_list() - for b in self.backends.with_playlists.values()] - results = pykka.get_all(futures) - return list(itertools.chain(*results)) + futures = { + b.actor_ref.actor_class.__name__: b.playlists.as_list() + for b in set(self.backends.with_playlists.values())} + + results = [] + for backend_name, future in futures.items(): + try: + results.extend(future.get()) + except NotImplementedError: + logger.warning( + '%s does not implement playlists.as_list(). ' + 'Please upgrade it.', backend_name) + + return results def get_items(self, uri): """ diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index fecbbdcb..081f73e6 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -31,10 +31,12 @@ class PlaylistsTest(unittest.TestCase): self.sp2.lookup.return_value.get.side_effect = [self.pl2a, self.pl2b] self.backend1 = mock.Mock() + self.backend1.actor_ref.actor_class.__name__ = 'Backend1' self.backend1.uri_schemes.get.return_value = ['dummy1'] self.backend1.playlists = self.sp1 self.backend2 = mock.Mock() + self.backend2.actor_ref.actor_class.__name__ = 'Backend2' self.backend2.uri_schemes.get.return_value = ['dummy2'] self.backend2.playlists = self.sp2 @@ -55,6 +57,15 @@ class PlaylistsTest(unittest.TestCase): self.assertIn(self.plr2a, result) self.assertIn(self.plr2b, result) + def test_as_list_ignores_backends_that_dont_support_it(self): + self.sp2.as_list.return_value.get.side_effect = NotImplementedError + + result = self.core.playlists.as_list() + + self.assertEqual(len(result), 2) + self.assertIn(self.plr1a, result) + self.assertIn(self.plr1b, result) + def test_get_items_selects_the_matching_backend(self): ref = Ref.track() self.sp2.get_items.return_value.get.return_value = [ref] From ead725e9952670b18559f89e66d91c3de373dee2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 24 Mar 2015 23:54:49 +0100 Subject: [PATCH 270/314] core/backend: Stop supporting old search signatures All backends are expected to support the exact argument. A friendly log message will be printed to prompt users to upgrade backends that fail due to this. --- mopidy/core/library.py | 30 ++++++++----------- mopidy/local/library.py | 9 ++---- tests/core/test_library.py | 61 +++++++++++++++++--------------------- tests/dummy_backend.py | 7 ++--- 4 files changed, 45 insertions(+), 62 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 44375f58..16e33d33 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -242,25 +242,21 @@ class LibraryController(object): The ``exact`` keyword argument, which replaces :meth:`find_exact`. """ query = _normalize_query(query or kwargs) - futures = [] + futures = {} for backend, backend_uris in self._get_backends_to_uris(uris).items(): - if hasattr(backend.library, 'find_exact'): - # Backends with find_exact probably don't have support for - # search with the exact kwarg, so give them the legacy calls. - if exact: - futures.append(backend.library.find_exact( - query=query, uris=backend_uris)) - else: - futures.append(backend.library.search( - query=query, uris=backend_uris)) - else: - # Assume backends without find_exact are up to date. Worst case - # the exact gets swallowed by the **kwargs and things hopefully - # still work. - futures.append(backend.library.search( - query=query, uris=backend_uris, exact=exact)) + futures[backend] = backend.library.search( + query=query, uris=backend_uris, exact=exact) - return [result for result in pykka.get_all(futures) if result] + results = [] + for backend, future in futures.items(): + try: + results.append(future.get()) + except TypeError: + backend_name = backend.actor_ref.actor_class.__name__ + logger.warning( + '%s does not implement library.search() with exact ' + 'support. Please upgrade it.', backend_name) + return [r for r in results if r] def _normalize_query(query): diff --git a/mopidy/local/library.py b/mopidy/local/library.py index 77c122bd..5e98964c 100644 --- a/mopidy/local/library.py +++ b/mopidy/local/library.py @@ -51,12 +51,7 @@ class LocalLibraryProvider(backend.LibraryProvider): tracks = [tracks] return tracks - def find_exact(self, query=None, uris=None): + def search(self, query=None, uris=None, exact=False): if not self._library: return None - return self._library.search(query=query, uris=uris, exact=True) - - def search(self, query=None, uris=None): - if not self._library: - return None - return self._library.search(query=query, uris=uris, exact=False) + return self._library.search(query=query, uris=uris, exact=exact) diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 50eb834f..51313daa 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -361,42 +361,35 @@ class CoreLibraryTest(unittest.TestCase): query={'any': ['foobar']}, uris=None, exact=True) -class LegacyLibraryProvider(backend.LibraryProvider): - def find_exact(self, query=None, uris=None): - pass +class LegacyFindExactToSearchLibraryTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.backend = mock.Mock() + self.backend.actor_ref.actor_class.__name__ = 'DummyBackend' + self.backend.uri_schemes.get.return_value = ['dummy'] + self.backend.library = mock.Mock(spec=backend.LibraryProvider) + self.core = core.Core(mixer=None, backends=[self.backend]) - -class LegacyCoreLibraryTest(unittest.TestCase): - def test_backend_with_find_exact_gets_find_exact_call(self): - b1 = mock.Mock() - b1.uri_schemes.get.return_value = ['dummy1'] - b1.library = mock.Mock(spec=LegacyLibraryProvider) - - b2 = mock.Mock() - b2.uri_schemes.get.return_value = ['dummy2'] - b2.library = mock.Mock(spec=backend.LibraryProvider) - - c = core.Core(mixer=None, backends=[b1, b2]) - c.library.find_exact(query={'any': ['a']}) - - b1.library.find_exact.assert_called_once_with( - query=dict(any=['a']), uris=None) - b2.library.search.assert_called_once_with( + def test_core_find_exact_calls_backend_search_with_exact(self): + self.core.library.find_exact(query={'any': ['a']}) + self.backend.library.search.assert_called_once_with( query=dict(any=['a']), uris=None, exact=True) - def test_backend_with_find_exact_gets_search_without_exact_arg(self): - b1 = mock.Mock() - b1.uri_schemes.get.return_value = ['dummy1'] - b1.library = mock.Mock(spec=LegacyLibraryProvider) + def test_core_find_exact_handles_legacy_backend(self): + self.backend.library.search.return_value.get.side_effect = TypeError + self.core.library.find_exact(query={'any': ['a']}) + # We are just testing that this doesn't fail. - b2 = mock.Mock() - b2.uri_schemes.get.return_value = ['dummy2'] - b2.library = mock.Mock(spec=backend.LibraryProvider) - - c = core.Core(mixer=None, backends=[b1, b2]) - c.library.search(query={'any': ['a']}) - - b1.library.search.assert_called_once_with( - query=dict(any=['a']), uris=None) - b2.library.search.assert_called_once_with( + def test_core_search_call_backend_search_with_exact(self): + self.core.library.search(query={'any': ['a']}) + self.backend.library.search.assert_called_once_with( query=dict(any=['a']), uris=None, exact=False) + + def test_core_search_with_exact_call_backend_search_with_exact(self): + self.core.library.search(query={'any': ['a']}, exact=True) + self.backend.library.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) + + def test_core_search_with_handles_legacy_backend(self): + self.backend.library.search.return_value.get.side_effect = TypeError + self.core.library.search(query={'any': ['a']}, exact=True) + # We are just testing that this doesn't fail. diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index babaf0de..99031ee1 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -46,16 +46,15 @@ class DummyLibraryProvider(backend.LibraryProvider): def get_distinct(self, field, query=None): return self.dummy_get_distinct_result.get(field, set()) - def find_exact(self, **query): - return self.dummy_find_exact_result - def lookup(self, uri): return [t for t in self.dummy_library if uri == t.uri] def refresh(self, uri=None): pass - def search(self, **query): + def search(self, query=None, uris=None, exact=False): + if exact: # TODO: remove uses of dummy_find_exact_result + return self.dummy_find_exact_result return self.dummy_search_result From f2a56edbf0ae45d4adb73f459fb4ae25a5df3c25 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 25 Mar 2015 00:03:48 +0100 Subject: [PATCH 271/314] dummy: Replace playlists property with test-only helper --- tests/dummy_backend.py | 24 ++++----- tests/mpd/protocol/test_music_db.py | 16 +++--- tests/mpd/protocol/test_stored_playlists.py | 54 ++++++++++----------- 3 files changed, 45 insertions(+), 49 deletions(-) diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index babaf0de..f2867b7b 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -100,6 +100,10 @@ class DummyPlaylistsProvider(backend.PlaylistsProvider): super(DummyPlaylistsProvider, self).__init__(backend) self._playlists = [] + def set_playlists(self, playlists): + """For tests using the dummy provider through an actor proxy.""" + self._playlists = playlists + def as_list(self): return [ Ref.playlist(uri=pl.uri, name=pl.name) for pl in self._playlists] @@ -111,13 +115,13 @@ class DummyPlaylistsProvider(backend.PlaylistsProvider): return [ Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] - @property - def playlists(self): - return copy.copy(self._playlists) + def lookup(self, uri): + for playlist in self._playlists: + if playlist.uri == uri: + return playlist - @playlists.setter - def playlists(self, playlists): - self._playlists = playlists + def refresh(self): + pass def create(self, name): playlist = Playlist(name=name, uri='dummy:%s' % name) @@ -129,14 +133,6 @@ class DummyPlaylistsProvider(backend.PlaylistsProvider): if playlist: self._playlists.remove(playlist) - def lookup(self, uri): - for playlist in self._playlists: - if playlist.uri == uri: - return playlist - - def refresh(self): - pass - def save(self, playlist): old_playlist = self.lookup(playlist.uri) diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index 613467ed..37cbfce0 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -277,8 +277,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_without_path_returns_same_as_for_root(self): last_modified = 1390942873222 - self.backend.playlists.playlists = [ - Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] + self.backend.playlists.set_playlists([ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) response1 = self.send_request('lsinfo') response2 = self.send_request('lsinfo "/"') @@ -286,8 +286,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_with_empty_path_returns_same_as_for_root(self): last_modified = 1390942873222 - self.backend.playlists.playlists = [ - Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] + self.backend.playlists.set_playlists([ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) response1 = self.send_request('lsinfo ""') response2 = self.send_request('lsinfo "/"') @@ -295,8 +295,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_for_root_includes_playlists(self): last_modified = 1390942873222 - self.backend.playlists.playlists = [ - Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] + self.backend.playlists.set_playlists([ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) self.send_request('lsinfo "/"') self.assertInResponse('playlist: a') @@ -384,8 +384,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} - self.backend.playlists.playlists = [ - Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] + self.backend.playlists.set_playlists([ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) response = self.send_request('lsinfo "/"') self.assertLess(response.index('directory: dummy'), diff --git a/tests/mpd/protocol/test_stored_playlists.py b/tests/mpd/protocol/test_stored_playlists.py index a9190aa1..39d0d1b0 100644 --- a/tests/mpd/protocol/test_stored_playlists.py +++ b/tests/mpd/protocol/test_stored_playlists.py @@ -7,18 +7,18 @@ from tests.mpd import protocol class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist(self): - self.backend.playlists.playlists = [ + self.backend.playlists.set_playlists([ Playlist( - name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])] + name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) self.send_request('listplaylist "name"') self.assertInResponse('file: dummy:a') self.assertInResponse('OK') def test_listplaylist_without_quotes(self): - self.backend.playlists.playlists = [ + self.backend.playlists.set_playlists([ Playlist( - name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])] + name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) self.send_request('listplaylist name') self.assertInResponse('file: dummy:a') @@ -31,16 +31,16 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist_duplicate(self): playlist1 = Playlist(name='a', uri='dummy:a1', tracks=[Track(uri='b')]) playlist2 = Playlist(name='a', uri='dummy:a2', tracks=[Track(uri='c')]) - self.backend.playlists.playlists = [playlist1, playlist2] + self.backend.playlists.set_playlists([playlist1, playlist2]) self.send_request('listplaylist "a [2]"') self.assertInResponse('file: c') self.assertInResponse('OK') def test_listplaylistinfo(self): - self.backend.playlists.playlists = [ + self.backend.playlists.set_playlists([ Playlist( - name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])] + name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) self.send_request('listplaylistinfo "name"') self.assertInResponse('file: dummy:a') @@ -49,9 +49,9 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listplaylistinfo_without_quotes(self): - self.backend.playlists.playlists = [ + self.backend.playlists.set_playlists([ Playlist( - name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])] + name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) self.send_request('listplaylistinfo name') self.assertInResponse('file: dummy:a') @@ -67,7 +67,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylistinfo_duplicate(self): playlist1 = Playlist(name='a', uri='dummy:a1', tracks=[Track(uri='b')]) playlist2 = Playlist(name='a', uri='dummy:a2', tracks=[Track(uri='c')]) - self.backend.playlists.playlists = [playlist1, playlist2] + self.backend.playlists.set_playlists([playlist1, playlist2]) self.send_request('listplaylistinfo "a [2]"') self.assertInResponse('file: c') @@ -77,8 +77,8 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists(self): last_modified = 1390942873222 - self.backend.playlists.playlists = [ - Playlist(name='a', uri='dummy:a', last_modified=last_modified)] + self.backend.playlists.set_playlists([ + Playlist(name='a', uri='dummy:a', last_modified=last_modified)]) self.send_request('listplaylists') self.assertInResponse('playlist: a') @@ -89,7 +89,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists_duplicate(self): playlist1 = Playlist(name='a', uri='dummy:a1') playlist2 = Playlist(name='a', uri='dummy:a2') - self.backend.playlists.playlists = [playlist1, playlist2] + self.backend.playlists.set_playlists([playlist1, playlist2]) self.send_request('listplaylists') self.assertInResponse('playlist: a') @@ -98,32 +98,32 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists_ignores_playlists_without_name(self): last_modified = 1390942873222 - self.backend.playlists.playlists = [ - Playlist(name='', uri='dummy:', last_modified=last_modified)] + self.backend.playlists.set_playlists([ + Playlist(name='', uri='dummy:', last_modified=last_modified)]) self.send_request('listplaylists') self.assertNotInResponse('playlist: ') self.assertInResponse('OK') def test_listplaylists_replaces_newline_with_space(self): - self.backend.playlists.playlists = [ - Playlist(name='a\n', uri='dummy:')] + self.backend.playlists.set_playlists([ + Playlist(name='a\n', uri='dummy:')]) self.send_request('listplaylists') self.assertInResponse('playlist: a ') self.assertNotInResponse('playlist: a\n') self.assertInResponse('OK') def test_listplaylists_replaces_carriage_return_with_space(self): - self.backend.playlists.playlists = [ - Playlist(name='a\r', uri='dummy:')] + self.backend.playlists.set_playlists([ + Playlist(name='a\r', uri='dummy:')]) self.send_request('listplaylists') self.assertInResponse('playlist: a ') self.assertNotInResponse('playlist: a\r') self.assertInResponse('OK') def test_listplaylists_replaces_forward_slash_with_pipe(self): - self.backend.playlists.playlists = [ - Playlist(name='a/b', uri='dummy:')] + self.backend.playlists.set_playlists([ + Playlist(name='a/b', uri='dummy:')]) self.send_request('listplaylists') self.assertInResponse('playlist: a|b') self.assertNotInResponse('playlist: a/b') @@ -132,9 +132,9 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_load_appends_to_tracklist(self): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.backend.playlists.playlists = [ + self.backend.playlists.set_playlists([ Playlist(name='A-list', uri='dummy:A-list', tracks=[ - Track(uri='c'), Track(uri='d'), Track(uri='e')])] + Track(uri='c'), Track(uri='d'), Track(uri='e')])]) self.send_request('load "A-list"') @@ -150,9 +150,9 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_load_with_range_loads_part_of_playlist(self): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.backend.playlists.playlists = [ + self.backend.playlists.set_playlists([ Playlist(name='A-list', uri='dummy:A-list', tracks=[ - Track(uri='c'), Track(uri='d'), Track(uri='e')])] + Track(uri='c'), Track(uri='d'), Track(uri='e')])]) self.send_request('load "A-list" "1:2"') @@ -166,9 +166,9 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_load_with_range_without_end_loads_rest_of_playlist(self): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.backend.playlists.playlists = [ + self.backend.playlists.set_playlists([ Playlist(name='A-list', uri='dummy:A-list', tracks=[ - Track(uri='c'), Track(uri='d'), Track(uri='e')])] + Track(uri='c'), Track(uri='d'), Track(uri='e')])]) self.send_request('load "A-list" "1:"') From 394081ae273d0628748c113fea1745b6dfe93d2e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 25 Mar 2015 00:40:59 +0100 Subject: [PATCH 272/314] core: Add quotes around 'exact' in warning --- mopidy/core/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 16e33d33..89a2037a 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -254,7 +254,7 @@ class LibraryController(object): except TypeError: backend_name = backend.actor_ref.actor_class.__name__ logger.warning( - '%s does not implement library.search() with exact ' + '%s does not implement library.search() with "exact" ' 'support. Please upgrade it.', backend_name) return [r for r in results if r] From a9393c38509840ab8431b9a34d741429413de468 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Wed, 25 Mar 2015 05:36:03 +0100 Subject: [PATCH 273/314] m3u: Replace slashes in playlist names with pipes. --- mopidy/m3u/playlists.py | 9 ++++++++- tests/m3u/test_playlists.py | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/mopidy/m3u/playlists.py b/mopidy/m3u/playlists.py index 1fc5b4c3..c09eccdf 100644 --- a/mopidy/m3u/playlists.py +++ b/mopidy/m3u/playlists.py @@ -4,6 +4,7 @@ import glob import logging import operator import os +import re import sys from mopidy import backend @@ -15,6 +16,10 @@ logger = logging.getLogger(__name__) class M3UPlaylistsProvider(backend.PlaylistsProvider): + + # TODO: currently this only handles UNIX file systems + _invalid_filename_chars = re.compile(r'[/]') + def __init__(self, *args, **kwargs): super(M3UPlaylistsProvider, self).__init__(*args, **kwargs) @@ -89,8 +94,10 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): file_handle.write('#EXTINF:' + str(runtime) + ',' + title + '\n') def _sanitize_m3u_name(self, name, encoding=sys.getfilesystemencoding()): + name = self._invalid_filename_chars.sub('|', name.strip()) + # make sure we end up with a valid path segment name = name.encode(encoding, errors='replace') - name = os.path.basename(name) + name = os.path.basename(name) # paranoia? name = name.decode(encoding) return name diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index 07ffc0a3..355aabf5 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -50,8 +50,8 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.assertTrue(os.path.exists(path)) def test_create_sanitizes_playlist_name(self): - playlist = self.core.playlists.create('../../test FOO baR') - self.assertEqual('test FOO baR', playlist.name) + playlist = self.core.playlists.create(' ../../test FOO baR ') + self.assertEqual('..|..|test FOO baR', playlist.name) path = playlist_uri_to_path(playlist.uri, self.playlists_dir) self.assertEqual(self.playlists_dir, os.path.dirname(path)) self.assertTrue(os.path.exists(path)) From 75020c91ec17943069f9322a39617cdd27effd2b Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Wed, 25 Mar 2015 05:46:55 +0100 Subject: [PATCH 274/314] docs: Add PR #1084 to changelog --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1f07203b..1e3e2d66 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -323,6 +323,9 @@ M3U backend migrate your local playlists to work with the M3U backend. (Fixes: :issue:`1054`, PR: :issue:`1066`) +- In playlist names, replace "/", which are illegal in M3U file names, + with "|". (PR: :issue:`1084`) + MPD frontend ------------ From 36fba3d67dd9b09bfa80adffe1d5f3c91504a367 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 25 Mar 2015 09:48:24 +0100 Subject: [PATCH 275/314] flake8: Fix unussed import --- tests/dummy_backend.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index acd081a0..c3c88c87 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -6,8 +6,6 @@ used in tests of the frontends. from __future__ import absolute_import, unicode_literals -import copy - import pykka from mopidy import backend From 2c11344434a8e5969867738d8d545d94f47e543c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 25 Mar 2015 13:14:51 +0100 Subject: [PATCH 276/314] dummy: Make it obvious that method is test-only --- tests/dummy_backend.py | 2 +- tests/mpd/protocol/test_music_db.py | 8 +++--- tests/mpd/protocol/test_stored_playlists.py | 30 ++++++++++----------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index c3c88c87..61c26c5f 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -97,7 +97,7 @@ class DummyPlaylistsProvider(backend.PlaylistsProvider): super(DummyPlaylistsProvider, self).__init__(backend) self._playlists = [] - def set_playlists(self, playlists): + def set_dummy_playlists(self, playlists): """For tests using the dummy provider through an actor proxy.""" self._playlists = playlists diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index 37cbfce0..b9fbcdf6 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -277,7 +277,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_without_path_returns_same_as_for_root(self): last_modified = 1390942873222 - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) response1 = self.send_request('lsinfo') @@ -286,7 +286,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_with_empty_path_returns_same_as_for_root(self): last_modified = 1390942873222 - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) response1 = self.send_request('lsinfo ""') @@ -295,7 +295,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_for_root_includes_playlists(self): last_modified = 1390942873222 - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) self.send_request('lsinfo "/"') @@ -384,7 +384,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.backend.library.dummy_browse_result = { 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), Ref.directory(uri='dummy:/foo', name='foo')]} - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='a', uri='dummy:/a', last_modified=last_modified)]) response = self.send_request('lsinfo "/"') diff --git a/tests/mpd/protocol/test_stored_playlists.py b/tests/mpd/protocol/test_stored_playlists.py index 39d0d1b0..cca32b0d 100644 --- a/tests/mpd/protocol/test_stored_playlists.py +++ b/tests/mpd/protocol/test_stored_playlists.py @@ -7,7 +7,7 @@ from tests.mpd import protocol class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist(self): - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist( name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) @@ -16,7 +16,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listplaylist_without_quotes(self): - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist( name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) @@ -31,14 +31,14 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist_duplicate(self): playlist1 = Playlist(name='a', uri='dummy:a1', tracks=[Track(uri='b')]) playlist2 = Playlist(name='a', uri='dummy:a2', tracks=[Track(uri='c')]) - self.backend.playlists.set_playlists([playlist1, playlist2]) + self.backend.playlists.set_dummy_playlists([playlist1, playlist2]) self.send_request('listplaylist "a [2]"') self.assertInResponse('file: c') self.assertInResponse('OK') def test_listplaylistinfo(self): - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist( name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) @@ -49,7 +49,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listplaylistinfo_without_quotes(self): - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist( name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) @@ -67,7 +67,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylistinfo_duplicate(self): playlist1 = Playlist(name='a', uri='dummy:a1', tracks=[Track(uri='b')]) playlist2 = Playlist(name='a', uri='dummy:a2', tracks=[Track(uri='c')]) - self.backend.playlists.set_playlists([playlist1, playlist2]) + self.backend.playlists.set_dummy_playlists([playlist1, playlist2]) self.send_request('listplaylistinfo "a [2]"') self.assertInResponse('file: c') @@ -77,7 +77,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists(self): last_modified = 1390942873222 - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='a', uri='dummy:a', last_modified=last_modified)]) self.send_request('listplaylists') @@ -89,7 +89,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists_duplicate(self): playlist1 = Playlist(name='a', uri='dummy:a1') playlist2 = Playlist(name='a', uri='dummy:a2') - self.backend.playlists.set_playlists([playlist1, playlist2]) + self.backend.playlists.set_dummy_playlists([playlist1, playlist2]) self.send_request('listplaylists') self.assertInResponse('playlist: a') @@ -98,7 +98,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists_ignores_playlists_without_name(self): last_modified = 1390942873222 - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='', uri='dummy:', last_modified=last_modified)]) self.send_request('listplaylists') @@ -106,7 +106,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listplaylists_replaces_newline_with_space(self): - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='a\n', uri='dummy:')]) self.send_request('listplaylists') self.assertInResponse('playlist: a ') @@ -114,7 +114,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listplaylists_replaces_carriage_return_with_space(self): - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='a\r', uri='dummy:')]) self.send_request('listplaylists') self.assertInResponse('playlist: a ') @@ -122,7 +122,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listplaylists_replaces_forward_slash_with_pipe(self): - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='a/b', uri='dummy:')]) self.send_request('listplaylists') self.assertInResponse('playlist: a|b') @@ -132,7 +132,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_load_appends_to_tracklist(self): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='A-list', uri='dummy:A-list', tracks=[ Track(uri='c'), Track(uri='d'), Track(uri='e')])]) @@ -150,7 +150,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_load_with_range_loads_part_of_playlist(self): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='A-list', uri='dummy:A-list', tracks=[ Track(uri='c'), Track(uri='d'), Track(uri='e')])]) @@ -166,7 +166,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_load_with_range_without_end_loads_rest_of_playlist(self): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.backend.playlists.set_playlists([ + self.backend.playlists.set_dummy_playlists([ Playlist(name='A-list', uri='dummy:A-list', tracks=[ Track(uri='c'), Track(uri='d'), Track(uri='e')])]) From 4176557efcd83438397bf41b1db4c6e171a73b75 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 25 Mar 2015 22:24:28 +0100 Subject: [PATCH 277/314] docs: Add release date for v1.0.0 --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1e3e2d66..b976c169 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,7 +5,7 @@ Changelog This changelog is used to track all major changes to Mopidy. -v1.0.0 (UNRELEASED) +v1.0.0 (2015-03-25) =================== Three months after our fifth anniversary, Mopidy 1.0 is finally here! From 7e66b719ea2542a067a6108a1a15a9bc9d09c048 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 26 Mar 2015 21:54:23 +0100 Subject: [PATCH 278/314] audio: pipeline.add_many() is deprecated --- mopidy/audio/scan.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 3880d91a..4234e748 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -73,7 +73,8 @@ def _setup_pipeline(uri, proxy_config=None): sink = gst.element_factory_make('fakesink') pipeline = gst.element_factory_make('pipeline') - pipeline.add_many(src, typefind, decodebin, sink) + for e in (src, typefind, decodebin, sink): + pipeline.add(e) gst.element_link_many(src, typefind, decodebin) if proxy_config: From b9d7ea37bee64aec523eb6eaca53e6f3107439fa Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 26 Mar 2015 21:54:46 +0100 Subject: [PATCH 279/314] commands: Exception.message is deprecated --- mopidy/commands.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/commands.py b/mopidy/commands.py index ebb2c891..dd91f5de 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -38,7 +38,8 @@ def config_override_type(value): class _ParserError(Exception): - pass + def __init__(self, message): + self.message = message class _HelpError(Exception): From b31f0c421f50dd9ea619c2d6c751646b05a1381f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 26 Mar 2015 21:58:44 +0100 Subject: [PATCH 280/314] tests: Make tests warning safe --- tests/core/test_events.py | 6 +++++- tests/mpd/protocol/__init__.py | 8 ++++++-- tests/mpd/protocol/test_current_playlist.py | 7 ++++++- tests/mpd/protocol/test_playback.py | 15 +++++++++------ tests/mpd/test_dispatcher.py | 6 +++++- tests/mpd/test_exceptions.py | 9 --------- tests/mpd/test_status.py | 9 +++++++-- tests/utils/test_jsonrpc.py | 6 +++++- 8 files changed, 43 insertions(+), 23 deletions(-) diff --git a/tests/core/test_events.py b/tests/core/test_events.py index 942f9b5f..a197972b 100644 --- a/tests/core/test_events.py +++ b/tests/core/test_events.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, unicode_literals import unittest +import warnings import mock @@ -16,7 +17,10 @@ from tests import dummy_backend class BackendEventsTest(unittest.TestCase): def setUp(self): # noqa: N802 self.backend = dummy_backend.create_proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() + + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + self.core = core.Core.start(backends=[self.backend]).proxy() def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index 88e3567b..cbbc1991 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, unicode_literals import unittest +import warnings import mock @@ -40,8 +41,11 @@ class BaseTestCase(unittest.TestCase): else: self.mixer = None self.backend = dummy_backend.create_proxy() - self.core = core.Core.start( - mixer=self.mixer, backends=[self.backend]).proxy() + + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + self.core = core.Core.start( + mixer=self.mixer, backends=[self.backend]).proxy() self.uri_map = uri_mapper.MpdUriMapper(self.core) self.connection = MockConnection() diff --git a/tests/mpd/protocol/test_current_playlist.py b/tests/mpd/protocol/test_current_playlist.py index d6fdce8e..c96febb7 100644 --- a/tests/mpd/protocol/test_current_playlist.py +++ b/tests/mpd/protocol/test_current_playlist.py @@ -1,5 +1,7 @@ from __future__ import absolute_import, unicode_literals +import warnings + from mopidy.models import Ref, Track from tests.mpd import protocol @@ -247,7 +249,10 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): 'ACK [50@0] {moveid} No such song') def test_playlist_returns_same_as_playlistinfo(self): - playlist_response = self.send_request('playlist') + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', message='.*playlistinfo.*') + playlist_response = self.send_request('playlist') + playlistinfo_response = self.send_request('playlistinfo') self.assertEqual(playlist_response, playlistinfo_response) diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index 22527e1e..8bac48cc 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, unicode_literals import unittest +import warnings from mopidy.core import PlaybackState from mopidy.models import Track @@ -200,13 +201,15 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') - self.send_request('pause') - self.assertEqual(PAUSED, self.core.playback.state.get()) - self.assertInResponse('OK') + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', message='.*pause command w/o.*') + self.send_request('pause') + self.assertEqual(PAUSED, self.core.playback.state.get()) + self.assertInResponse('OK') - self.send_request('pause') - self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertInResponse('OK') + self.send_request('pause') + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertInResponse('OK') def test_play_without_pos(self): self.core.tracklist.add([Track(uri='dummy:a')]) diff --git a/tests/mpd/test_dispatcher.py b/tests/mpd/test_dispatcher.py index d6b11e43..2c21df67 100644 --- a/tests/mpd/test_dispatcher.py +++ b/tests/mpd/test_dispatcher.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, unicode_literals import unittest +import warnings import pykka @@ -20,9 +21,12 @@ class MpdDispatcherTest(unittest.TestCase): } } self.backend = dummy_backend.create_proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() self.dispatcher = MpdDispatcher(config=config) + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + self.core = core.Core.start(backends=[self.backend]).proxy() + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() diff --git a/tests/mpd/test_exceptions.py b/tests/mpd/test_exceptions.py index 7bb64096..123bae5d 100644 --- a/tests/mpd/test_exceptions.py +++ b/tests/mpd/test_exceptions.py @@ -8,15 +8,6 @@ from mopidy.mpd.exceptions import ( class MpdExceptionsTest(unittest.TestCase): - def test_key_error_wrapped_in_mpd_ack_error(self): - try: - try: - raise KeyError('Track X not found') - except KeyError as e: - raise MpdAckError(e.message) - except MpdAckError as e: - self.assertEqual(e.message, 'Track X not found') - def test_mpd_not_implemented_is_a_mpd_ack_error(self): try: raise MpdNotImplemented diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index e130353b..89030651 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, unicode_literals import unittest +import warnings import pykka @@ -25,8 +26,12 @@ class StatusHandlerTest(unittest.TestCase): def setUp(self): # noqa: N802 self.mixer = dummy_mixer.create_proxy() self.backend = dummy_backend.create_proxy() - self.core = core.Core.start( - mixer=self.mixer, backends=[self.backend]).proxy() + + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + self.core = core.Core.start( + mixer=self.mixer, backends=[self.backend]).proxy() + self.dispatcher = dispatcher.MpdDispatcher(core=self.core) self.context = self.dispatcher.context diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index fb59d06b..890c2aba 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals import json import unittest +import warnings import mock @@ -52,9 +53,12 @@ class Calculator(object): class JsonRpcTestBase(unittest.TestCase): def setUp(self): # noqa: N802 self.backend = dummy_backend.create_proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() self.calc = Calculator() + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + self.core = core.Core.start(backends=[self.backend]).proxy() + self.jrw = jsonrpc.JsonRpcWrapper( objects={ 'hello': lambda: 'Hello, world!', From 5a3fb64250accca6dddb2dbcf480881b28e253f5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 26 Mar 2015 22:45:22 +0100 Subject: [PATCH 281/314] core: Emit deprecation warning for library.find_exact --- mopidy/core/library.py | 2 + mopidy/mpd/protocol/music_db.py | 6 +- tests/core/test_library.py | 164 ++++++++++++++++++-------------- tests/local/test_library.py | 130 +++++++++++++------------ 4 files changed, 166 insertions(+), 136 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 89a2037a..9aaa386e 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -4,6 +4,7 @@ import collections import logging import operator import urlparse +import warnings import pykka @@ -132,6 +133,7 @@ class LibraryController(object): .. deprecated:: 1.0 Use :meth:`search` with ``exact`` set. """ + warnings.warn('library.find_exact() is deprecated', DeprecationWarning) return self.search(query=query, uris=uris, exact=True, **kwargs) def lookup(self, uri=None, uris=None): diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index a942abf5..59a1f9b6 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -100,7 +100,7 @@ def count(context, *args): query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: raise exceptions.MpdArgError('incorrect arguments') - results = context.core.library.find_exact(**query).get() + results = context.core.library.search(query=query, exact=True).get() result_tracks = _get_tracks(results) return [ ('songs', len(result_tracks)), @@ -141,7 +141,7 @@ def find(context, *args): except ValueError: return - results = context.core.library.find_exact(**query).get() + results = context.core.library.search(query=query, exact=True).get() result_tracks = [] if ('artist' not in query and 'albumartist' not in query and @@ -168,7 +168,7 @@ def findadd(context, *args): query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return - results = context.core.library.find_exact(**query).get() + results = context.core.library.search(query=query, exact=True).get() context.core.tracklist.add(_get_tracks(results)) diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 51313daa..4a96042d 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, unicode_literals import unittest +import warnings import mock @@ -206,75 +207,6 @@ class CoreLibraryTest(unittest.TestCase): self.library1.refresh.assert_called_once_with(None) self.library2.refresh.assert_called_twice_with(None) - def test_find_exact_combines_results_from_all_backends(self): - track1 = Track(uri='dummy1:a') - track2 = Track(uri='dummy2:a') - result1 = SearchResult(tracks=[track1]) - result2 = SearchResult(tracks=[track2]) - - self.library1.search.return_value.get.return_value = result1 - self.library2.search.return_value.get.return_value = result2 - - result = self.core.library.find_exact(any=['a']) - - self.assertIn(result1, result) - self.assertIn(result2, result) - self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=True) - self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=True) - - def test_find_exact_with_uris_selects_dummy1_backend(self): - self.core.library.find_exact( - any=['a'], uris=['dummy1:', 'dummy1:foo', 'dummy3:']) - - self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], exact=True) - self.assertFalse(self.library2.search.called) - - def test_find_exact_with_uris_selects_both_backends(self): - self.core.library.find_exact( - any=['a'], uris=['dummy1:', 'dummy1:foo', 'dummy2:']) - - self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], exact=True) - self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy2:'], exact=True) - - def test_find_exact_filters_out_none(self): - track1 = Track(uri='dummy1:a') - result1 = SearchResult(tracks=[track1]) - - self.library1.search.return_value.get.return_value = result1 - self.library2.search.return_value.get.return_value = None - - result = self.core.library.find_exact(any=['a']) - - self.assertIn(result1, result) - self.assertNotIn(None, result) - self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=True) - self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=True) - - def test_find_accepts_query_dict_instead_of_kwargs(self): - track1 = Track(uri='dummy1:a') - track2 = Track(uri='dummy2:a') - result1 = SearchResult(tracks=[track1]) - result2 = SearchResult(tracks=[track2]) - - self.library1.search.return_value.get.return_value = result1 - self.library2.search.return_value.get.return_value = result2 - - result = self.core.library.find_exact(dict(any=['a'])) - - self.assertIn(result1, result) - self.assertIn(result2, result) - self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=True) - self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=True) - def test_search_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') @@ -355,8 +287,90 @@ class CoreLibraryTest(unittest.TestCase): self.library1.search.assert_called_once_with( query={'any': ['foobar']}, uris=None, exact=False) + +class DeprecatedCoreLibraryTest(CoreLibraryTest): + def setUp(self): # noqa: N802 + super(DeprecatedCoreLibraryTest, self).setUp() + self._warnings_filters = warnings.filters + warnings.filters = warnings.filters[:] + warnings.filterwarnings('ignore', '.*library.find_exact.*') + + def tearDown(self): # noqa: N802 + super(DeprecatedCoreLibraryTest, self).tearDown() + warnings.filters = self._warnings_filters + + def test_find_exact_combines_results_from_all_backends(self): + track1 = Track(uri='dummy1:a') + track2 = Track(uri='dummy2:a') + result1 = SearchResult(tracks=[track1]) + result2 = SearchResult(tracks=[track2]) + + self.library1.search.return_value.get.return_value = result1 + self.library2.search.return_value.get.return_value = result2 + + result = self.core.library.find_exact(any=['a']) + + self.assertIn(result1, result) + self.assertIn(result2, result) + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) + self.library2.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) + + def test_find_exact_with_uris_selects_dummy1_backend(self): + self.core.library.find_exact( + any=['a'], uris=['dummy1:', 'dummy1:foo', 'dummy3:']) + + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], exact=True) + self.assertFalse(self.library2.search.called) + + def test_find_exact_with_uris_selects_both_backends(self): + self.core.library.find_exact( + any=['a'], uris=['dummy1:', 'dummy1:foo', 'dummy2:']) + + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], exact=True) + self.library2.search.assert_called_once_with( + query=dict(any=['a']), uris=['dummy2:'], exact=True) + + def test_find_exact_filters_out_none(self): + track1 = Track(uri='dummy1:a') + result1 = SearchResult(tracks=[track1]) + + self.library1.search.return_value.get.return_value = result1 + self.library2.search.return_value.get.return_value = None + + result = self.core.library.find_exact(any=['a']) + + self.assertIn(result1, result) + self.assertNotIn(None, result) + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) + self.library2.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) + + def test_find_accepts_query_dict_instead_of_kwargs(self): + track1 = Track(uri='dummy1:a') + track2 = Track(uri='dummy2:a') + result1 = SearchResult(tracks=[track1]) + result2 = SearchResult(tracks=[track2]) + + self.library1.search.return_value.get.return_value = result1 + self.library2.search.return_value.get.return_value = result2 + + result = self.core.library.find_exact(dict(any=['a'])) + + self.assertIn(result1, result) + self.assertIn(result2, result) + self.library1.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) + self.library2.search.assert_called_once_with( + query=dict(any=['a']), uris=None, exact=True) + def test_find_exact_normalises_bad_queries(self): self.core.library.find_exact({'any': 'foobar'}) + self.library1.search.assert_called_once_with( query={'any': ['foobar']}, uris=None, exact=True) @@ -369,8 +383,18 @@ class LegacyFindExactToSearchLibraryTest(unittest.TestCase): self.backend.library = mock.Mock(spec=backend.LibraryProvider) self.core = core.Core(mixer=None, backends=[self.backend]) + self._warnings_filters = warnings.filters + warnings.filters = warnings.filters[:] + warnings.filterwarnings('ignore', '.*library.find_exact.*') + + def tearDown(self): # noqa: N802 + warnings.filters = self._warnings_filters + def test_core_find_exact_calls_backend_search_with_exact(self): - self.core.library.find_exact(query={'any': ['a']}) + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + self.core.library.find_exact(query={'any': ['a']}) + self.backend.library.search.assert_called_once_with( query=dict(any=['a']), uris=None, exact=True) diff --git a/tests/local/test_library.py b/tests/local/test_library.py index 39f0e53e..7ab67fa6 100644 --- a/tests/local/test_library.py +++ b/tests/local/test_library.py @@ -84,6 +84,10 @@ class LocalLibraryProviderTest(unittest.TestCase): pykka.ActorRegistry.stop_all() actor.LocalBackend.libraries = [] + def find_exact(self, **query): + # TODO: remove this helper? + return self.library.search(query=query, exact=True) + def test_refresh(self): self.library.refresh() @@ -149,228 +153,228 @@ class LocalLibraryProviderTest(unittest.TestCase): # TODO: move to search_test module def test_find_exact_no_hits(self): - result = self.library.find_exact(track_name=['unknown track']) + result = self.find_exact(track_name=['unknown track']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(artist=['unknown artist']) + result = self.find_exact(artist=['unknown artist']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(albumartist=['unknown albumartist']) + result = self.find_exact(albumartist=['unknown albumartist']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(composer=['unknown composer']) + result = self.find_exact(composer=['unknown composer']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(performer=['unknown performer']) + result = self.find_exact(performer=['unknown performer']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(album=['unknown album']) + result = self.find_exact(album=['unknown album']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(date=['1990']) + result = self.find_exact(date=['1990']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(genre=['unknown genre']) + result = self.find_exact(genre=['unknown genre']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(track_no=['9']) + result = self.find_exact(track_no=['9']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(track_no=['no_match']) + result = self.find_exact(track_no=['no_match']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(comment=['fake comment']) + result = self.find_exact(comment=['fake comment']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(uri=['fake uri']) + result = self.find_exact(uri=['fake uri']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(any=['unknown any']) + result = self.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) + result = self.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) + result = self.find_exact(uri=track_2_uri) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_track_name(self): - result = self.library.find_exact(track_name=['track1']) + result = self.find_exact(track_name=['track1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.find_exact(track_name=['track2']) + result = self.find_exact(track_name=['track2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_artist(self): - result = self.library.find_exact(artist=['artist1']) + result = self.find_exact(artist=['artist1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.find_exact(artist=['artist2']) + result = self.find_exact(artist=['artist2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - result = self.library.find_exact(artist=['artist3']) + result = self.find_exact(artist=['artist3']) self.assertEqual(list(result[0].tracks), self.tracks[3:4]) def test_find_exact_composer(self): - result = self.library.find_exact(composer=['artist5']) + result = self.find_exact(composer=['artist5']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) - result = self.library.find_exact(composer=['artist6']) + result = self.find_exact(composer=['artist6']) self.assertEqual(list(result[0].tracks), []) def test_find_exact_performer(self): - result = self.library.find_exact(performer=['artist6']) + result = self.find_exact(performer=['artist6']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) - result = self.library.find_exact(performer=['artist5']) + result = self.find_exact(performer=['artist5']) self.assertEqual(list(result[0].tracks), []) def test_find_exact_album(self): - result = self.library.find_exact(album=['album1']) + result = self.find_exact(album=['album1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.find_exact(album=['album2']) + result = self.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']) + result = self.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']) + result = self.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']) + result = self.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']) + result = self.find_exact(track_no=['1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.find_exact(track_no=['2']) + result = self.find_exact(track_no=['2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_genre(self): - result = self.library.find_exact(genre=['genre1']) + result = self.find_exact(genre=['genre1']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) - result = self.library.find_exact(genre=['genre2']) + result = self.find_exact(genre=['genre2']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) def test_find_exact_date(self): - result = self.library.find_exact(date=['2001']) + result = self.find_exact(date=['2001']) self.assertEqual(list(result[0].tracks), []) - result = self.library.find_exact(date=['2001-02-03']) + result = self.find_exact(date=['2001-02-03']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.find_exact(date=['2002']) + result = self.find_exact(date=['2002']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_comment(self): - result = self.library.find_exact( + result = self.find_exact( comment=['This is a fantastic track']) self.assertEqual(list(result[0].tracks), self.tracks[3:4]) - result = self.library.find_exact( + result = self.find_exact( comment=['This is a fantastic']) self.assertEqual(list(result[0].tracks), []) def test_find_exact_any(self): # Matches on track artist - result = self.library.find_exact(any=['artist1']) + result = self.find_exact(any=['artist1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.find_exact(any=['artist2']) + result = self.find_exact(any=['artist2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) # Matches on track name - result = self.library.find_exact(any=['track1']) + result = self.find_exact(any=['track1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.find_exact(any=['track2']) + result = self.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']) + result = self.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']) + result = self.find_exact(any=['artist3']) self.assertEqual(len(result[0].tracks), 2) self.assertIn(self.tracks[2], result[0].tracks) self.assertIn(self.tracks[3], result[0].tracks) # Matches on track composer - result = self.library.find_exact(any=['artist5']) + result = self.find_exact(any=['artist5']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) # Matches on track performer - result = self.library.find_exact(any=['artist6']) + result = self.find_exact(any=['artist6']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) # Matches on track genre - result = self.library.find_exact(any=['genre1']) + result = self.find_exact(any=['genre1']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) - result = self.library.find_exact(any=['genre2']) + result = self.find_exact(any=['genre2']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) # Matches on track date - result = self.library.find_exact(any=['2002']) + result = self.find_exact(any=['2002']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) # Matches on track comment - result = self.library.find_exact( + result = self.find_exact( any=['This is a fantastic track']) self.assertEqual(list(result[0].tracks), self.tracks[3:4]) # Matches on URI - result = self.library.find_exact(any=['local:track:path1']) + result = self.find_exact(any=['local:track:path1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) def test_find_exact_wrong_type(self): with self.assertRaises(LookupError): - self.library.find_exact(wrong=['test']) + self.find_exact(wrong=['test']) def test_find_exact_with_empty_query(self): with self.assertRaises(LookupError): - self.library.find_exact(artist=['']) + self.find_exact(artist=['']) with self.assertRaises(LookupError): - self.library.find_exact(albumartist=['']) + self.find_exact(albumartist=['']) with self.assertRaises(LookupError): - self.library.find_exact(track_name=['']) + self.find_exact(track_name=['']) with self.assertRaises(LookupError): - self.library.find_exact(composer=['']) + self.find_exact(composer=['']) with self.assertRaises(LookupError): - self.library.find_exact(performer=['']) + self.find_exact(performer=['']) with self.assertRaises(LookupError): - self.library.find_exact(album=['']) + self.find_exact(album=['']) with self.assertRaises(LookupError): - self.library.find_exact(track_no=['']) + self.find_exact(track_no=['']) with self.assertRaises(LookupError): - self.library.find_exact(genre=['']) + self.find_exact(genre=['']) with self.assertRaises(LookupError): - self.library.find_exact(date=['']) + self.find_exact(date=['']) with self.assertRaises(LookupError): - self.library.find_exact(comment=['']) + self.find_exact(comment=['']) with self.assertRaises(LookupError): - self.library.find_exact(any=['']) + self.find_exact(any=['']) def test_search_no_hits(self): result = self.library.search(track_name=['unknown track']) From a54551d9855a00ced41dcddce94c54563bebbe17 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 26 Mar 2015 23:12:26 +0100 Subject: [PATCH 282/314] core: Mark get_playlists and filter as deprecated --- mopidy/core/playlists.py | 6 +++ tests/core/test_playlists.py | 94 +++++++++++++++++++++++------------- tests/m3u/test_playlists.py | 66 +++++++++++++++---------- 3 files changed, 106 insertions(+), 60 deletions(-) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 669e1f35..b6f2e726 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals import logging import urlparse +import warnings import pykka @@ -80,6 +81,9 @@ class PlaylistsController(object): .. deprecated:: 1.0 Use :meth:`as_list` and :meth:`get_items` instead. """ + warnings.warn( + 'playlists.get_playlists() is deprecated', DeprecationWarning) + playlist_refs = self.as_list() if include_tracks: @@ -166,6 +170,8 @@ class PlaylistsController(object): .. deprecated:: 1.0 Use :meth:`as_list` and filter yourself. """ + warnings.warn('playlists.filter() is deprecated', DeprecationWarning) + criteria = criteria or kwargs matches = self.playlists for (key, value) in criteria.iteritems(): diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index 081f73e6..fa8c3531 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, unicode_literals import unittest +import warnings import mock @@ -83,30 +84,6 @@ class PlaylistsTest(unittest.TestCase): self.assertFalse(self.sp1.delete.called) self.assertFalse(self.sp2.delete.called) - def test_get_playlists_combines_result_from_backends(self): - result = self.core.playlists.get_playlists() - - self.assertIn(self.pl1a, result) - self.assertIn(self.pl1b, result) - self.assertIn(self.pl2a, result) - self.assertIn(self.pl2b, result) - - def test_get_playlists_includes_tracks_by_default(self): - result = self.core.playlists.get_playlists() - - self.assertEqual(result[0].name, 'A') - self.assertEqual(len(result[0].tracks), 1) - self.assertEqual(result[1].name, 'B') - self.assertEqual(len(result[1].tracks), 1) - - def test_get_playlist_can_strip_tracks_from_returned_playlists(self): - result = self.core.playlists.get_playlists(include_tracks=False) - - self.assertEqual(result[0].name, 'A') - self.assertEqual(len(result[0].tracks), 0) - self.assertEqual(result[1].name, 'B') - self.assertEqual(len(result[1].tracks), 0) - def test_create_without_uri_scheme_uses_first_backend(self): playlist = Playlist() self.sp1.create().get.return_value = playlist @@ -164,16 +141,6 @@ class PlaylistsTest(unittest.TestCase): self.assertFalse(self.sp1.delete.called) self.assertFalse(self.sp2.delete.called) - def test_filter_returns_matching_playlists(self): - result = self.core.playlists.filter(name='A') - - self.assertEqual(2, len(result)) - - def test_filter_accepts_dict_instead_of_kwargs(self): - result = self.core.playlists.filter({'name': 'A'}) - - self.assertEqual(2, len(result)) - def test_lookup_selects_the_dummy1_backend(self): self.core.playlists.lookup('dummy1:a') @@ -259,3 +226,62 @@ class PlaylistsTest(unittest.TestCase): self.assertIsNone(result) self.assertFalse(self.sp1.save.called) self.assertFalse(self.sp2.save.called) + + +class DeprecatedFilterPlaylistsTest(BasePlaylistsTest): + def setUp(self): # noqa: N802 + super(DeprecatedFilterPlaylistsTest, self).setUp() + self._warnings_filters = warnings.filters + warnings.filters = warnings.filters[:] + warnings.filterwarnings('ignore', '.*filter.*') + warnings.filterwarnings('ignore', '.*get_playlists.*') + + def tearDown(self): # noqa: N802 + super(DeprecatedFilterPlaylistsTest, self).tearDown() + warnings.filters = self._warnings_filters + + def test_filter_returns_matching_playlists(self): + result = self.core.playlists.filter(name='A') + + self.assertEqual(2, len(result)) + + def test_filter_accepts_dict_instead_of_kwargs(self): + result = self.core.playlists.filter({'name': 'A'}) + + self.assertEqual(2, len(result)) + + +class DeprecatedGetPlaylistsTest(BasePlaylistsTest): + def setUp(self): # noqa: N802 + super(DeprecatedGetPlaylistsTest, self).setUp() + self._warnings_filters = warnings.filters + warnings.filters = warnings.filters[:] + warnings.filterwarnings('ignore', '.*get_playlists.*') + + def tearDown(self): # noqa: N802 + super(DeprecatedGetPlaylistsTest, self).tearDown() + warnings.filters = self._warnings_filters + + def test_get_playlists_combines_result_from_backends(self): + result = self.core.playlists.get_playlists() + + self.assertIn(self.pl1a, result) + self.assertIn(self.pl1b, result) + self.assertIn(self.pl2a, result) + self.assertIn(self.pl2b, result) + + def test_get_playlists_includes_tracks_by_default(self): + result = self.core.playlists.get_playlists() + + self.assertEqual(result[0].name, 'A') + self.assertEqual(len(result[0].tracks), 1) + self.assertEqual(result[1].name, 'B') + self.assertEqual(len(result[1].tracks), 1) + + def test_get_playlist_can_strip_tracks_from_returned_playlists(self): + result = self.core.playlists.get_playlists(include_tracks=False) + + self.assertEqual(result[0].name, 'A') + self.assertEqual(len(result[0].tracks), 0) + self.assertEqual(result[1].name, 'B') + self.assertEqual(len(result[1].tracks), 0) diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index 355aabf5..cf3265c3 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -4,6 +4,7 @@ import os import shutil import tempfile import unittest +import warnings import pykka @@ -141,8 +142,8 @@ class M3UPlaylistsProviderTest(unittest.TestCase): 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) + playlists = self.core.playlists.as_list() + self.assertIn(playlist.uri, [ref.uri for ref in playlists]) def test_as_list_empty_to_start_with(self): self.assertEqual(len(self.core.playlists.as_list()), 0) @@ -171,30 +172,6 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.core.playlists.delete(playlist.uri) self.assertIsNone(self.core.playlists.lookup(playlist.uri)) - def test_filter_without_criteria(self): - self.assertEqual( - self.core.playlists.get_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): - self.core.playlists.create('a') - playlist = self.core.playlists.create('b') - - self.assertEqual([playlist], self.core.playlists.filter(name='b')) - - def test_filter_by_name_returns_no_matches(self): - self.core.playlists.create('a') - self.core.playlists.create('b') - - self.assertEqual([], self.core.playlists.filter(name='c')) - def test_lookup_finds_playlist_by_uri(self): original_playlist = self.core.playlists.create('test') @@ -292,3 +269,40 @@ class M3UPlaylistsProviderTest(unittest.TestCase): item_refs = self.core.playlists.get_items('dummy:unknown') self.assertIsNone(item_refs) + + +class DeprecatedM3UPlaylistsProviderTest(M3UPlaylistsProviderTest): + def setUp(self): # noqa: N802 + super(DeprecatedM3UPlaylistsProviderTest, self).setUp() + self._warnings_filters = warnings.filters + warnings.filters = warnings.filters[:] + warnings.filterwarnings('ignore', '.*filter.*') + warnings.filterwarnings('ignore', '.*get_playlists.*') + + def tearDown(self): # noqa: N802 + super(DeprecatedM3UPlaylistsProviderTest, self).tearDown() + warnings.filters = self._warnings_filters + + def test_filter_without_criteria(self): + self.assertEqual(self.core.playlists.get_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): + self.core.playlists.create('a') + playlist = self.core.playlists.create('b') + + self.assertEqual([playlist], self.core.playlists.filter(name='b')) + + def test_filter_by_name_returns_no_matches(self): + self.core.playlists.create('a') + self.core.playlists.create('b') + + self.assertEqual([], self.core.playlists.filter(name='c')) From 447629cbf9ab6d2c0e50aa87ad16a9caa91531fc Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 26 Mar 2015 23:48:33 +0100 Subject: [PATCH 283/314] audio: Add deprecation warning to emit_end_of_stream --- mopidy/audio/actor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index b4c78ecb..7b3e7985 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals import logging import os +import warnings import gobject @@ -605,6 +606,8 @@ class Audio(pykka.ThreadingActor): .. deprecated:: 1.0 Use :meth:`emit_data` with a :class:`None` buffer instead. """ + warnings.warn('audio.emit_end_of_stream() is deprecated.', + DeprecationWarning) self._appsrc.push(None) def set_about_to_finish_callback(self, callback): From f5f9899db923f1cdc4e150dda59f6f87fc863cf7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 27 Mar 2015 19:30:10 +0100 Subject: [PATCH 284/314] tests: Make bases test classes in core --- tests/core/test_library.py | 11 +++++++---- tests/core/test_playlists.py | 4 +++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 4a96042d..bebdd8a6 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -9,7 +9,7 @@ from mopidy import backend, core from mopidy.models import Image, Ref, SearchResult, Track -class CoreLibraryTest(unittest.TestCase): +class BaseCoreLibraryTest(unittest.TestCase): def setUp(self): # noqa: N802 dummy1_root = Ref.directory(uri='dummy1:directory', name='dummy1') self.backend1 = mock.Mock() @@ -38,6 +38,9 @@ class CoreLibraryTest(unittest.TestCase): self.core = core.Core(mixer=None, backends=[ self.backend1, self.backend2, self.backend3]) + +# TODO: split by method +class CoreLibraryTest(BaseCoreLibraryTest): def test_get_images_returns_empty_dict_for_no_uris(self): self.assertEqual({}, self.core.library.get_images([])) @@ -288,15 +291,15 @@ class CoreLibraryTest(unittest.TestCase): query={'any': ['foobar']}, uris=None, exact=False) -class DeprecatedCoreLibraryTest(CoreLibraryTest): +class DeprecatedFindExactCoreLibraryTest(BaseCoreLibraryTest): def setUp(self): # noqa: N802 - super(DeprecatedCoreLibraryTest, self).setUp() + super(DeprecatedFindExactCoreLibraryTest, self).setUp() self._warnings_filters = warnings.filters warnings.filters = warnings.filters[:] warnings.filterwarnings('ignore', '.*library.find_exact.*') def tearDown(self): # noqa: N802 - super(DeprecatedCoreLibraryTest, self).tearDown() + super(DeprecatedFindExactCoreLibraryTest, self).tearDown() warnings.filters = self._warnings_filters def test_find_exact_combines_results_from_all_backends(self): diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index fa8c3531..dbe05733 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -9,7 +9,7 @@ from mopidy import backend, core from mopidy.models import Playlist, Ref, Track -class PlaylistsTest(unittest.TestCase): +class BasePlaylistsTest(unittest.TestCase): def setUp(self): # noqa: N802 self.plr1a = Ref.playlist(name='A', uri='dummy1:pl:a') self.plr1b = Ref.playlist(name='B', uri='dummy1:pl:b') @@ -50,6 +50,8 @@ class PlaylistsTest(unittest.TestCase): self.core = core.Core(mixer=None, backends=[ self.backend3, self.backend1, self.backend2]) + +class PlaylistTest(BasePlaylistsTest): def test_as_list_combines_result_from_backends(self): result = self.core.playlists.as_list() From 0ab52a73faa0feb274423dfd85f551a84046d319 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 27 Mar 2015 19:32:47 +0100 Subject: [PATCH 285/314] core: Mark library.lookup by uri deprecated Updates core, mpd and tests to not use deprecated calls or safely catch them when running with -W error. --- mopidy/core/library.py | 4 ++ mopidy/core/tracklist.py | 12 +++--- mopidy/mpd/dispatcher.py | 11 +++--- mopidy/mpd/protocol/current_playlist.py | 3 +- mopidy/mpd/protocol/music_db.py | 11 +++--- tests/core/test_library.py | 50 +++++++++++++++---------- tests/local/test_library.py | 9 +++-- 7 files changed, 60 insertions(+), 40 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 9aaa386e..38e231f1 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -162,6 +162,10 @@ class LibraryController(object): if none_set or both_set: raise ValueError("One of 'uri' or 'uris' must be set") + if uri: + warnings.warn('library.lookup() "uri" argument is deprecated.', + DeprecationWarning) + if uri is not None: uris = [uri] diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 9186ae42..51ce7fbf 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -334,12 +334,12 @@ class TracklistController(object): if tracks is None: if uri is not None: - tracks = self.core.library.lookup(uri=uri) - elif uris is not None: - tracks = [] - track_map = self.core.library.lookup(uris=uris) - for uri in uris: - tracks.extend(track_map[uri]) + uris = [uri] + + tracks = [] + track_map = self.core.library.lookup(uris=uris) + for uri in uris: + tracks.extend(track_map[uri]) tl_tracks = [] diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index d156b891..4d1c6196 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -267,10 +267,10 @@ class MpdContext(object): given path. If ``lookup`` is true and the ``path`` is to a track, the returned - ``data`` is a future which will contain the - :class:`mopidy.models.Track` model. If ``lookup`` is false and the - ``path`` is to a track, the returned ``data`` will be a - :class:`mopidy.models.Ref` for the track. + ``data`` is a future which will contain the results from looking up + the URI with :meth:`mopidy.core.LibraryController.lookup` If ``lookup`` + is false and the ``path`` is to a track, the returned ``data`` will be + a :class:`mopidy.models.Ref` for the track. For all entries that are not tracks, the returned ``data`` will be :class:`None`. @@ -302,7 +302,8 @@ class MpdContext(object): if ref.type == ref.TRACK: if lookup: - yield (path, self.core.library.lookup(ref.uri)) + # TODO: can we lookup all the refs at once now? + yield (path, self.core.library.lookup(uris=[ref.uri])) else: yield (path, ref) else: diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index d8e1a9d8..ccf6f788 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -29,7 +29,8 @@ def add(context, uri): tracks = [] for path, lookup_future in context.browse(uri): if lookup_future: - tracks.extend(lookup_future.get()) + for result in lookup_future.get().values(): + tracks.extend(result) except exceptions.MpdNoExistError as e: e.message = 'directory or file not found' raise diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 59a1f9b6..644da88d 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -331,8 +331,9 @@ def listallinfo(context, uri=None): if not lookup_future: result.append(('directory', path)) else: - for track in lookup_future.get(): - result.extend(translator.track_to_mpd_format(track)) + for uri, tracks in lookup_future.get().items(): + for track in tracks: + result.extend(translator.track_to_mpd_format(track)) return result @@ -358,9 +359,9 @@ def lsinfo(context, uri=None): if not lookup_future: result.append(('directory', path.lstrip('/'))) else: - tracks = lookup_future.get() - if tracks: - result.extend(translator.track_to_mpd_format(tracks[0])) + for uri, tracks in lookup_future.get().items(): + if tracks: + result.extend(translator.track_to_mpd_format(tracks[0])) if uri in (None, '', '/'): result.extend(protocol.stored_playlists.listplaylists(context)) diff --git a/tests/core/test_library.py b/tests/core/test_library.py index bebdd8a6..bdefd8f0 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -149,18 +149,6 @@ class CoreLibraryTest(BaseCoreLibraryTest): Ref.track(uri='dummy1:track:/foo/baz.mp3', name='Baz'), ]) - def test_lookup_selects_dummy1_backend(self): - self.core.library.lookup('dummy1:a') - - self.library1.lookup.assert_called_once_with('dummy1:a') - self.assertFalse(self.library2.lookup.called) - - def test_lookup_selects_dummy2_backend(self): - self.core.library.lookup('dummy2:a') - - self.assertFalse(self.library1.lookup.called) - self.library2.lookup.assert_called_once_with('dummy2:a') - def test_lookup_fails_with_uri_and_uris_set(self): with self.assertRaises(ValueError): self.core.library.lookup('dummy1:a', ['dummy2:a']) @@ -172,13 +160,6 @@ class CoreLibraryTest(BaseCoreLibraryTest): result = self.core.library.lookup(uris=['dummy1:a', 'dummy2:a']) self.assertEqual(result, {'dummy2:a': [5678], 'dummy1:a': [1234]}) - def test_lookup_uri_returns_empty_list_for_dummy3_track(self): - result = self.core.library.lookup('dummy3:a') - - self.assertEqual(result, []) - self.assertFalse(self.library1.lookup.called) - self.assertFalse(self.library2.lookup.called) - def test_lookup_uris_returns_empty_list_for_dummy3_track(self): result = self.core.library.lookup(uris=['dummy3:a']) @@ -378,6 +359,37 @@ class DeprecatedFindExactCoreLibraryTest(BaseCoreLibraryTest): query={'any': ['foobar']}, uris=None, exact=True) +class DeprecatedLookupCoreLibraryTest(BaseCoreLibraryTest): + def setUp(self): # noqa: N802 + super(DeprecatedLookupCoreLibraryTest, self).setUp() + self._warnings_filters = warnings.filters + warnings.filters = warnings.filters[:] + warnings.filterwarnings('ignore', 'library.lookup.*"uri" argument.*') + + def tearDown(self): # noqa: N802 + super(DeprecatedLookupCoreLibraryTest, self).tearDown() + warnings.filters = self._warnings_filters + + def test_lookup_selects_dummy1_backend(self): + self.core.library.lookup('dummy1:a') + + self.library1.lookup.assert_called_once_with('dummy1:a') + self.assertFalse(self.library2.lookup.called) + + def test_lookup_selects_dummy2_backend(self): + self.core.library.lookup('dummy2:a') + + self.assertFalse(self.library1.lookup.called) + self.library2.lookup.assert_called_once_with('dummy2:a') + + def test_lookup_uri_returns_empty_list_for_dummy3_track(self): + result = self.core.library.lookup('dummy3:a') + + self.assertEqual(result, []) + self.assertFalse(self.library1.lookup.called) + self.assertFalse(self.library2.lookup.called) + + class LegacyFindExactToSearchLibraryTest(unittest.TestCase): def setUp(self): # noqa: N802 self.backend = mock.Mock() diff --git a/tests/local/test_library.py b/tests/local/test_library.py index 7ab67fa6..dfab2c89 100644 --- a/tests/local/test_library.py +++ b/tests/local/test_library.py @@ -128,12 +128,13 @@ class LocalLibraryProviderTest(unittest.TestCase): pass # TODO def test_lookup(self): - tracks = self.library.lookup(self.tracks[0].uri) - self.assertEqual(tracks, self.tracks[0:1]) + uri = self.tracks[0].uri + result = self.library.lookup(uris=[uri]) + self.assertEqual(result[uri], self.tracks[0:1]) def test_lookup_unknown_track(self): - tracks = self.library.lookup('fake uri') - self.assertEqual(tracks, []) + tracks = self.library.lookup(uris=['fake uri']) + self.assertEqual(tracks, {'fake uri': []}) # test backward compatibility with local libraries returning a # single Track From 49fc9941a14c5de9248c4b52fd2afd0d22a48e3f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 27 Mar 2015 19:59:28 +0100 Subject: [PATCH 286/314] core: Mark searching via keyword argument based query deprecated --- mopidy/core/library.py | 6 ++ mopidy/mpd/protocol/music_db.py | 6 +- tests/core/test_library.py | 52 +++++++------- tests/local/test_library.py | 124 ++++++++++++++++---------------- 4 files changed, 99 insertions(+), 89 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 38e231f1..f87c9aa7 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -248,6 +248,12 @@ class LibraryController(object): The ``exact`` keyword argument, which replaces :meth:`find_exact`. """ query = _normalize_query(query or kwargs) + + if kwargs: + warnings.warn( + 'library.search() with keyword argument query is deprecated', + DeprecationWarning) + futures = {} for backend, backend_uris in self._get_backends_to_uris(uris).items(): futures[backend] = backend.library.search( diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 644da88d..b0919a9a 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -413,7 +413,7 @@ def search(context, *args): query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return - results = context.core.library.search(**query).get() + results = context.core.library.search(query).get() artists = [_artist_as_track(a) for a in _get_artists(results)] albums = [_album_as_track(a) for a in _get_albums(results)] tracks = _get_tracks(results) @@ -437,7 +437,7 @@ def searchadd(context, *args): query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return - results = context.core.library.search(**query).get() + results = context.core.library.search(query).get() context.core.tracklist.add(_get_tracks(results)) @@ -464,7 +464,7 @@ def searchaddpl(context, *args): query = _query_from_mpd_search_parameters(parameters, _SEARCH_MAPPING) except ValueError: return - results = context.core.library.search(**query).get() + results = context.core.library.search(query).get() uri = context.lookup_playlist_uri_from_name(playlist_name) playlist = uri is not None and context.core.playlists.lookup(uri).get() diff --git a/tests/core/test_library.py b/tests/core/test_library.py index bdefd8f0..eb3d4dc3 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -202,31 +202,31 @@ class CoreLibraryTest(BaseCoreLibraryTest): self.library2.search().get.return_value = result2 self.library2.search.reset_mock() - result = self.core.library.search(any=['a']) + result = self.core.library.search({'any': ['a']}) self.assertIn(result1, result) self.assertIn(result2, result) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=False) + query={'any': ['a']}, uris=None, exact=False) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=False) + query={'any': ['a']}, uris=None, exact=False) def test_search_with_uris_selects_dummy1_backend(self): self.core.library.search( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo', 'dummy3:']) + query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo', 'dummy3:']) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], exact=False) + query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo'], exact=False) self.assertFalse(self.library2.search.called) def test_search_with_uris_selects_both_backends(self): self.core.library.search( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo', 'dummy2:']) + query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo', 'dummy2:']) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], exact=False) + query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo'], exact=False) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy2:'], exact=False) + query={'any': ['a']}, uris=['dummy2:'], exact=False) def test_search_filters_out_none(self): track1 = Track(uri='dummy1:a') @@ -237,14 +237,14 @@ class CoreLibraryTest(BaseCoreLibraryTest): self.library2.search().get.return_value = None self.library2.search.reset_mock() - result = self.core.library.search(any=['a']) + result = self.core.library.search({'any': ['a']}) self.assertIn(result1, result) self.assertNotIn(None, result) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=False) + query={'any': ['a']}, uris=None, exact=False) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=False) + query={'any': ['a']}, uris=None, exact=False) def test_search_accepts_query_dict_instead_of_kwargs(self): track1 = Track(uri='dummy1:a') @@ -257,14 +257,14 @@ class CoreLibraryTest(BaseCoreLibraryTest): self.library2.search().get.return_value = result2 self.library2.search.reset_mock() - result = self.core.library.search(dict(any=['a'])) + result = self.core.library.search({'any': ['a']}) self.assertIn(result1, result) self.assertIn(result2, result) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=False) + query={'any': ['a']}, uris=None, exact=False) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=False) + query={'any': ['a']}, uris=None, exact=False) def test_search_normalises_bad_queries(self): self.core.library.search({'any': 'foobar'}) @@ -292,7 +292,7 @@ class DeprecatedFindExactCoreLibraryTest(BaseCoreLibraryTest): self.library1.search.return_value.get.return_value = result1 self.library2.search.return_value.get.return_value = result2 - result = self.core.library.find_exact(any=['a']) + result = self.core.library.find_exact({'any': ['a']}) self.assertIn(result1, result) self.assertIn(result2, result) @@ -303,20 +303,20 @@ class DeprecatedFindExactCoreLibraryTest(BaseCoreLibraryTest): def test_find_exact_with_uris_selects_dummy1_backend(self): self.core.library.find_exact( - any=['a'], uris=['dummy1:', 'dummy1:foo', 'dummy3:']) + query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo', 'dummy3:']) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], exact=True) + query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo'], exact=True) self.assertFalse(self.library2.search.called) def test_find_exact_with_uris_selects_both_backends(self): self.core.library.find_exact( - any=['a'], uris=['dummy1:', 'dummy1:foo', 'dummy2:']) + query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo', 'dummy2:']) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy1:', 'dummy1:foo'], exact=True) + query={'any': ['a']}, uris=['dummy1:', 'dummy1:foo'], exact=True) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=['dummy2:'], exact=True) + query={'any': ['a']}, uris=['dummy2:'], exact=True) def test_find_exact_filters_out_none(self): track1 = Track(uri='dummy1:a') @@ -325,14 +325,14 @@ class DeprecatedFindExactCoreLibraryTest(BaseCoreLibraryTest): self.library1.search.return_value.get.return_value = result1 self.library2.search.return_value.get.return_value = None - result = self.core.library.find_exact(any=['a']) + result = self.core.library.find_exact({'any': ['a']}) self.assertIn(result1, result) self.assertNotIn(None, result) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=True) + query={'any': ['a']}, uris=None, exact=True) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=True) + query={'any': ['a']}, uris=None, exact=True) def test_find_accepts_query_dict_instead_of_kwargs(self): track1 = Track(uri='dummy1:a') @@ -343,14 +343,14 @@ class DeprecatedFindExactCoreLibraryTest(BaseCoreLibraryTest): self.library1.search.return_value.get.return_value = result1 self.library2.search.return_value.get.return_value = result2 - result = self.core.library.find_exact(dict(any=['a'])) + result = self.core.library.find_exact({'any': ['a']}) self.assertIn(result1, result) self.assertIn(result2, result) self.library1.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=True) + query={'any': ['a']}, uris=None, exact=True) self.library2.search.assert_called_once_with( - query=dict(any=['a']), uris=None, exact=True) + query={'any': ['a']}, uris=None, exact=True) def test_find_exact_normalises_bad_queries(self): self.core.library.find_exact({'any': 'foobar'}) diff --git a/tests/local/test_library.py b/tests/local/test_library.py index dfab2c89..0198ec9e 100644 --- a/tests/local/test_library.py +++ b/tests/local/test_library.py @@ -88,6 +88,10 @@ class LocalLibraryProviderTest(unittest.TestCase): # TODO: remove this helper? return self.library.search(query=query, exact=True) + def search(self, **query): + # TODO: remove this helper? + return self.library.search(query=query) + def test_refresh(self): self.library.refresh() @@ -378,213 +382,213 @@ class LocalLibraryProviderTest(unittest.TestCase): self.find_exact(any=['']) def test_search_no_hits(self): - result = self.library.search(track_name=['unknown track']) + result = self.search(track_name=['unknown track']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(artist=['unknown artist']) + result = self.search(artist=['unknown artist']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(albumartist=['unknown albumartist']) + result = self.search(albumartist=['unknown albumartist']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(composer=['unknown composer']) + result = self.search(composer=['unknown composer']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(performer=['unknown performer']) + result = self.search(performer=['unknown performer']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(album=['unknown album']) + result = self.search(album=['unknown album']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(track_no=['9']) + result = self.search(track_no=['9']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(track_no=['no_match']) + result = self.search(track_no=['no_match']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(genre=['unknown genre']) + result = self.search(genre=['unknown genre']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(date=['unknown date']) + result = self.search(date=['unknown date']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(comment=['unknown comment']) + result = self.search(comment=['unknown comment']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(uri=['unknown uri']) + result = self.search(uri=['unknown uri']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(any=['unknown anything']) + result = self.search(any=['unknown anything']) self.assertEqual(list(result[0].tracks), []) def test_search_uri(self): - result = self.library.search(uri=['TH1']) + result = self.search(uri=['TH1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(uri=['TH2']) + result = self.search(uri=['TH2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_track_name(self): - result = self.library.search(track_name=['Rack1']) + result = self.search(track_name=['Rack1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(track_name=['Rack2']) + result = self.search(track_name=['Rack2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_artist(self): - result = self.library.search(artist=['Tist1']) + result = self.search(artist=['Tist1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(artist=['Tist2']) + result = self.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']) + result = self.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']) + result = self.search(albumartist=['Tist2']) self.assertEqual(list(result[0].tracks), [self.tracks[1]]) # Artist is just album artist - result = self.library.search(albumartist=['Tist3']) + result = self.search(albumartist=['Tist3']) self.assertEqual(list(result[0].tracks), [self.tracks[2]]) def test_search_composer(self): - result = self.library.search(composer=['Tist5']) + result = self.search(composer=['Tist5']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) def test_search_performer(self): - result = self.library.search(performer=['Tist6']) + result = self.search(performer=['Tist6']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) def test_search_album(self): - result = self.library.search(album=['Bum1']) + result = self.search(album=['Bum1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(album=['Bum2']) + result = self.search(album=['Bum2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_genre(self): - result = self.library.search(genre=['Enre1']) + result = self.search(genre=['Enre1']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) - result = self.library.search(genre=['Enre2']) + result = self.search(genre=['Enre2']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) def test_search_date(self): - result = self.library.search(date=['2001']) + result = self.search(date=['2001']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(date=['2001-02-03']) + result = self.search(date=['2001-02-03']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(date=['2001-02-04']) + result = self.search(date=['2001-02-04']) self.assertEqual(list(result[0].tracks), []) - result = self.library.search(date=['2002']) + result = self.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']) + result = self.search(track_no=['1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(track_no=['2']) + result = self.search(track_no=['2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_comment(self): - result = self.library.search(comment=['fantastic']) + result = self.search(comment=['fantastic']) self.assertEqual(list(result[0].tracks), self.tracks[3:4]) - result = self.library.search(comment=['antasti']) + result = self.search(comment=['antasti']) self.assertEqual(list(result[0].tracks), self.tracks[3:4]) def test_search_any(self): # Matches on track artist - result = self.library.search(any=['Tist1']) + result = self.search(any=['Tist1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) # Matches on track composer - result = self.library.search(any=['Tist5']) + result = self.search(any=['Tist5']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) # Matches on track performer - result = self.library.search(any=['Tist6']) + result = self.search(any=['Tist6']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) # Matches on track - result = self.library.search(any=['Rack1']) + result = self.search(any=['Rack1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(any=['Rack2']) + result = self.search(any=['Rack2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) # Matches on track album - result = self.library.search(any=['Bum1']) + result = self.search(any=['Bum1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) # Matches on track album artists - result = self.library.search(any=['Tist3']) + result = self.search(any=['Tist3']) self.assertEqual(len(result[0].tracks), 2) self.assertIn(self.tracks[2], result[0].tracks) self.assertIn(self.tracks[3], result[0].tracks) # Matches on track genre - result = self.library.search(any=['Enre1']) + result = self.search(any=['Enre1']) self.assertEqual(list(result[0].tracks), self.tracks[4:5]) - result = self.library.search(any=['Enre2']) + result = self.search(any=['Enre2']) self.assertEqual(list(result[0].tracks), self.tracks[5:6]) # Matches on track comment - result = self.library.search(any=['fanta']) + result = self.search(any=['fanta']) self.assertEqual(list(result[0].tracks), self.tracks[3:4]) - result = self.library.search(any=['is a fan']) + result = self.search(any=['is a fan']) self.assertEqual(list(result[0].tracks), self.tracks[3:4]) # Matches on URI - result = self.library.search(any=['TH1']) + result = self.search(any=['TH1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) def test_search_wrong_type(self): with self.assertRaises(LookupError): - self.library.search(wrong=['test']) + self.search(wrong=['test']) def test_search_with_empty_query(self): with self.assertRaises(LookupError): - self.library.search(artist=['']) + self.search(artist=['']) with self.assertRaises(LookupError): - self.library.search(albumartist=['']) + self.search(albumartist=['']) with self.assertRaises(LookupError): - self.library.search(composer=['']) + self.search(composer=['']) with self.assertRaises(LookupError): - self.library.search(performer=['']) + self.search(performer=['']) with self.assertRaises(LookupError): - self.library.search(track_name=['']) + self.search(track_name=['']) with self.assertRaises(LookupError): - self.library.search(album=['']) + self.search(album=['']) with self.assertRaises(LookupError): - self.library.search(genre=['']) + self.search(genre=['']) with self.assertRaises(LookupError): - self.library.search(date=['']) + self.search(date=['']) with self.assertRaises(LookupError): - self.library.search(comment=['']) + self.search(comment=['']) with self.assertRaises(LookupError): - self.library.search(uri=['']) + self.search(uri=['']) with self.assertRaises(LookupError): - self.library.search(any=['']) + self.search(any=['']) def test_default_get_images_impl_no_images(self): result = self.library.get_images([track.uri for track in self.tracks]) From d3b275e1a4bc5ac002e0fdf48db27959232147fa Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 27 Mar 2015 20:50:15 +0100 Subject: [PATCH 287/314] core: Mark tracklist.add by URI as deprecated --- mopidy/core/tracklist.py | 5 +++++ mopidy/mpd/protocol/current_playlist.py | 5 +++-- tests/core/test_tracklist.py | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 51ce7fbf..54206881 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -3,6 +3,7 @@ from __future__ import absolute_import, unicode_literals import collections import logging import random +import warnings from mopidy import compat from mopidy.core import listener @@ -332,6 +333,10 @@ class TracklistController(object): assert tracks is not None or uri is not None or uris is not None, \ 'tracks, uri or uris must be provided' + if uri: + warnings.warn('tracklist.add() "uri" argument is deprecated.', + DeprecationWarning) + if tracks is None: if uri is not None: uris = [uri] diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index ccf6f788..4e8ce5e1 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -22,7 +22,7 @@ def add(context, uri): if not uri.strip('/'): return - if context.core.tracklist.add(uri=uri).get(): + if context.core.tracklist.add(uris=[uri]).get(): return try: @@ -63,7 +63,8 @@ def addid(context, uri, songpos=None): raise exceptions.MpdNoExistError('No such song') if songpos is not None and songpos > context.core.tracklist.length.get(): raise exceptions.MpdArgError('Bad song index') - tl_tracks = context.core.tracklist.add(uri=uri, at_position=songpos).get() + tl_tracks = context.core.tracklist.add( + uris=[uri], at_position=songpos).get() if not tl_tracks: raise exceptions.MpdNoExistError('No such song') return ('Id', tl_tracks[0].tlid) diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 415d1fa0..c8cbf97f 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, unicode_literals import unittest +import warnings import mock @@ -28,7 +29,9 @@ class TracklistTest(unittest.TestCase): track = Track(uri='dummy1:x', name='x') self.library.lookup.return_value.get.return_value = [track] - tl_tracks = self.core.tracklist.add(uri='dummy1:x') + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', r'tracklist.add.*"uri".*') + tl_tracks = self.core.tracklist.add(uri='dummy1:x') self.library.lookup.assert_called_once_with('dummy1:x') self.assertEqual(1, len(tl_tracks)) From a8860faa35d30a87db7a2f592aaeb0a35e7d0368 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 27 Mar 2015 23:40:38 +0100 Subject: [PATCH 288/314] tests: Cleanup mpd.protocol.current_playlist tests - Split into smaller test cases more or less per command - Created a BasePopulatedTracklistTestCase with a sensible setUp - Modified test cases to work with the common tracklist state - Replaced all calls to tracklist.add(tracks=...) with uris=... - Test tracklist ordering in more compact way that also gives better error messages --- tests/mpd/protocol/test_current_playlist.py | 375 +++++++------------- 1 file changed, 125 insertions(+), 250 deletions(-) diff --git a/tests/mpd/protocol/test_current_playlist.py b/tests/mpd/protocol/test_current_playlist.py index c96febb7..84d905be 100644 --- a/tests/mpd/protocol/test_current_playlist.py +++ b/tests/mpd/protocol/test_current_playlist.py @@ -7,18 +7,25 @@ from mopidy.models import Ref, Track from tests.mpd import protocol -class CurrentPlaylistHandlerTest(protocol.BaseTestCase): - def test_add(self): - needle = Track(uri='dummy://foo') - self.backend.library.dummy_library = [ - Track(), Track(), needle, Track()] - self.core.tracklist.add( - [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) +class AddCommandsTest(protocol.BaseTestCase): + def setUp(self): # noqa: N802 + super(AddCommandsTest, self).setUp() - self.send_request('add "dummy://foo"') - self.assertEqual(len(self.core.tracklist.tracks.get()), 6) - self.assertEqual(self.core.tracklist.tracks.get()[5], needle) + self.tracks = [Track(uri='dummy:/a', name='a'), + Track(uri='dummy:/foo/b', name='b')] + + self.refs = {'/a': Ref.track(uri='dummy:/a', name='a'), + '/foo': Ref.directory(uri='dummy:/foo', name='foo'), + '/foo/b': Ref.track(uri='dummy:/foo/b', name='b')} + + self.backend.library.dummy_library = self.tracks + + def test_add(self): + for track in [self.tracks[0], self.tracks[0], self.tracks[1]]: + self.send_request('add "%s"' % track.uri) + + self.assertEqual(len(self.core.tracklist.tracks.get()), 3) + self.assertEqual(self.core.tracklist.tracks.get()[2], self.tracks[1]) self.assertEqualResponse('OK') def test_add_with_uri_not_found_in_library_should_ack(self): @@ -27,220 +34,150 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): 'ACK [50@0] {add} directory or file not found') def test_add_with_empty_uri_should_not_add_anything_and_ok(self): - self.backend.library.dummy_library = [Track(uri='dummy:/a', name='a')] self.backend.library.dummy_browse_result = { - 'dummy:/': [Ref.track(uri='dummy:/a', name='a')]} + 'dummy:/': [self.refs['/a']]} self.send_request('add ""') self.assertEqual(len(self.core.tracklist.tracks.get()), 0) self.assertInResponse('OK') def test_add_with_library_should_recurse(self): - tracks = [Track(uri='dummy:/a', name='a'), - Track(uri='dummy:/foo/b', name='b')] - - self.backend.library.dummy_library = tracks self.backend.library.dummy_browse_result = { - 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), - Ref.directory(uri='dummy:/foo', name='foo')], - 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} + 'dummy:/': [self.refs['/a'], self.refs['/foo']], + 'dummy:/foo': [self.refs['/foo/b']]} self.send_request('add "/dummy"') - self.assertEqual(self.core.tracklist.tracks.get(), tracks) + self.assertEqual(self.core.tracklist.tracks.get(), self.tracks) self.assertInResponse('OK') def test_add_root_should_not_add_anything_and_ok(self): - self.backend.library.dummy_library = [Track(uri='dummy:/a', name='a')] self.backend.library.dummy_browse_result = { - 'dummy:/': [Ref.track(uri='dummy:/a', name='a')]} + 'dummy:/': [self.refs['/a']]} self.send_request('add "/"') self.assertEqual(len(self.core.tracklist.tracks.get()), 0) self.assertInResponse('OK') def test_addid_without_songpos(self): - needle = Track(uri='dummy://foo') - self.backend.library.dummy_library = [ - Track(), Track(), needle, Track()] - self.core.tracklist.add( - [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) + for track in [self.tracks[0], self.tracks[0], self.tracks[1]]: + self.send_request('addid "%s"' % track.uri) + tl_tracks = self.core.tracklist.tl_tracks.get() - self.send_request('addid "dummy://foo"') - self.assertEqual(len(self.core.tracklist.tracks.get()), 6) - self.assertEqual(self.core.tracklist.tracks.get()[5], needle) - self.assertInResponse( - 'Id: %d' % self.core.tracklist.tl_tracks.get()[5].tlid) + self.assertEqual(len(tl_tracks), 3) + self.assertEqual(tl_tracks[2].track, self.tracks[1]) + self.assertInResponse('Id: %d' % tl_tracks[2].tlid) self.assertInResponse('OK') + def test_addid_with_songpos(self): + for track in [self.tracks[0], self.tracks[0]]: + self.send_request('add "%s"' % track.uri) + self.send_request('addid "%s" "1"' % self.tracks[1].uri) + tl_tracks = self.core.tracklist.tl_tracks.get() + + self.assertEqual(len(tl_tracks), 3) + self.assertEqual(tl_tracks[1].track, self.tracks[1]) + self.assertInResponse('Id: %d' % tl_tracks[1].tlid) + self.assertInResponse('OK') + + def test_addid_with_songpos_out_of_bounds_should_ack(self): + self.send_request('addid "%s" "3"' % self.tracks[0].uri) + self.assertEqualResponse('ACK [2@0] {addid} Bad song index') + def test_addid_with_empty_uri_acks(self): self.send_request('addid ""') self.assertEqualResponse('ACK [50@0] {addid} No such song') - def test_addid_with_songpos(self): - needle = Track(uri='dummy://foo') - self.backend.library.dummy_library = [ - Track(), Track(), needle, Track()] - self.core.tracklist.add( - [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - - self.send_request('addid "dummy://foo" "3"') - self.assertEqual(len(self.core.tracklist.tracks.get()), 6) - self.assertEqual(self.core.tracklist.tracks.get()[3], needle) - self.assertInResponse( - 'Id: %d' % self.core.tracklist.tl_tracks.get()[3].tlid) - self.assertInResponse('OK') - - def test_addid_with_songpos_out_of_bounds_should_ack(self): - needle = Track(uri='dummy://foo') - self.backend.library.dummy_library = [ - Track(), Track(), needle, Track()] - self.core.tracklist.add( - [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - - self.send_request('addid "dummy://foo" "6"') - self.assertEqualResponse('ACK [2@0] {addid} Bad song index') - def test_addid_with_uri_not_found_in_library_should_ack(self): self.send_request('addid "dummy://foo"') self.assertEqualResponse('ACK [50@0] {addid} No such song') - def test_clear(self): - self.core.tracklist.add( - [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) +class BasePopulatedTracklistTestCase(protocol.BaseTestCase): + def setUp(self): # noqa: N802 + super(BasePopulatedTracklistTestCase, self).setUp() + tracks = [Track(uri='dummy:/%s' % x, name=x) for x in 'abcdef'] + self.backend.library.dummy_library = tracks + self.core.tracklist.add(uris=[t.uri for t in tracks]) + + +class DeleteCommandsTest(BasePopulatedTracklistTestCase): + def test_clear(self): self.send_request('clear') self.assertEqual(len(self.core.tracklist.tracks.get()), 0) self.assertEqual(self.core.playback.current_track.get(), None) self.assertInResponse('OK') def test_delete_songpos(self): - self.core.tracklist.add( - [Track(), Track(), Track(), Track(), Track()]) + tl_tracks = self.core.tracklist.tl_tracks.get() + self.send_request('delete "%d"' % tl_tracks[1].tlid) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - - self.send_request( - 'delete "%d"' % self.core.tracklist.tl_tracks.get()[2].tlid) - self.assertEqual(len(self.core.tracklist.tracks.get()), 4) self.assertInResponse('OK') def test_delete_songpos_out_of_bounds(self): - self.core.tracklist.add( - [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - - self.send_request('delete "5"') - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) + self.send_request('delete "8"') + self.assertEqual(len(self.core.tracklist.tracks.get()), 6) self.assertEqualResponse('ACK [2@0] {delete} Bad song index') def test_delete_open_range(self): - self.core.tracklist.add( - [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - self.send_request('delete "1:"') self.assertEqual(len(self.core.tracklist.tracks.get()), 1) self.assertInResponse('OK') - def test_delete_closed_range(self): - self.core.tracklist.add( - [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) + # TODO: check how this should work. + # def test_delete_open_upper_range(self): + # self.send_request('delete ":8"') + # self.assertEqual(len(self.core.tracklist.tracks.get()), 0) + # self.assertInResponse('OK') + def test_delete_closed_range(self): self.send_request('delete "1:3"') - self.assertEqual(len(self.core.tracklist.tracks.get()), 3) + self.assertEqual(len(self.core.tracklist.tracks.get()), 4) self.assertInResponse('OK') - def test_delete_range_out_of_bounds(self): - self.core.tracklist.add( - [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) - - self.send_request('delete "5:7"') - self.assertEqual(len(self.core.tracklist.tracks.get()), 5) + def test_delete_entire_range_out_of_bounds(self): + self.send_request('delete "8:9"') + self.assertEqual(len(self.core.tracklist.tracks.get()), 6) self.assertEqualResponse('ACK [2@0] {delete} Bad song index') - def test_deleteid(self): - self.core.tracklist.add([Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 2) + def test_delete_upper_range_out_of_bounds(self): + self.send_request('delete "5:9"') + self.assertEqual(len(self.core.tracklist.tracks.get()), 5) + self.assertEqualResponse('OK') + def test_deleteid(self): self.send_request('deleteid "1"') - self.assertEqual(len(self.core.tracklist.tracks.get()), 1) + self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.assertInResponse('OK') def test_deleteid_does_not_exist(self): - self.core.tracklist.add([Track(), Track()]) - self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.send_request('deleteid "12345"') - self.assertEqual(len(self.core.tracklist.tracks.get()), 2) + self.assertEqual(len(self.core.tracklist.tracks.get()), 6) self.assertEqualResponse('ACK [50@0] {deleteid} No such song') - def test_move_songpos(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) +class MoveCommandsTest(BasePopulatedTracklistTestCase): + def test_move_songpos(self): self.send_request('move "1" "0"') - tracks = self.core.tracklist.tracks.get() - self.assertEqual(tracks[0].name, 'b') - self.assertEqual(tracks[1].name, 'a') - self.assertEqual(tracks[2].name, 'c') - self.assertEqual(tracks[3].name, 'd') - self.assertEqual(tracks[4].name, 'e') - self.assertEqual(tracks[5].name, 'f') + result = [t.name for t in self.core.tracklist.tracks.get()] + self.assertEqual(result, ['b', 'a', 'c', 'd', 'e', 'f']) self.assertInResponse('OK') def test_move_open_range(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) - self.send_request('move "2:" "0"') - tracks = self.core.tracklist.tracks.get() - self.assertEqual(tracks[0].name, 'c') - self.assertEqual(tracks[1].name, 'd') - self.assertEqual(tracks[2].name, 'e') - self.assertEqual(tracks[3].name, 'f') - self.assertEqual(tracks[4].name, 'a') - self.assertEqual(tracks[5].name, 'b') + result = [t.name for t in self.core.tracklist.tracks.get()] + self.assertEqual(result, ['c', 'd', 'e', 'f', 'a', 'b']) self.assertInResponse('OK') def test_move_closed_range(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) - self.send_request('move "1:3" "0"') - tracks = self.core.tracklist.tracks.get() - self.assertEqual(tracks[0].name, 'b') - self.assertEqual(tracks[1].name, 'c') - self.assertEqual(tracks[2].name, 'a') - self.assertEqual(tracks[3].name, 'd') - self.assertEqual(tracks[4].name, 'e') - self.assertEqual(tracks[5].name, 'f') + result = [t.name for t in self.core.tracklist.tracks.get()] + self.assertEqual(result, ['b', 'c', 'a', 'd', 'e', 'f']) self.assertInResponse('OK') def test_moveid(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) - self.send_request('moveid "4" "2"') - tracks = self.core.tracklist.tracks.get() - self.assertEqual(tracks[0].name, 'a') - self.assertEqual(tracks[1].name, 'b') - self.assertEqual(tracks[2].name, 'e') - self.assertEqual(tracks[3].name, 'c') - self.assertEqual(tracks[4].name, 'd') - self.assertEqual(tracks[5].name, 'f') + result = [t.name for t in self.core.tracklist.tracks.get()] + self.assertEqual(result, ['a', 'b', 'e', 'c', 'd', 'f']) self.assertInResponse('OK') def test_moveid_with_tlid_not_found_in_tracklist_should_ack(self): @@ -248,14 +185,8 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse( 'ACK [50@0] {moveid} No such song') - def test_playlist_returns_same_as_playlistinfo(self): - with warnings.catch_warnings(): - warnings.filterwarnings('ignore', message='.*playlistinfo.*') - playlist_response = self.send_request('playlist') - - playlistinfo_response = self.send_request('playlistinfo') - self.assertEqual(playlist_response, playlistinfo_response) +class PlaylistFindCommandTest(protocol.BaseTestCase): def test_playlistfind(self): self.send_request('playlistfind "tag" "needle"') self.assertEqualResponse('ACK [0@0] {playlistfind} Not implemented') @@ -269,25 +200,25 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse('OK') def test_playlistfind_by_filename_in_tracklist(self): - self.core.tracklist.add([Track(uri='file:///exists')]) + track = Track(uri='dummy:///exists') + self.backend.library.dummy_library = [track] + self.core.tracklist.add(uris=[track.uri]) - self.send_request('playlistfind filename "file:///exists"') - self.assertInResponse('file: file:///exists') + self.send_request('playlistfind filename "dummy:///exists"') + self.assertInResponse('file: dummy:///exists') self.assertInResponse('Id: 0') self.assertInResponse('Pos: 0') self.assertInResponse('OK') - def test_playlistid_without_songid(self): - self.core.tracklist.add([Track(name='a'), Track(name='b')]) +class PlaylistIdCommandTest(BasePopulatedTracklistTestCase): + def test_playlistid_without_songid(self): self.send_request('playlistid') self.assertInResponse('Title: a') self.assertInResponse('Title: b') self.assertInResponse('OK') def test_playlistid_with_songid(self): - self.core.tracklist.add([Track(name='a'), Track(name='b')]) - self.send_request('playlistid "1"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Id: 0') @@ -296,17 +227,20 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playlistid_with_not_existing_songid_fails(self): - self.core.tracklist.add([Track(name='a'), Track(name='b')]) - self.send_request('playlistid "25"') self.assertEqualResponse('ACK [50@0] {playlistid} No such song') - def test_playlistinfo_without_songpos_or_range(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) +class PlaylistInfoCommandTest(BasePopulatedTracklistTestCase): + def test_playlist_returns_same_as_playlistinfo(self): + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', message='.*playlistinfo.*') + playlist_response = self.send_request('playlist') + + playlistinfo_response = self.send_request('playlistinfo') + self.assertEqual(playlist_response, playlistinfo_response) + + def test_playlistinfo_without_songpos_or_range(self): self.send_request('playlistinfo') self.assertInResponse('Title: a') self.assertInResponse('Pos: 0') @@ -325,10 +259,6 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_playlistinfo_with_songpos(self): # Make the track's CPID not match the playlist position self.core.tracklist.tlid = 17 - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) self.send_request('playlistinfo "4"') self.assertNotInResponse('Title: a') @@ -351,11 +281,6 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqual(response1, response2) def test_playlistinfo_with_open_range(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) - self.send_request('playlistinfo "2:"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Pos: 0') @@ -372,11 +297,6 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playlistinfo_with_closed_range(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) - self.send_request('playlistinfo "2:4"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Title: b') @@ -398,6 +318,8 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.send_request('playlistinfo "0"') self.assertInResponse('OK') + +class PlaylistSearchCommandTest(protocol.BaseTestCase): def test_playlistsearch(self): self.send_request('playlistsearch "any" "needle"') self.assertEqualResponse('ACK [0@0] {playlistsearch} Not implemented') @@ -406,10 +328,9 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.send_request('playlistsearch any "needle"') self.assertEqualResponse('ACK [0@0] {playlistsearch} Not implemented') - def test_plchanges_with_lower_version_returns_changes(self): - self.core.tracklist.add( - [Track(name='a'), Track(name='b'), Track(name='c')]) +class PlChangeCommandTest(BasePopulatedTracklistTestCase): + def test_plchanges_with_lower_version_returns_changes(self): self.send_request('plchanges "0"') self.assertInResponse('Title: a') self.assertInResponse('Title: b') @@ -417,9 +338,6 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_plchanges_with_equal_version_returns_nothing(self): - self.core.tracklist.add( - [Track(name='a'), Track(name='b'), Track(name='c')]) - self.assertEqual(self.core.tracklist.version.get(), 1) self.send_request('plchanges "1"') self.assertNotInResponse('Title: a') @@ -428,9 +346,6 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_plchanges_with_greater_version_returns_nothing(self): - self.core.tracklist.add( - [Track(name='a'), Track(name='b'), Track(name='c')]) - self.assertEqual(self.core.tracklist.version.get(), 1) self.send_request('plchanges "2"') self.assertNotInResponse('Title: a') @@ -439,9 +354,6 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_plchanges_with_minus_one_returns_entire_playlist(self): - self.core.tracklist.add( - [Track(name='a'), Track(name='b'), Track(name='c')]) - self.send_request('plchanges "-1"') self.assertInResponse('Title: a') self.assertInResponse('Title: b') @@ -449,9 +361,6 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_plchanges_without_quotes_works(self): - self.core.tracklist.add( - [Track(name='a'), Track(name='b'), Track(name='c')]) - self.send_request('plchanges 0') self.assertInResponse('Title: a') self.assertInResponse('Title: b') @@ -459,8 +368,6 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_plchangesposid(self): - self.core.tracklist.add([Track(), Track(), Track()]) - self.send_request('plchangesposid "0"') tl_tracks = self.core.tracklist.tl_tracks.get() self.assertInResponse('cpos: 0') @@ -471,11 +378,10 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('Id: %d' % tl_tracks[2].tlid) self.assertInResponse('OK') + +# TODO: we only seem to be testing that don't touch the non shuffled region :/ +class ShuffleCommandTest(BasePopulatedTracklistTestCase): def test_shuffle_without_range(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) version = self.core.tracklist.version.get() self.send_request('shuffle') @@ -483,77 +389,46 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_shuffle_with_open_range(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) version = self.core.tracklist.version.get() self.send_request('shuffle "4:"') self.assertLess(version, self.core.tracklist.version.get()) - tracks = self.core.tracklist.tracks.get() - self.assertEqual(tracks[0].name, 'a') - self.assertEqual(tracks[1].name, 'b') - self.assertEqual(tracks[2].name, 'c') - self.assertEqual(tracks[3].name, 'd') + + result = [t.name for t in self.core.tracklist.tracks.get()] + self.assertEqual(result[:4], ['a', 'b', 'c', 'd']) self.assertInResponse('OK') def test_shuffle_with_closed_range(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) version = self.core.tracklist.version.get() self.send_request('shuffle "1:3"') self.assertLess(version, self.core.tracklist.version.get()) - tracks = self.core.tracklist.tracks.get() - self.assertEqual(tracks[0].name, 'a') - self.assertEqual(tracks[3].name, 'd') - self.assertEqual(tracks[4].name, 'e') - self.assertEqual(tracks[5].name, 'f') + + result = [t.name for t in self.core.tracklist.tracks.get()] + self.assertEqual(result[:1], ['a']) + self.assertEqual(result[3:], ['d', 'e', 'f']) self.assertInResponse('OK') - def test_swap(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) +class SwapCommandTest(BasePopulatedTracklistTestCase): + def test_swap(self): self.send_request('swap "1" "4"') - tracks = self.core.tracklist.tracks.get() - self.assertEqual(tracks[0].name, 'a') - self.assertEqual(tracks[1].name, 'e') - self.assertEqual(tracks[2].name, 'c') - self.assertEqual(tracks[3].name, 'd') - self.assertEqual(tracks[4].name, 'b') - self.assertEqual(tracks[5].name, 'f') + result = [t.name for t in self.core.tracklist.tracks.get()] + self.assertEqual(result, ['a', 'e', 'c', 'd', 'b', 'f']) self.assertInResponse('OK') def test_swapid(self): - self.core.tracklist.add([ - Track(name='a'), Track(name='b'), Track(name='c'), - Track(name='d'), Track(name='e'), Track(name='f'), - ]) - self.send_request('swapid "1" "4"') - tracks = self.core.tracklist.tracks.get() - self.assertEqual(tracks[0].name, 'a') - self.assertEqual(tracks[1].name, 'e') - self.assertEqual(tracks[2].name, 'c') - self.assertEqual(tracks[3].name, 'd') - self.assertEqual(tracks[4].name, 'b') - self.assertEqual(tracks[5].name, 'f') + result = [t.name for t in self.core.tracklist.tracks.get()] + self.assertEqual(result, ['a', 'e', 'c', 'd', 'b', 'f']) self.assertInResponse('OK') def test_swapid_with_first_id_unknown_should_ack(self): - self.core.tracklist.add([Track()]) - self.send_request('swapid "0" "4"') + self.send_request('swapid "0" "8"') self.assertEqualResponse( 'ACK [50@0] {swapid} No such song') def test_swapid_with_second_id_unknown_should_ack(self): - self.core.tracklist.add([Track()]) - self.send_request('swapid "4" "0"') + self.send_request('swapid "8" "0"') self.assertEqualResponse( 'ACK [50@0] {swapid} No such song') From faf8174ef72131a7e308df3062eaa5b79b7f6447 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 28 Mar 2015 00:03:06 +0100 Subject: [PATCH 289/314] tests: Update mpd.test_status to not use tracklist.add(tracks=...) --- tests/mpd/test_status.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index 89030651..675626f6 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -38,6 +38,10 @@ class StatusHandlerTest(unittest.TestCase): def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() + def set_tracklist(self, track): + self.backend.library.dummy_library = [track] + self.core.tracklist.add(uris=[track.uri]).get() + def test_stats_method(self): result = status.stats(self.context) self.assertIn('artists', result) @@ -140,21 +144,22 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['state'], 'pause') def test_status_method_when_playlist_loaded_contains_song(self): - self.core.tracklist.add([Track(uri='dummy:a')]) + self.set_tracklist(Track(uri='dummy:/a')) + self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('song', result) self.assertGreaterEqual(int(result['song']), 0) def test_status_method_when_playlist_loaded_contains_tlid_as_songid(self): - self.core.tracklist.add([Track(uri='dummy:a')]) + self.set_tracklist(Track(uri='dummy:/a')) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('songid', result) self.assertEqual(int(result['songid']), 0) def test_status_method_when_playing_contains_time_with_no_length(self): - self.core.tracklist.add([Track(uri='dummy:a', length=None)]) + self.set_tracklist(Track(uri='dummy:/a', length=None)) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('time', result) @@ -164,7 +169,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertLessEqual(position, total) def test_status_method_when_playing_contains_time_with_length(self): - self.core.tracklist.add([Track(uri='dummy:a', length=10000)]) + self.set_tracklist(Track(uri='dummy:/a', length=10000)) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('time', result) @@ -174,7 +179,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertLessEqual(position, total) def test_status_method_when_playing_contains_elapsed(self): - self.core.tracklist.add([Track(uri='dummy:a', length=60000)]) + self.set_tracklist(Track(uri='dummy:/a', length=60000)) self.core.playback.play() self.core.playback.pause() self.core.playback.seek(59123) @@ -183,7 +188,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['elapsed'], '59.123') def test_status_method_when_starting_playing_contains_elapsed_zero(self): - self.core.tracklist.add([Track(uri='dummy:a', length=10000)]) + self.set_tracklist(Track(uri='dummy:/a', length=10000)) self.core.playback.play() self.core.playback.pause() result = dict(status.status(self.context)) @@ -191,8 +196,8 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['elapsed'], '0.000') def test_status_method_when_playing_contains_bitrate(self): - self.core.tracklist.add([Track(uri='dummy:a', bitrate=320)]) + self.set_tracklist(Track(uri='dummy:/a', bitrate=3200)) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('bitrate', result) - self.assertEqual(int(result['bitrate']), 320) + self.assertEqual(int(result['bitrate']), 3200) From 7d42d028c6562ada21e7d54eaf0cfe2bfa03f6d3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 28 Mar 2015 00:28:34 +0100 Subject: [PATCH 290/314] tests: Stop using tracklist tracks in mpd playback tests --- tests/mpd/protocol/test_playback.py | 70 +++++++++-------------------- 1 file changed, 22 insertions(+), 48 deletions(-) diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index 8bac48cc..4d6e727d 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -173,13 +173,19 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): class PlaybackControlHandlerTest(protocol.BaseTestCase): + def setUp(self): # noqa: N802 + super(PlaybackControlHandlerTest, self).setUp() + self.tracks = [Track(uri='dummy:a', length=40000), + Track(uri='dummy:b', length=40000)] + self.backend.library.dummy_library = self.tracks + self.core.tracklist.add(uris=[t.uri for t in self.tracks]).get() + def test_next(self): + self.core.tracklist.clear().get() self.send_request('next') self.assertInResponse('OK') def test_pause_off(self): - self.core.tracklist.add([Track(uri='dummy:a')]) - self.send_request('play "0"') self.send_request('pause "1"') self.send_request('pause "0"') @@ -187,16 +193,12 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_pause_on(self): - self.core.tracklist.add([Track(uri='dummy:a')]) - self.send_request('play "0"') self.send_request('pause "1"') self.assertEqual(PAUSED, self.core.playback.state.get()) self.assertInResponse('OK') def test_pause_toggle(self): - self.core.tracklist.add([Track(uri='dummy:a')]) - self.send_request('play "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') @@ -212,36 +214,28 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_play_without_pos(self): - self.core.tracklist.add([Track(uri='dummy:a')]) - self.send_request('play') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_play_with_pos(self): - self.core.tracklist.add([Track(uri='dummy:a')]) - self.send_request('play "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_play_with_pos_without_quotes(self): - self.core.tracklist.add([Track(uri='dummy:a')]) - self.send_request('play 0') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_play_with_pos_out_of_bounds(self): - self.core.tracklist.add([]) - + self.core.tracklist.clear().get() self.send_request('play "0"') self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertInResponse('ACK [2@0] {play} Bad song index') def test_play_minus_one_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.core.playback.current_track.get(), None) - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.send_request('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -250,7 +244,6 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_play_minus_one_plays_current_track_if_current_track_is_set(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.assertEqual(self.core.playback.current_track.get(), None) self.core.playback.play() self.core.playback.next() @@ -272,7 +265,6 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_play_minus_is_ignored_if_playing(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -285,7 +277,6 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_play_minus_one_resumes_if_paused(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -300,22 +291,17 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid(self): - self.core.tracklist.add([Track(uri='dummy:a')]) - self.send_request('playid "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_playid_without_quotes(self): - self.core.tracklist.add([Track(uri='dummy:a')]) - self.send_request('playid 0') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_playid_minus_1_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.core.playback.current_track.get(), None) - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.send_request('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -324,7 +310,6 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid_minus_1_plays_current_track_if_current_track_is_set(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.assertEqual(self.core.playback.current_track.get(), None) self.core.playback.play() self.core.playback.next() @@ -346,7 +331,6 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid_minus_is_ignored_if_playing(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -359,7 +343,6 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid_minus_one_resumes_if_paused(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -374,40 +357,36 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid_which_does_not_exist(self): - self.core.tracklist.add([Track(uri='dummy:a')]) - self.send_request('playid "12345"') self.assertInResponse('ACK [50@0] {playid} No such song') def test_previous(self): + self.core.tracklist.clear().get() self.send_request('previous') self.assertInResponse('OK') def test_seek_in_current_track(self): - seek_track = Track(uri='dummy:a', length=40000) - self.core.tracklist.add([seek_track]) self.core.playback.play() self.send_request('seek "0" "30"') - self.assertEqual(self.core.playback.current_track.get(), seek_track) + current_track = self.core.playback.current_track.get() + self.assertEqual(current_track, self.tracks[0]) self.assertGreaterEqual(self.core.playback.time_position, 30000) self.assertInResponse('OK') def test_seek_in_another_track(self): - seek_track = Track(uri='dummy:b', length=40000) - self.core.tracklist.add( - [Track(uri='dummy:a', length=40000), seek_track]) self.core.playback.play() - self.assertNotEqual(self.core.playback.current_track.get(), seek_track) + current_track = self.core.playback.current_track.get() + self.assertNotEqual(current_track, self.tracks[1]) self.send_request('seek "1" "30"') - self.assertEqual(self.core.playback.current_track.get(), seek_track) + current_track = self.core.playback.current_track.get() + self.assertEqual(current_track, self.tracks[1]) self.assertInResponse('OK') def test_seek_without_quotes(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.send_request('seek 0 30') @@ -416,31 +395,27 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_seekid_in_current_track(self): - seek_track = Track(uri='dummy:a', length=40000) - self.core.tracklist.add([seek_track]) self.core.playback.play() self.send_request('seekid "0" "30"') - self.assertEqual(self.core.playback.current_track.get(), seek_track) + current_track = self.core.playback.current_track.get() + self.assertEqual(current_track, self.tracks[0]) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') def test_seekid_in_another_track(self): - seek_track = Track(uri='dummy:b', length=40000) - self.core.tracklist.add( - [Track(uri='dummy:a', length=40000), seek_track]) self.core.playback.play() self.send_request('seekid "1" "30"') - self.assertEqual(1, self.core.playback.current_tl_track.get().tlid) - self.assertEqual(seek_track, self.core.playback.current_track.get()) + current_tl_track = self.core.playback.current_tl_track.get() + self.assertEqual(current_tl_track.tlid, 1) + self.assertEqual(current_tl_track.track, self.tracks[1]) self.assertInResponse('OK') def test_seekcur_absolute_value(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.send_request('seekcur "30"') @@ -449,7 +424,6 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_seekcur_positive_diff(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(10000) self.assertGreaterEqual(self.core.playback.time_position.get(), 10000) @@ -460,7 +434,6 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_seekcur_negative_diff(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(30000) self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) @@ -471,6 +444,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_stop(self): + self.core.tracklist.clear().get() self.send_request('stop') self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertInResponse('OK') From 79b0584887075eb1732770d1732ae07147ec21b6 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 28 Mar 2015 00:29:24 +0100 Subject: [PATCH 291/314] tests: Stop using tracklist add tracks in mpd status test --- tests/mpd/protocol/test_status.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/mpd/protocol/test_status.py b/tests/mpd/protocol/test_status.py index 09df3526..bec54466 100644 --- a/tests/mpd/protocol/test_status.py +++ b/tests/mpd/protocol/test_status.py @@ -11,11 +11,13 @@ class StatusHandlerTest(protocol.BaseTestCase): self.assertEqualResponse('ACK [0@0] {clearerror} Not implemented') def test_currentsong(self): - track = Track() - self.core.tracklist.add([track]) + track = Track(uri='dummy:/a') + self.backend.library.dummy_library = [track] + self.core.tracklist.add(uris=[track.uri]).get() + self.core.playback.play() self.send_request('currentsong') - self.assertInResponse('file: ') + self.assertInResponse('file: dummy:/a') self.assertInResponse('Time: 0') self.assertInResponse('Artist: ') self.assertInResponse('Title: ') From f7399c18495f1d3e66006cec28da3ca7d0a1b7ec Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 29 Mar 2015 18:20:28 +0200 Subject: [PATCH 292/314] tests: Stop using playlist filters in mpd music_db tests --- tests/mpd/protocol/test_music_db.py | 32 ++++++++++++++++------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index b9fbcdf6..df8fa866 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -104,31 +104,35 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.core.playlists.save(playlist) self.backend.library.dummy_search_result = SearchResult( tracks=[Track(uri='dummy:a', name='A')]) - playlists = self.core.playlists.filter(name='my favs').get() - self.assertEqual(len(playlists), 1) - self.assertEqual(len(playlists[0].tracks), 2) + + items = self.core.playlists.get_items(playlist.uri).get() + self.assertEqual(len(items), 2) self.send_request('searchaddpl "my favs" "title" "a"') - playlists = self.core.playlists.filter(name='my favs').get() - self.assertEqual(len(playlists), 1) - self.assertEqual(len(playlists[0].tracks), 3) - self.assertEqual(playlists[0].tracks[0].uri, 'dummy:x') - self.assertEqual(playlists[0].tracks[1].uri, 'dummy:y') - self.assertEqual(playlists[0].tracks[2].uri, 'dummy:a') + items = self.core.playlists.get_items(playlist.uri).get() + self.assertEqual(len(items), 3) + self.assertEqual(items[0].uri, 'dummy:x') + self.assertEqual(items[1].uri, 'dummy:y') + self.assertEqual(items[2].uri, 'dummy:a') self.assertInResponse('OK') def test_searchaddpl_creates_missing_playlist(self): self.backend.library.dummy_search_result = SearchResult( tracks=[Track(uri='dummy:a', name='A')]) - self.assertEqual( - len(self.core.playlists.filter(name='my favs').get()), 0) + + playlists = self.core.playlists.as_list().get() + self.assertNotIn('my favs', {p.name for p in playlists}) self.send_request('searchaddpl "my favs" "title" "a"') - playlists = self.core.playlists.filter(name='my favs').get() - self.assertEqual(len(playlists), 1) - self.assertEqual(playlists[0].tracks[0].uri, 'dummy:a') + playlists = self.core.playlists.as_list().get() + playlist = {p.name: p for p in playlists}['my favs'] + + items = self.core.playlists.get_items(playlist.uri).get() + + self.assertEqual(len(items), 1) + self.assertEqual(items[0].uri, 'dummy:a') self.assertInResponse('OK') def test_listall_without_uri(self): From c85689edad4aa8cf9f627294c6b8e9b643a9be27 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 29 Mar 2015 22:53:52 +0200 Subject: [PATCH 293/314] mpd: Make mpd warnings safe with respect to tracklist.add(tracks=...) --- mopidy/mpd/protocol/current_playlist.py | 20 ++++--- mopidy/mpd/protocol/music_db.py | 17 +++++- mopidy/mpd/protocol/stored_playlists.py | 6 +- tests/mpd/protocol/test_music_db.py | 4 ++ tests/mpd/protocol/test_regression.py | 31 +++++++--- tests/mpd/protocol/test_stored_playlists.py | 66 ++++++++++++++------- 6 files changed, 104 insertions(+), 40 deletions(-) diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index 4e8ce5e1..080b6f2c 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -26,18 +26,17 @@ def add(context, uri): return try: - tracks = [] - for path, lookup_future in context.browse(uri): - if lookup_future: - for result in lookup_future.get().values(): - tracks.extend(result) + uris = [] + for path, ref in context.browse(uri, lookup=False): + if ref: + uris.append(ref.uri) except exceptions.MpdNoExistError as e: e.message = 'directory or file not found' raise - if not tracks: + if not uris: raise exceptions.MpdNoExistError('directory or file not found') - context.core.tracklist.add(tracks=tracks) + context.core.tracklist.add(uris=uris).get() @protocol.commands.add('addid', songpos=protocol.UINT) @@ -351,8 +350,13 @@ def swap(context, songpos1, songpos2): tracks.insert(songpos1, song2) del tracks[songpos2] tracks.insert(songpos2, song1) + + # TODO: do we need a tracklist.replace() context.core.tracklist.clear() - context.core.tracklist.add(tracks) + + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', 'tracklist.add.*"tracks".*') + context.core.tracklist.add(tracks=tracks).get() @protocol.commands.add('swapid', tlid1=protocol.UINT, tlid2=protocol.UINT) diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index b0919a9a..3f1dd2bc 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals import functools import itertools +import warnings from mopidy.models import Track from mopidy.mpd import exceptions, protocol, translator @@ -168,8 +169,14 @@ def findadd(context, *args): query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return + results = context.core.library.search(query=query, exact=True).get() - context.core.tracklist.add(_get_tracks(results)) + + with warnings.catch_warnings(): + # TODO: for now just use tracks as other wise we have to lookup the + # tracks we just got from the search. + warnings.filterwarnings('ignore', 'tracklist.add.*"tracks" argument.*') + context.core.tracklist.add(tracks=_get_tracks(results)).get() @protocol.commands.add('list') @@ -437,8 +444,14 @@ def searchadd(context, *args): query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING) except ValueError: return + results = context.core.library.search(query).get() - context.core.tracklist.add(_get_tracks(results)) + + with warnings.catch_warnings(): + # TODO: for now just use tracks as other wise we have to lookup the + # tracks we just got from the search. + warnings.filterwarnings('ignore', 'tracklist.add.*"tracks".*') + context.core.tracklist.add(_get_tracks(results)).get() @protocol.commands.add('searchaddpl') diff --git a/mopidy/mpd/protocol/stored_playlists.py b/mopidy/mpd/protocol/stored_playlists.py index 9d9f66e0..a5d4b180 100644 --- a/mopidy/mpd/protocol/stored_playlists.py +++ b/mopidy/mpd/protocol/stored_playlists.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, division, unicode_literals import datetime +import warnings from mopidy.mpd import exceptions, protocol, translator @@ -127,7 +128,10 @@ def load(context, name, playlist_slice=slice(0, None)): playlist = uri is not None and context.core.playlists.lookup(uri).get() if not playlist: raise exceptions.MpdNoExistError('No such playlist') - context.core.tracklist.add(playlist.tracks[playlist_slice]) + + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', 'tracklist.add.*"tracks".*') + context.core.tracklist.add(playlist.tracks[playlist_slice]).get() @protocol.commands.add('playlistadd') diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index df8fa866..ee8d386d 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -7,6 +7,8 @@ from mopidy.mpd.protocol import music_db from tests.mpd import protocol +# TODO: split into more modules for faster parallel tests? + class QueryFromMpdSearchFormatTest(unittest.TestCase): def test_dates_are_extracted(self): @@ -32,6 +34,8 @@ class QueryFromMpdListFormatTest(unittest.TestCase): pass # TODO +# TODO: why isn't core.playlists.filter getting deprecation warnings? + class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_count(self): self.send_request('count "artist" "needle"') diff --git a/tests/mpd/protocol/test_regression.py b/tests/mpd/protocol/test_regression.py index 6fb59afd..b8a5d1d5 100644 --- a/tests/mpd/protocol/test_regression.py +++ b/tests/mpd/protocol/test_regression.py @@ -18,14 +18,17 @@ class IssueGH17RegressionTest(protocol.BaseTestCase): - Press next until you get to the unplayable track """ def test(self): - self.core.tracklist.add([ + tracks = [ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:error'), Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f'), - ]) + ] + self.backend.library.dummy_library = tracks + self.core.tracklist.add(uris=[t.uri for t in tracks]).get() + random.seed(1) # Playlist order: abcfde self.send_request('play') @@ -59,9 +62,13 @@ class IssueGH18RegressionTest(protocol.BaseTestCase): """ def test(self): - self.core.tracklist.add([ + tracks = [ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), - Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) + Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f'), + ] + self.backend.library.dummy_library = tracks + self.core.tracklist.add(uris=[t.uri for t in tracks]).get() + random.seed(1) self.send_request('play') @@ -95,9 +102,13 @@ class IssueGH22RegressionTest(protocol.BaseTestCase): """ def test(self): - self.core.tracklist.add([ + tracks = [ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), - Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) + Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f'), + ] + self.backend.library.dummy_library = tracks + self.core.tracklist.add(uris=[t.uri for t in tracks]).get() + random.seed(1) self.send_request('play') @@ -124,9 +135,13 @@ class IssueGH69RegressionTest(protocol.BaseTestCase): def test(self): self.core.playlists.create('foo') - self.core.tracklist.add([ + + tracks = [ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), - Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) + Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f'), + ] + self.backend.library.dummy_library = tracks + self.core.tracklist.add(uris=[t.uri for t in tracks]).get() self.send_request('play') self.send_request('stop') diff --git a/tests/mpd/protocol/test_stored_playlists.py b/tests/mpd/protocol/test_stored_playlists.py index cca32b0d..6018686e 100644 --- a/tests/mpd/protocol/test_stored_playlists.py +++ b/tests/mpd/protocol/test_stored_playlists.py @@ -130,54 +130,78 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_load_appends_to_tracklist(self): - self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) + tracks = [ + Track(uri='dummy:a'), + Track(uri='dummy:b'), + Track(uri='dummy:c'), + Track(uri='dummy:d'), + Track(uri='dummy:e'), + ] + self.backend.library.dummy_library = tracks + self.core.tracklist.add(uris=['dummy:a', 'dummy:b']).get() + self.assertEqual(len(self.core.tracklist.tracks.get()), 2) self.backend.playlists.set_dummy_playlists([ - Playlist(name='A-list', uri='dummy:A-list', tracks=[ - Track(uri='c'), Track(uri='d'), Track(uri='e')])]) + Playlist(name='A-list', uri='dummy:A-list', tracks=tracks[2:])]) self.send_request('load "A-list"') tracks = self.core.tracklist.tracks.get() self.assertEqual(5, len(tracks)) - self.assertEqual('a', tracks[0].uri) - self.assertEqual('b', tracks[1].uri) - self.assertEqual('c', tracks[2].uri) - self.assertEqual('d', tracks[3].uri) - self.assertEqual('e', tracks[4].uri) + self.assertEqual('dummy:a', tracks[0].uri) + self.assertEqual('dummy:b', tracks[1].uri) + self.assertEqual('dummy:c', tracks[2].uri) + self.assertEqual('dummy:d', tracks[3].uri) + self.assertEqual('dummy:e', tracks[4].uri) self.assertInResponse('OK') def test_load_with_range_loads_part_of_playlist(self): - self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) + tracks = [ + Track(uri='dummy:a'), + Track(uri='dummy:b'), + Track(uri='dummy:c'), + Track(uri='dummy:d'), + Track(uri='dummy:e'), + ] + self.backend.library.dummy_library = tracks + self.core.tracklist.add(uris=['dummy:a', 'dummy:b']).get() + self.assertEqual(len(self.core.tracklist.tracks.get()), 2) self.backend.playlists.set_dummy_playlists([ - Playlist(name='A-list', uri='dummy:A-list', tracks=[ - Track(uri='c'), Track(uri='d'), Track(uri='e')])]) + Playlist(name='A-list', uri='dummy:A-list', tracks=tracks[2:])]) self.send_request('load "A-list" "1:2"') tracks = self.core.tracklist.tracks.get() self.assertEqual(3, len(tracks)) - self.assertEqual('a', tracks[0].uri) - self.assertEqual('b', tracks[1].uri) - self.assertEqual('d', tracks[2].uri) + self.assertEqual('dummy:a', tracks[0].uri) + self.assertEqual('dummy:b', tracks[1].uri) + self.assertEqual('dummy:d', tracks[2].uri) self.assertInResponse('OK') def test_load_with_range_without_end_loads_rest_of_playlist(self): - self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) + tracks = [ + Track(uri='dummy:a'), + Track(uri='dummy:b'), + Track(uri='dummy:c'), + Track(uri='dummy:d'), + Track(uri='dummy:e'), + ] + self.backend.library.dummy_library = tracks + self.core.tracklist.add(uris=['dummy:a', 'dummy:b']).get() + self.assertEqual(len(self.core.tracklist.tracks.get()), 2) self.backend.playlists.set_dummy_playlists([ - Playlist(name='A-list', uri='dummy:A-list', tracks=[ - Track(uri='c'), Track(uri='d'), Track(uri='e')])]) + Playlist(name='A-list', uri='dummy:A-list', tracks=tracks[2:])]) self.send_request('load "A-list" "1:"') tracks = self.core.tracklist.tracks.get() self.assertEqual(4, len(tracks)) - self.assertEqual('a', tracks[0].uri) - self.assertEqual('b', tracks[1].uri) - self.assertEqual('d', tracks[2].uri) - self.assertEqual('e', tracks[3].uri) + self.assertEqual('dummy:a', tracks[0].uri) + self.assertEqual('dummy:b', tracks[1].uri) + self.assertEqual('dummy:d', tracks[2].uri) + self.assertEqual('dummy:e', tracks[3].uri) self.assertInResponse('OK') def test_load_unknown_playlist_acks(self): From dc673d554c08daa37c576527ff3455c66882636e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 29 Mar 2015 23:02:27 +0200 Subject: [PATCH 294/314] tests: Ignore deprecated tracklist.add(tracks=...) in local tests Note, this is mostly because these tests are just core tests in disguise and need a lot more love than I can give them right now. --- tests/local/__init__.py | 6 +++++- tests/local/test_playback.py | 6 ++++++ tests/local/test_tracklist.py | 6 ++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/local/__init__.py b/tests/local/__init__.py index b1520768..bfd60044 100644 --- a/tests/local/__init__.py +++ b/tests/local/__init__.py @@ -1,5 +1,7 @@ from __future__ import absolute_import, unicode_literals +import warnings + def generate_song(i): return 'local:track:song%s.wav' % i @@ -7,7 +9,9 @@ def generate_song(i): def populate_tracklist(func): def wrapper(self): - self.tl_tracks = self.core.tracklist.add(self.tracks) + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', 'tracklist.add.*"tracks".*') + self.tl_tracks = self.core.tracklist.add(self.tracks) return func(self) wrapper.__name__ = func.__name__ diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index 6ea82f2d..28ded52a 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals import time import unittest +import warnings import mock @@ -55,8 +56,13 @@ class LocalPlaybackProviderTest(unittest.TestCase): assert self.tracks[0].length >= 2000, \ 'First song needs to be at least 2000 miliseconds' + self._warnings_filters = warnings.filters + warnings.filters = warnings.filters[:] + warnings.filterwarnings('ignore', 'tracklist.add.*"tracks".*') + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() + warnings.filters = self._warnings_filters def test_uri_scheme(self): self.assertNotIn('file', self.core.uri_schemes) diff --git a/tests/local/test_tracklist.py b/tests/local/test_tracklist.py index db5de58b..48257ff4 100644 --- a/tests/local/test_tracklist.py +++ b/tests/local/test_tracklist.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals import random import unittest +import warnings import pykka @@ -36,8 +37,13 @@ class LocalTracklistProviderTest(unittest.TestCase): assert len(self.tracks) == 3, 'Need three tracks to run tests.' + self._warnings_filters = warnings.filters + warnings.filters = warnings.filters[:] + warnings.filterwarnings('ignore', 'tracklist.add.*"tracks".*') + def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() + warnings.filters = self._warnings_filters def test_length(self): self.assertEqual(0, len(self.controller.tl_tracks)) From f4c93619d1bae7534cc2be5212e60137c22063f6 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 29 Mar 2015 23:04:02 +0200 Subject: [PATCH 295/314] core: Make core tracklist.add(tracks=...) deprecation safe --- mopidy/core/tracklist.py | 6 ++++++ tests/core/test_events.py | 14 ++++++------ tests/core/test_playback.py | 41 ++++++++++++++++++++++++++++++------ tests/core/test_tracklist.py | 39 +++++++++++++++++++--------------- 4 files changed, 69 insertions(+), 31 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 54206881..182cffba 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -333,6 +333,12 @@ class TracklistController(object): assert tracks is not None or uri is not None or uris is not None, \ 'tracks, uri or uris must be provided' + # TODO: assert that tracks are track instances + + if tracks: + warnings.warn('tracklist.add() "tracks" argument is deprecated.', + DeprecationWarning) + if uri: warnings.warn('tracklist.add() "uri" argument is deprecated.', DeprecationWarning) diff --git a/tests/core/test_events.py b/tests/core/test_events.py index a197972b..09244b0d 100644 --- a/tests/core/test_events.py +++ b/tests/core/test_events.py @@ -17,6 +17,8 @@ from tests import dummy_backend class BackendEventsTest(unittest.TestCase): def setUp(self): # noqa: N802 self.backend = dummy_backend.create_proxy() + self.backend.library.dummy_library = [ + Track(uri='dummy:a'), Track(uri='dummy:b')] with warnings.catch_warnings(): warnings.simplefilter('ignore') @@ -45,12 +47,12 @@ class BackendEventsTest(unittest.TestCase): def test_tracklist_add_sends_tracklist_changed_event(self, send): send.reset_mock() - self.core.tracklist.add([Track(uri='dummy:a')]).get() + self.core.tracklist.add(uris=['dummy:a']).get() self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_clear_sends_tracklist_changed_event(self, send): - self.core.tracklist.add([Track(uri='dummy:a')]).get() + self.core.tracklist.add(uris=['dummy:a']).get() send.reset_mock() self.core.tracklist.clear().get() @@ -58,8 +60,7 @@ class BackendEventsTest(unittest.TestCase): self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_move_sends_tracklist_changed_event(self, send): - self.core.tracklist.add( - [Track(uri='dummy:a'), Track(uri='dummy:b')]).get() + self.core.tracklist.add(uris=['dummy:a', 'dummy:b']).get() send.reset_mock() self.core.tracklist.move(0, 1, 1).get() @@ -67,7 +68,7 @@ class BackendEventsTest(unittest.TestCase): self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_remove_sends_tracklist_changed_event(self, send): - self.core.tracklist.add([Track(uri='dummy:a')]).get() + self.core.tracklist.add(uris=['dummy:a']).get() send.reset_mock() self.core.tracklist.remove(uri=['dummy:a']).get() @@ -75,8 +76,7 @@ class BackendEventsTest(unittest.TestCase): self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_shuffle_sends_tracklist_changed_event(self, send): - self.core.tracklist.add( - [Track(uri='dummy:a'), Track(uri='dummy:b')]).get() + self.core.tracklist.add(uris=['dummy:a', 'dummy:b']).get() send.reset_mock() self.core.tracklist.shuffle().get() diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 7c4db0d6..d09950b2 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -13,6 +13,7 @@ from tests import dummy_audio as audio # TODO: split into smaller easier to follow tests. setup is way to complex. +# TODO: just mock tracklist? class CorePlaybackTest(unittest.TestCase): def setUp(self): # noqa: N802 self.backend1 = mock.Mock() @@ -42,14 +43,32 @@ class CorePlaybackTest(unittest.TestCase): Track(uri='dummy1:c', length=None), # No duration ] + self.uris = [ + 'dummy1:a', 'dummy2:a', 'dummy3:a', 'dummy1:b', 'dummy1:c'] + self.core = core.Core(mixer=None, backends=[ self.backend1, self.backend2, self.backend3]) - self.core.tracklist.add(self.tracks) + + def lookup(uris): + result = {uri: [] for uri in uris} + for track in self.tracks: + if track.uri in result: + result[track.uri].append(track) + return result + + self.lookup_patcher = mock.patch.object(self.core.library, 'lookup') + self.lookup_mock = self.lookup_patcher.start() + self.lookup_mock.side_effect = lookup + + self.core.tracklist.add(uris=self.uris) self.tl_tracks = self.core.tracklist.tl_tracks self.unplayable_tl_track = self.tl_tracks[2] self.duration_less_tl_track = self.tl_tracks[4] + def tearDown(self): # noqa: N802 + self.lookup_patcher.stop() + def trigger_end_of_track(self): self.core.playback._on_end_of_track() @@ -136,7 +155,7 @@ class CorePlaybackTest(unittest.TestCase): self.playback2.change_track.return_value.get.return_value = False self.core.tracklist.clear() - self.core.tracklist.add(self.tracks[:2]) + self.core.tracklist.add(uris=self.uris[:2]) tl_tracks = self.core.tracklist.tl_tracks self.core.playback.play(tl_tracks[0]) @@ -591,11 +610,16 @@ class TestStream(unittest.TestCase): self.tracks = [Track(uri='dummy:a', length=1234), Track(uri='dummy:b', length=1234)] - self.core.tracklist.add(self.tracks) + self.lookup_patcher = mock.patch.object(self.core.library, 'lookup') + self.lookup_mock = self.lookup_patcher.start() + self.lookup_mock.return_value = {t.uri: [t] for t in self.tracks} + + self.core.tracklist.add(uris=[t.uri for t in self.tracks]) self.events = [] - self.patcher = mock.patch('mopidy.audio.listener.AudioListener.send') - self.send_mock = self.patcher.start() + self.send_patcher = mock.patch( + 'mopidy.audio.listener.AudioListener.send') + self.send_mock = self.send_patcher.start() def send(event, **kwargs): self.events.append((event, kwargs)) @@ -604,7 +628,8 @@ class TestStream(unittest.TestCase): def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() - self.patcher.stop() + self.lookup_patcher.stop() + self.send_patcher.stop() def replay_audio_events(self): while self.events: @@ -664,7 +689,9 @@ class CorePlaybackWithOldBackendTest(unittest.TestCase): b.uri_schemes.get.return_value = ['dummy1'] b.playback = mock.Mock(spec=backend.PlaybackProvider) b.playback.play.side_effect = TypeError + b.library.lookup.return_value.get.return_value = [ + Track(uri='dummy1:a', length=40000)] c = core.Core(mixer=None, backends=[b]) - c.tracklist.add([Track(uri='dummy1:a', length=40000)]) + c.tracklist.add(uris=['dummy1:a']) c.playback.play() # No TypeError == test passed. diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index c8cbf97f..60d70547 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -17,44 +17,49 @@ class TracklistTest(unittest.TestCase): Track(uri='dummy1:c', name='bar'), ] + def lookup(uri): + future = mock.Mock() + future.get.return_value = [t for t in self.tracks if t.uri == uri] + return future + self.backend = mock.Mock() self.backend.uri_schemes.get.return_value = ['dummy1'] self.library = mock.Mock(spec=backend.LibraryProvider) + self.library.lookup.side_effect = lookup self.backend.library = self.library self.core = core.Core(mixer=None, backends=[self.backend]) - self.tl_tracks = self.core.tracklist.add(self.tracks) + self.tl_tracks = self.core.tracklist.add(uris=[ + t.uri for t in self.tracks]) def test_add_by_uri_looks_up_uri_in_library(self): - track = Track(uri='dummy1:x', name='x') - self.library.lookup.return_value.get.return_value = [track] + self.library.lookup.reset_mock() + self.core.tracklist.clear() with warnings.catch_warnings(): warnings.filterwarnings('ignore', r'tracklist.add.*"uri".*') - tl_tracks = self.core.tracklist.add(uri='dummy1:x') + tl_tracks = self.core.tracklist.add(uris=['dummy1:a']) - self.library.lookup.assert_called_once_with('dummy1:x') + self.library.lookup.assert_called_once_with('dummy1:a') self.assertEqual(1, len(tl_tracks)) - self.assertEqual(track, tl_tracks[0].track) + self.assertEqual(self.tracks[0], tl_tracks[0].track) self.assertEqual(tl_tracks, self.core.tracklist.tl_tracks[-1:]) def test_add_by_uris_looks_up_uris_in_library(self): - track1 = Track(uri='dummy1:x', name='x') - track2 = Track(uri='dummy1:y1', name='y1') - track3 = Track(uri='dummy1:y2', name='y2') - self.library.lookup.return_value.get.side_effect = [ - [track1], [track2, track3]] + self.library.lookup.reset_mock() + self.core.tracklist.clear() - tl_tracks = self.core.tracklist.add(uris=['dummy1:x', 'dummy1:y']) + tl_tracks = self.core.tracklist.add(uris=[t.uri for t in self.tracks]) self.library.lookup.assert_has_calls([ - mock.call('dummy1:x'), - mock.call('dummy1:y'), + mock.call('dummy1:a'), + mock.call('dummy1:b'), + mock.call('dummy1:c'), ]) self.assertEqual(3, len(tl_tracks)) - self.assertEqual(track1, tl_tracks[0].track) - self.assertEqual(track2, tl_tracks[1].track) - self.assertEqual(track3, tl_tracks[2].track) + self.assertEqual(self.tracks[0], tl_tracks[0].track) + self.assertEqual(self.tracks[1], tl_tracks[1].track) + self.assertEqual(self.tracks[2], tl_tracks[2].track) self.assertEqual( tl_tracks, self.core.tracklist.tl_tracks[-len(tl_tracks):]) From d44e8ff6f7bff0fad1ab4d752dd8a8a55048db75 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 29 Mar 2015 23:27:42 +0200 Subject: [PATCH 296/314] core: Add warning when doing library.search with a query. Tests and code that rely on this are not yet "warnings safe". --- mopidy/core/library.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index f87c9aa7..fdafaed0 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -254,6 +254,11 @@ class LibraryController(object): 'library.search() with keyword argument query is deprecated', DeprecationWarning) + if not query: + warnings.warn( + 'library.search() with an empty "query" argument deprecated', + DeprecationWarning) + futures = {} for backend, backend_uris in self._get_backends_to_uris(uris).items(): futures[backend] = backend.library.search( From 385e9ac4211807ee93aa24c7921972558926026d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 30 Mar 2015 22:18:29 +0200 Subject: [PATCH 297/314] travis: Use new faster build infrastructure --- .travis.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 2058fcc7..41fc3c31 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,18 @@ +sudo: false + language: python python: - "2.7_with_system_site_packages" +addons: + apt: + sources: + - mopidy + packages: + - graphviz-dev + - mopidy + env: - TOX_ENV=py27 - TOX_ENV=py27-tornado23 @@ -11,10 +21,6 @@ env: - TOX_ENV=flake8 install: - - "wget -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add -" - - "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 mopidy graphviz-dev" - "pip install tox" script: From 860ea7cb81fdc9340b154b155c70e47fdf0fcc9c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 30 Mar 2015 22:39:56 +0200 Subject: [PATCH 298/314] travis: Use correct APT source name Ref APT source addition in travis-ci/apt-source-whitelist@af532b06aac870e428905bb2cdba0d47c1bf38c7 --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 41fc3c31..5f01f223 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,7 +8,7 @@ python: addons: apt: sources: - - mopidy + - mopidy-stable packages: - graphviz-dev - mopidy From bd1e822fea13a5b25c554be78e76b9629922159a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 30 Mar 2015 21:04:56 +0200 Subject: [PATCH 299/314] utils: Create warn and ignore deprecation warning helpers This moves all the deprecation warnings messages to a central place so that it is easy to match against them without having to redefine the same regex all over the place. Each message has been given a message id which is more or less module.func:extra-info. This is not intended to be parsed, just used in tests when using the ignore helper. --- mopidy/audio/actor.py | 6 +-- mopidy/core/library.py | 17 +++---- mopidy/core/playback.py | 26 +++++------ mopidy/core/playlists.py | 11 ++--- mopidy/core/tracklist.py | 26 +++++------ mopidy/mpd/protocol/current_playlist.py | 9 ++-- mopidy/mpd/protocol/playback.py | 7 +-- mopidy/utils/deprecation.py | 61 +++++++++++++++++++++++++ 8 files changed, 101 insertions(+), 62 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 7b3e7985..802c67d1 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -2,7 +2,6 @@ from __future__ import absolute_import, unicode_literals import logging import os -import warnings import gobject @@ -17,7 +16,7 @@ from mopidy import exceptions from mopidy.audio import playlists, utils from mopidy.audio.constants import PlaybackState from mopidy.audio.listener import AudioListener -from mopidy.utils import process +from mopidy.utils import deprecation, process logger = logging.getLogger(__name__) @@ -606,8 +605,7 @@ class Audio(pykka.ThreadingActor): .. deprecated:: 1.0 Use :meth:`emit_data` with a :class:`None` buffer instead. """ - warnings.warn('audio.emit_end_of_stream() is deprecated.', - DeprecationWarning) + deprecation.warn('audio.emit_end_of_stream') self._appsrc.push(None) def set_about_to_finish_callback(self, callback): diff --git a/mopidy/core/library.py b/mopidy/core/library.py index fdafaed0..995c5e58 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -4,10 +4,12 @@ import collections import logging import operator import urlparse -import warnings import pykka +from mopidy.utils import deprecation + + logger = logging.getLogger(__name__) @@ -133,7 +135,7 @@ class LibraryController(object): .. deprecated:: 1.0 Use :meth:`search` with ``exact`` set. """ - warnings.warn('library.find_exact() is deprecated', DeprecationWarning) + deprecation.warn('core.library.find_exact') return self.search(query=query, uris=uris, exact=True, **kwargs) def lookup(self, uri=None, uris=None): @@ -163,8 +165,7 @@ class LibraryController(object): raise ValueError("One of 'uri' or 'uris' must be set") if uri: - warnings.warn('library.lookup() "uri" argument is deprecated.', - DeprecationWarning) + deprecation.warn('core.library.lookup:uri_arg') if uri is not None: uris = [uri] @@ -250,14 +251,10 @@ class LibraryController(object): query = _normalize_query(query or kwargs) if kwargs: - warnings.warn( - 'library.search() with keyword argument query is deprecated', - DeprecationWarning) + deprecation.warn('core.library.search:kwargs_query') if not query: - warnings.warn( - 'library.search() with an empty "query" argument deprecated', - DeprecationWarning) + deprecation.warn('core.library.search:empty_query') futures = {} for backend, backend_uris in self._get_backends_to_uris(uris).items(): diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 61bbc60c..abf9ae8a 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -2,12 +2,10 @@ from __future__ import absolute_import, unicode_literals import logging import urlparse -import warnings from mopidy.audio import PlaybackState from mopidy.core import listener -from mopidy.utils.deprecation import deprecated_property - +from mopidy.utils import deprecation logger = logging.getLogger(__name__) @@ -48,7 +46,7 @@ class PlaybackController(object): """ self._current_tl_track = value - current_tl_track = deprecated_property(get_current_tl_track) + current_tl_track = deprecation.deprecated_property(get_current_tl_track) """ .. deprecated:: 1.0 Use :meth:`get_current_tl_track` instead. @@ -66,7 +64,7 @@ class PlaybackController(object): if tl_track is not None: return tl_track.track - current_track = deprecated_property(get_current_track) + current_track = deprecation.deprecated_property(get_current_track) """ .. deprecated:: 1.0 Use :meth:`get_current_track` instead. @@ -103,7 +101,7 @@ class PlaybackController(object): self._trigger_playback_state_changed(old_state, new_state) - state = deprecated_property(get_state, set_state) + state = deprecation.deprecated_property(get_state, set_state) """ .. deprecated:: 1.0 Use :meth:`get_state` and :meth:`set_state` instead. @@ -117,7 +115,7 @@ class PlaybackController(object): else: return 0 - time_position = deprecated_property(get_time_position) + time_position = deprecation.deprecated_property(get_time_position) """ .. deprecated:: 1.0 Use :meth:`get_time_position` instead. @@ -129,8 +127,7 @@ class PlaybackController(object): Use :meth:`core.mixer.get_volume() ` instead. """ - warnings.warn( - 'playback.get_volume() is deprecated', DeprecationWarning) + deprecation.warn('core.playback.get_volume') return self.core.mixer.get_volume() def set_volume(self, volume): @@ -139,11 +136,10 @@ class PlaybackController(object): Use :meth:`core.mixer.set_volume() ` instead. """ - warnings.warn( - 'playback.set_volume() is deprecated', DeprecationWarning) + deprecation.warn('core.playback.set_volume') return self.core.mixer.set_volume(volume) - volume = deprecated_property(get_volume, set_volume) + volume = deprecation.deprecated_property(get_volume, set_volume) """ .. deprecated:: 1.0 Use :meth:`core.mixer.get_volume() @@ -158,7 +154,7 @@ class PlaybackController(object): Use :meth:`core.mixer.get_mute() ` instead. """ - warnings.warn('playback.get_mute() is deprecated', DeprecationWarning) + deprecation.warn('core.playback.get_mute') return self.core.mixer.get_mute() def set_mute(self, mute): @@ -167,10 +163,10 @@ class PlaybackController(object): Use :meth:`core.mixer.set_mute() ` instead. """ - warnings.warn('playback.set_mute() is deprecated', DeprecationWarning) + deprecation.warn('core.playback.set_mute') return self.core.mixer.set_mute(mute) - mute = deprecated_property(get_mute, set_mute) + mute = deprecation.deprecated_property(get_mute, set_mute) """ .. deprecated:: 1.0 Use :meth:`core.mixer.get_mute() diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index b6f2e726..2c997d84 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -2,14 +2,12 @@ from __future__ import absolute_import, unicode_literals import logging import urlparse -import warnings import pykka from mopidy.core import listener from mopidy.models import Playlist -from mopidy.utils.deprecation import deprecated_property - +from mopidy.utils import deprecation logger = logging.getLogger(__name__) @@ -81,8 +79,7 @@ class PlaylistsController(object): .. deprecated:: 1.0 Use :meth:`as_list` and :meth:`get_items` instead. """ - warnings.warn( - 'playlists.get_playlists() is deprecated', DeprecationWarning) + deprecation.warn('core.playlists.get_playlists') playlist_refs = self.as_list() @@ -97,7 +94,7 @@ class PlaylistsController(object): return [ Playlist(uri=r.uri, name=r.name) for r in playlist_refs] - playlists = deprecated_property(get_playlists) + playlists = deprecation.deprecated_property(get_playlists) """ .. deprecated:: 1.0 Use :meth:`as_list` and :meth:`get_items` instead. @@ -170,7 +167,7 @@ class PlaylistsController(object): .. deprecated:: 1.0 Use :meth:`as_list` and filter yourself. """ - warnings.warn('playlists.filter() is deprecated', DeprecationWarning) + deprecation.warn('core.playlists.filter') criteria = criteria or kwargs matches = self.playlists diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 182cffba..9a251b75 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -3,13 +3,11 @@ from __future__ import absolute_import, unicode_literals import collections import logging import random -import warnings from mopidy import compat from mopidy.core import listener from mopidy.models import TlTrack -from mopidy.utils.deprecation import deprecated_property - +from mopidy.utils import deprecation logger = logging.getLogger(__name__) @@ -31,7 +29,7 @@ class TracklistController(object): """Get tracklist as list of :class:`mopidy.models.TlTrack`.""" return self._tl_tracks[:] - tl_tracks = deprecated_property(get_tl_tracks) + tl_tracks = deprecation.deprecated_property(get_tl_tracks) """ .. deprecated:: 1.0 Use :meth:`get_tl_tracks` instead. @@ -41,7 +39,7 @@ class TracklistController(object): """Get tracklist as list of :class:`mopidy.models.Track`.""" return [tl_track.track for tl_track in self._tl_tracks] - tracks = deprecated_property(get_tracks) + tracks = deprecation.deprecated_property(get_tracks) """ .. deprecated:: 1.0 Use :meth:`get_tracks` instead. @@ -51,7 +49,7 @@ class TracklistController(object): """Get length of the tracklist.""" return len(self._tl_tracks) - length = deprecated_property(get_length) + length = deprecation.deprecated_property(get_length) """ .. deprecated:: 1.0 Use :meth:`get_length` instead. @@ -71,7 +69,7 @@ class TracklistController(object): self.core.playback._on_tracklist_change() self._trigger_tracklist_changed() - version = deprecated_property(get_version) + version = deprecation.deprecated_property(get_version) """ .. deprecated:: 1.0 Use :meth:`get_version` instead. @@ -99,7 +97,7 @@ class TracklistController(object): self._trigger_options_changed() return setattr(self, '_consume', value) - consume = deprecated_property(get_consume, set_consume) + consume = deprecation.deprecated_property(get_consume, set_consume) """ .. deprecated:: 1.0 Use :meth:`get_consume` and :meth:`set_consume` instead. @@ -131,7 +129,7 @@ class TracklistController(object): random.shuffle(self._shuffled) return setattr(self, '_random', value) - random = deprecated_property(get_random, set_random) + random = deprecation.deprecated_property(get_random, set_random) """ .. deprecated:: 1.0 Use :meth:`get_random` and :meth:`set_random` instead. @@ -164,7 +162,7 @@ class TracklistController(object): self._trigger_options_changed() return setattr(self, '_repeat', value) - repeat = deprecated_property(get_repeat, set_repeat) + repeat = deprecation.deprecated_property(get_repeat, set_repeat) """ .. deprecated:: 1.0 Use :meth:`get_repeat` and :meth:`set_repeat` instead. @@ -194,7 +192,7 @@ class TracklistController(object): self._trigger_options_changed() return setattr(self, '_single', value) - single = deprecated_property(get_single, set_single) + single = deprecation.deprecated_property(get_single, set_single) """ .. deprecated:: 1.0 Use :meth:`get_single` and :meth:`set_single` instead. @@ -336,12 +334,10 @@ class TracklistController(object): # TODO: assert that tracks are track instances if tracks: - warnings.warn('tracklist.add() "tracks" argument is deprecated.', - DeprecationWarning) + deprecation.warn('core.tracklist.add:tracks_arg') if uri: - warnings.warn('tracklist.add() "uri" argument is deprecated.', - DeprecationWarning) + deprecation.warn('core.tracklist.add:uri_arg') if tracks is None: if uri is not None: diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index 080b6f2c..38ad4017 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -1,8 +1,7 @@ from __future__ import absolute_import, unicode_literals -import warnings - from mopidy.mpd import exceptions, protocol, translator +from mopidy.utils import deprecation @protocol.commands.add('add') @@ -163,8 +162,7 @@ def playlist(context): Do not use this, instead use ``playlistinfo``. """ - warnings.warn( - 'Do not use this, instead use playlistinfo', DeprecationWarning) + deprecation.warn('mpd.protocol.current_playlist.playlist') return playlistinfo(context) @@ -354,8 +352,7 @@ def swap(context, songpos1, songpos2): # TODO: do we need a tracklist.replace() context.core.tracklist.clear() - with warnings.catch_warnings(): - warnings.filterwarnings('ignore', 'tracklist.add.*"tracks".*') + with deprecation.ignore('core.tracklist.add:tracks_arg'): context.core.tracklist.add(tracks=tracks).get() diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index 86f2e36b..6beb4277 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -1,9 +1,8 @@ from __future__ import absolute_import, unicode_literals -import warnings - from mopidy.core import PlaybackState from mopidy.mpd import exceptions, protocol +from mopidy.utils import deprecation @protocol.commands.add('consume', state=protocol.BOOL) @@ -134,9 +133,7 @@ def pause(context, state=None): - Calls ``pause`` without any arguments to toogle pause. """ if state is None: - warnings.warn( - 'The use of pause command w/o the PAUSE argument is deprecated.', - DeprecationWarning) + deprecation.warn('mpd.protocol.playback.pause:state_arg') if (context.core.playback.state.get() == PlaybackState.PLAYING): context.core.playback.pause() diff --git a/mopidy/utils/deprecation.py b/mopidy/utils/deprecation.py index bf4756d7..a22a248c 100644 --- a/mopidy/utils/deprecation.py +++ b/mopidy/utils/deprecation.py @@ -1,5 +1,66 @@ from __future__ import unicode_literals +import contextlib +import re +import warnings + +# Messages used in deprecation warnings are collected here so we can target +# them easily when ignoring warnings. +_MESSAGES = { + # Deprecated features mpd: + 'mpd.protocol.playback.pause:state_arg': + 'The use of pause command w/o the PAUSE argument is deprecated.', + 'mpd.protocol.current_playlist.playlist': + 'Do not use this, instead use playlistinfo', + + # Deprecated features in audio: + 'audio.emit_end_of_stream': 'audio.emit_end_of_stream() is deprecated', + + # Deprecated features in core libary: + 'core.library.find_exact': 'library.find_exact() is deprecated', + 'core.library.lookup:uri_arg': + 'library.lookup() "uri" argument is deprecated', + 'core.library.search:kwargs_query': + 'library.search() with keyword argument query is deprecated', + 'core.library.search:empty_query': + 'library.search() with an empty "query" argument deprecated', + + # Deprecated features in core playback: + 'core.playback.get_mute': 'playback.get_mute() is deprecated', + 'core.playback.set_mute': 'playback.set_mute() is deprecated', + 'core.playback.get_volume': 'playback.get_volume() is deprecated', + 'core.playback.set_volume': 'playback.set_volume() is deprecated', + + # Deprecated features in core playlists: + 'core.playlists.filter': 'playlists.filter() is deprecated', + 'core.playlists.get_playlists': 'playlists.get_playlists() is deprecated', + + # Deprecated features in core tracklist: + 'core.tracklist.add:tracks_arg': + 'tracklist.add() "tracks" argument is deprecated', + 'core.tracklist.add:uri_arg': + 'tracklist.add() "uri" argument is deprecated', +} + + +def warn(msg_id): + warnings.warn(_MESSAGES.get(msg_id, msg_id), DeprecationWarning) + + +@contextlib.contextmanager +def ignore(ids=None): + with warnings.catch_warnings(): + if isinstance(ids, basestring): + ids = [ids] + + if ids: + for msg_id in ids: + msg = re.escape(_MESSAGES.get(msg_id, msg_id)) + warnings.filterwarnings('ignore', msg, DeprecationWarning) + else: + warnings.filterwarnings('ignore', category=DeprecationWarning) + yield + def deprecated_property( getter=None, setter=None, message='Property is deprecated'): From 9ede14f4a13e8c9b68a39a8a4e93d59b2538c437 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 30 Mar 2015 23:50:54 +0200 Subject: [PATCH 300/314] tests: Convert to using deprecation helpers across the board. --- tests/core/test_events.py | 5 +-- tests/core/test_library.py | 42 ++++++--------------- tests/core/test_playlists.py | 28 ++++---------- tests/core/test_tracklist.py | 5 +-- tests/local/__init__.py | 5 +-- tests/local/test_playback.py | 11 +++--- tests/local/test_tracklist.py | 11 +++--- tests/m3u/test_playlists.py | 16 +++----- tests/mpd/protocol/__init__.py | 5 +-- tests/mpd/protocol/test_current_playlist.py | 6 +-- tests/mpd/protocol/test_playback.py | 5 +-- tests/mpd/test_dispatcher.py | 5 +-- tests/mpd/test_status.py | 5 +-- tests/utils/test_jsonrpc.py | 6 +-- 14 files changed, 53 insertions(+), 102 deletions(-) diff --git a/tests/core/test_events.py b/tests/core/test_events.py index 09244b0d..443c1b7e 100644 --- a/tests/core/test_events.py +++ b/tests/core/test_events.py @@ -1,7 +1,6 @@ from __future__ import absolute_import, unicode_literals import unittest -import warnings import mock @@ -9,6 +8,7 @@ import pykka from mopidy import core from mopidy.models import Track +from mopidy.utils import deprecation from tests import dummy_backend @@ -20,8 +20,7 @@ class BackendEventsTest(unittest.TestCase): self.backend.library.dummy_library = [ Track(uri='dummy:a'), Track(uri='dummy:b')] - with warnings.catch_warnings(): - warnings.simplefilter('ignore') + with deprecation.ignore(): self.core = core.Core.start(backends=[self.backend]).proxy() def tearDown(self): # noqa: N802 diff --git a/tests/core/test_library.py b/tests/core/test_library.py index eb3d4dc3..7e3c8698 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -1,12 +1,12 @@ from __future__ import absolute_import, unicode_literals import unittest -import warnings import mock from mopidy import backend, core from mopidy.models import Image, Ref, SearchResult, Track +from mopidy.utils import deprecation class BaseCoreLibraryTest(unittest.TestCase): @@ -273,15 +273,9 @@ class CoreLibraryTest(BaseCoreLibraryTest): class DeprecatedFindExactCoreLibraryTest(BaseCoreLibraryTest): - def setUp(self): # noqa: N802 - super(DeprecatedFindExactCoreLibraryTest, self).setUp() - self._warnings_filters = warnings.filters - warnings.filters = warnings.filters[:] - warnings.filterwarnings('ignore', '.*library.find_exact.*') - - def tearDown(self): # noqa: N802 - super(DeprecatedFindExactCoreLibraryTest, self).tearDown() - warnings.filters = self._warnings_filters + def run(self, result=None): + with deprecation.ignore('core.library.find_exact'): + return super(DeprecatedFindExactCoreLibraryTest, self).run(result) def test_find_exact_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') @@ -360,15 +354,9 @@ class DeprecatedFindExactCoreLibraryTest(BaseCoreLibraryTest): class DeprecatedLookupCoreLibraryTest(BaseCoreLibraryTest): - def setUp(self): # noqa: N802 - super(DeprecatedLookupCoreLibraryTest, self).setUp() - self._warnings_filters = warnings.filters - warnings.filters = warnings.filters[:] - warnings.filterwarnings('ignore', 'library.lookup.*"uri" argument.*') - - def tearDown(self): # noqa: N802 - super(DeprecatedLookupCoreLibraryTest, self).tearDown() - warnings.filters = self._warnings_filters + def run(self, result=None): + with deprecation.ignore('core.library.lookup:uri_arg'): + return super(DeprecatedLookupCoreLibraryTest, self).run(result) def test_lookup_selects_dummy1_backend(self): self.core.library.lookup('dummy1:a') @@ -391,6 +379,10 @@ class DeprecatedLookupCoreLibraryTest(BaseCoreLibraryTest): class LegacyFindExactToSearchLibraryTest(unittest.TestCase): + def run(self, result=None): + with deprecation.ignore('core.library.find_exact'): + return super(LegacyFindExactToSearchLibraryTest, self).run(result) + def setUp(self): # noqa: N802 self.backend = mock.Mock() self.backend.actor_ref.actor_class.__name__ = 'DummyBackend' @@ -398,18 +390,8 @@ class LegacyFindExactToSearchLibraryTest(unittest.TestCase): self.backend.library = mock.Mock(spec=backend.LibraryProvider) self.core = core.Core(mixer=None, backends=[self.backend]) - self._warnings_filters = warnings.filters - warnings.filters = warnings.filters[:] - warnings.filterwarnings('ignore', '.*library.find_exact.*') - - def tearDown(self): # noqa: N802 - warnings.filters = self._warnings_filters - def test_core_find_exact_calls_backend_search_with_exact(self): - with warnings.catch_warnings(): - warnings.simplefilter('ignore') - self.core.library.find_exact(query={'any': ['a']}) - + self.core.library.find_exact(query={'any': ['a']}) self.backend.library.search.assert_called_once_with( query=dict(any=['a']), uris=None, exact=True) diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index dbe05733..b842ae44 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -1,12 +1,12 @@ from __future__ import absolute_import, unicode_literals import unittest -import warnings import mock from mopidy import backend, core from mopidy.models import Playlist, Ref, Track +from mopidy.utils import deprecation class BasePlaylistsTest(unittest.TestCase): @@ -231,16 +231,10 @@ class PlaylistTest(BasePlaylistsTest): class DeprecatedFilterPlaylistsTest(BasePlaylistsTest): - def setUp(self): # noqa: N802 - super(DeprecatedFilterPlaylistsTest, self).setUp() - self._warnings_filters = warnings.filters - warnings.filters = warnings.filters[:] - warnings.filterwarnings('ignore', '.*filter.*') - warnings.filterwarnings('ignore', '.*get_playlists.*') - - def tearDown(self): # noqa: N802 - super(DeprecatedFilterPlaylistsTest, self).tearDown() - warnings.filters = self._warnings_filters + def run(self, result=None): + with deprecation.ignore(ids=['core.playlists.filter', + 'core.playlists.get_playlists']): + return super(DeprecatedFilterPlaylistsTest, self).run(result) def test_filter_returns_matching_playlists(self): result = self.core.playlists.filter(name='A') @@ -254,15 +248,9 @@ class DeprecatedFilterPlaylistsTest(BasePlaylistsTest): class DeprecatedGetPlaylistsTest(BasePlaylistsTest): - def setUp(self): # noqa: N802 - super(DeprecatedGetPlaylistsTest, self).setUp() - self._warnings_filters = warnings.filters - warnings.filters = warnings.filters[:] - warnings.filterwarnings('ignore', '.*get_playlists.*') - - def tearDown(self): # noqa: N802 - super(DeprecatedGetPlaylistsTest, self).tearDown() - warnings.filters = self._warnings_filters + def run(self, result=None): + with deprecation.ignore('core.playlists.get_playlists'): + return super(DeprecatedGetPlaylistsTest, self).run(result) def test_get_playlists_combines_result_from_backends(self): result = self.core.playlists.get_playlists() diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 60d70547..96de0f80 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -1,12 +1,12 @@ from __future__ import absolute_import, unicode_literals import unittest -import warnings import mock from mopidy import backend, core from mopidy.models import Track +from mopidy.utils import deprecation class TracklistTest(unittest.TestCase): @@ -36,8 +36,7 @@ class TracklistTest(unittest.TestCase): self.library.lookup.reset_mock() self.core.tracklist.clear() - with warnings.catch_warnings(): - warnings.filterwarnings('ignore', r'tracklist.add.*"uri".*') + with deprecation.ignore('core.tracklist.add:uri_arg'): tl_tracks = self.core.tracklist.add(uris=['dummy1:a']) self.library.lookup.assert_called_once_with('dummy1:a') diff --git a/tests/local/__init__.py b/tests/local/__init__.py index bfd60044..3841a1e4 100644 --- a/tests/local/__init__.py +++ b/tests/local/__init__.py @@ -1,6 +1,6 @@ from __future__ import absolute_import, unicode_literals -import warnings +from mopidy.utils import deprecation def generate_song(i): @@ -9,8 +9,7 @@ def generate_song(i): def populate_tracklist(func): def wrapper(self): - with warnings.catch_warnings(): - warnings.filterwarnings('ignore', 'tracklist.add.*"tracks".*') + with deprecation.ignore('core.tracklist.add:tracks_arg'): self.tl_tracks = self.core.tracklist.add(self.tracks) return func(self) diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index 28ded52a..2bda46d3 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -2,7 +2,6 @@ from __future__ import absolute_import, unicode_literals import time import unittest -import warnings import mock @@ -12,6 +11,7 @@ from mopidy import core from mopidy.core import PlaybackState from mopidy.local import actor from mopidy.models import Track +from mopidy.utils import deprecation from tests import dummy_audio, path_to_data_dir from tests.local import generate_song, populate_tracklist @@ -43,6 +43,10 @@ class LocalPlaybackProviderTest(unittest.TestCase): def trigger_end_of_track(self): self.playback._on_end_of_track() + def run(self, result=None): + with deprecation.ignore('core.tracklist.add:tracks_arg'): + return super(LocalPlaybackProviderTest, self).run(result) + def setUp(self): # noqa: N802 self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( @@ -56,13 +60,8 @@ class LocalPlaybackProviderTest(unittest.TestCase): assert self.tracks[0].length >= 2000, \ 'First song needs to be at least 2000 miliseconds' - self._warnings_filters = warnings.filters - warnings.filters = warnings.filters[:] - warnings.filterwarnings('ignore', 'tracklist.add.*"tracks".*') - def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() - warnings.filters = self._warnings_filters def test_uri_scheme(self): self.assertNotIn('file', self.core.uri_schemes) diff --git a/tests/local/test_tracklist.py b/tests/local/test_tracklist.py index 48257ff4..22d4c954 100644 --- a/tests/local/test_tracklist.py +++ b/tests/local/test_tracklist.py @@ -2,7 +2,6 @@ from __future__ import absolute_import, unicode_literals import random import unittest -import warnings import pykka @@ -10,6 +9,7 @@ from mopidy import core from mopidy.core import PlaybackState from mopidy.local import actor from mopidy.models import Playlist, TlTrack, Track +from mopidy.utils import deprecation from tests import dummy_audio, path_to_data_dir from tests.local import generate_song, populate_tracklist @@ -27,6 +27,10 @@ class LocalTracklistProviderTest(unittest.TestCase): tracks = [ Track(uri=generate_song(i), length=4464) for i in range(1, 4)] + def run(self, result=None): + with deprecation.ignore('core.tracklist.add:tracks_arg'): + return super(LocalTracklistProviderTest, self).run(result) + def setUp(self): # noqa: N802 self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( @@ -37,13 +41,8 @@ class LocalTracklistProviderTest(unittest.TestCase): assert len(self.tracks) == 3, 'Need three tracks to run tests.' - self._warnings_filters = warnings.filters - warnings.filters = warnings.filters[:] - warnings.filterwarnings('ignore', 'tracklist.add.*"tracks".*') - def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() - warnings.filters = self._warnings_filters def test_length(self): self.assertEqual(0, len(self.controller.tl_tracks)) diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index cf3265c3..ecb3d40e 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -4,7 +4,6 @@ import os import shutil import tempfile import unittest -import warnings import pykka @@ -12,6 +11,7 @@ from mopidy import core from mopidy.m3u import actor from mopidy.m3u.translator import playlist_uri_to_path from mopidy.models import Playlist, Track +from mopidy.utils import deprecation from tests import dummy_audio, path_to_data_dir from tests.m3u import generate_song @@ -272,16 +272,10 @@ class M3UPlaylistsProviderTest(unittest.TestCase): class DeprecatedM3UPlaylistsProviderTest(M3UPlaylistsProviderTest): - def setUp(self): # noqa: N802 - super(DeprecatedM3UPlaylistsProviderTest, self).setUp() - self._warnings_filters = warnings.filters - warnings.filters = warnings.filters[:] - warnings.filterwarnings('ignore', '.*filter.*') - warnings.filterwarnings('ignore', '.*get_playlists.*') - - def tearDown(self): # noqa: N802 - super(DeprecatedM3UPlaylistsProviderTest, self).tearDown() - warnings.filters = self._warnings_filters + def run(self, result=None): + with deprecation.ignore(ids=['core.playlists.filter', + 'core.playlists.get_playlists']): + return super(DeprecatedM3UPlaylistsProviderTest, self).run(result) def test_filter_without_criteria(self): self.assertEqual(self.core.playlists.get_playlists(), diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index cbbc1991..9ebe99b0 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -1,7 +1,6 @@ from __future__ import absolute_import, unicode_literals import unittest -import warnings import mock @@ -9,6 +8,7 @@ import pykka from mopidy import core from mopidy.mpd import session, uri_mapper +from mopidy.utils import deprecation from tests import dummy_backend, dummy_mixer @@ -42,8 +42,7 @@ class BaseTestCase(unittest.TestCase): self.mixer = None self.backend = dummy_backend.create_proxy() - with warnings.catch_warnings(): - warnings.simplefilter('ignore') + with deprecation.ignore(): self.core = core.Core.start( mixer=self.mixer, backends=[self.backend]).proxy() diff --git a/tests/mpd/protocol/test_current_playlist.py b/tests/mpd/protocol/test_current_playlist.py index 84d905be..4fa1926a 100644 --- a/tests/mpd/protocol/test_current_playlist.py +++ b/tests/mpd/protocol/test_current_playlist.py @@ -1,8 +1,7 @@ from __future__ import absolute_import, unicode_literals -import warnings - from mopidy.models import Ref, Track +from mopidy.utils import deprecation from tests.mpd import protocol @@ -233,8 +232,7 @@ class PlaylistIdCommandTest(BasePopulatedTracklistTestCase): class PlaylistInfoCommandTest(BasePopulatedTracklistTestCase): def test_playlist_returns_same_as_playlistinfo(self): - with warnings.catch_warnings(): - warnings.filterwarnings('ignore', message='.*playlistinfo.*') + with deprecation.ignore('mpd.protocol.current_playlist.playlist'): playlist_response = self.send_request('playlist') playlistinfo_response = self.send_request('playlistinfo') diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index 4d6e727d..328fe136 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -1,10 +1,10 @@ from __future__ import absolute_import, unicode_literals import unittest -import warnings from mopidy.core import PlaybackState from mopidy.models import Track +from mopidy.utils import deprecation from tests.mpd import protocol @@ -203,8 +203,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') - with warnings.catch_warnings(): - warnings.filterwarnings('ignore', message='.*pause command w/o.*') + with deprecation.ignore('mpd.protocol.playback.pause:state_arg'): self.send_request('pause') self.assertEqual(PAUSED, self.core.playback.state.get()) self.assertInResponse('OK') diff --git a/tests/mpd/test_dispatcher.py b/tests/mpd/test_dispatcher.py index 2c21df67..9e2838b7 100644 --- a/tests/mpd/test_dispatcher.py +++ b/tests/mpd/test_dispatcher.py @@ -1,13 +1,13 @@ from __future__ import absolute_import, unicode_literals import unittest -import warnings import pykka from mopidy import core from mopidy.mpd.dispatcher import MpdDispatcher from mopidy.mpd.exceptions import MpdAckError +from mopidy.utils import deprecation from tests import dummy_backend @@ -23,8 +23,7 @@ class MpdDispatcherTest(unittest.TestCase): self.backend = dummy_backend.create_proxy() self.dispatcher = MpdDispatcher(config=config) - with warnings.catch_warnings(): - warnings.simplefilter('ignore') + with deprecation.ignore(): self.core = core.Core.start(backends=[self.backend]).proxy() def tearDown(self): # noqa: N802 diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index 675626f6..080cbfc6 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -1,7 +1,6 @@ from __future__ import absolute_import, unicode_literals import unittest -import warnings import pykka @@ -10,6 +9,7 @@ from mopidy.core import PlaybackState from mopidy.models import Track from mopidy.mpd import dispatcher from mopidy.mpd.protocol import status +from mopidy.utils import deprecation from tests import dummy_backend, dummy_mixer @@ -27,8 +27,7 @@ class StatusHandlerTest(unittest.TestCase): self.mixer = dummy_mixer.create_proxy() self.backend = dummy_backend.create_proxy() - with warnings.catch_warnings(): - warnings.simplefilter('ignore') + with deprecation.ignore(): self.core = core.Core.start( mixer=self.mixer, backends=[self.backend]).proxy() diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index 890c2aba..411c0db4 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -2,14 +2,13 @@ from __future__ import absolute_import, unicode_literals import json import unittest -import warnings import mock import pykka from mopidy import core, models -from mopidy.utils import jsonrpc +from mopidy.utils import deprecation, jsonrpc from tests import dummy_backend @@ -55,8 +54,7 @@ class JsonRpcTestBase(unittest.TestCase): self.backend = dummy_backend.create_proxy() self.calc = Calculator() - with warnings.catch_warnings(): - warnings.simplefilter('ignore') + with deprecation.ignore(): self.core = core.Core.start(backends=[self.backend]).proxy() self.jrw = jsonrpc.JsonRpcWrapper( From f78973074eb67939018cdb25f821252cc028c278 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 30 Mar 2015 23:51:36 +0200 Subject: [PATCH 301/314] mpd: Only loop over tracks in lsinfo/listallinfo --- mopidy/mpd/protocol/music_db.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 3f1dd2bc..fc726255 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -338,7 +338,7 @@ def listallinfo(context, uri=None): if not lookup_future: result.append(('directory', path)) else: - for uri, tracks in lookup_future.get().items(): + for tracks in lookup_future.get().values(): for track in tracks: result.extend(translator.track_to_mpd_format(track)) return result @@ -366,7 +366,7 @@ def lsinfo(context, uri=None): if not lookup_future: result.append(('directory', path.lstrip('/'))) else: - for uri, tracks in lookup_future.get().items(): + for tracks in lookup_future.get().values(): if tracks: result.extend(translator.track_to_mpd_format(tracks[0])) From 887c0774fb2987e08a153fc6b138d07e78e3a3f1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 30 Mar 2015 23:56:59 +0200 Subject: [PATCH 302/314] review: Update wording deprecation messages --- mopidy/utils/deprecation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/utils/deprecation.py b/mopidy/utils/deprecation.py index a22a248c..31d0fdc1 100644 --- a/mopidy/utils/deprecation.py +++ b/mopidy/utils/deprecation.py @@ -21,9 +21,9 @@ _MESSAGES = { 'core.library.lookup:uri_arg': 'library.lookup() "uri" argument is deprecated', 'core.library.search:kwargs_query': - 'library.search() with keyword argument query is deprecated', + 'library.search() with "kwargs" as query is deprecated', 'core.library.search:empty_query': - 'library.search() with an empty "query" argument deprecated', + 'library.search() with empty "query" is argument deprecated', # Deprecated features in core playback: 'core.playback.get_mute': 'playback.get_mute() is deprecated', From e2faf7f08370b7e42dcfc1a92ac5209ebcd4ea24 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 31 Mar 2015 00:01:34 +0200 Subject: [PATCH 303/314] docs: Update docstring and changelog --- docs/changelog.rst | 15 +++++++++++++++ mopidy/core/library.py | 15 +++++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index b976c169..47b6bb8c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,21 @@ Changelog This changelog is used to track all major changes to Mopidy. +v1.1.0 (unreleased) +=================== + +Core API +-------- + +- Calling :meth:`mopidy.core.library.LibraryController.search`` with ``kwargs`` + as the query is no longer supported (PR: :issue:`1090`) + +Internal changes +---------------- + +- Tests have been cleaned up to stop using deprecated APIs where feasible. + (Partial fix: :issue:`1083`, PR: :issue:`1090`) + v1.0.0 (2015-03-25) =================== diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 995c5e58..c787e013 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -209,12 +209,6 @@ class LibraryController(object): """ Search the library for tracks where ``field`` contains ``values``. - .. deprecated:: 1.0 - Previously, if the query was empty, and the backend could support - it, all available tracks were returned. This has not changed, but - it is strongly discouraged. No new code should rely on this - behavior. - If ``uris`` is given, the search is limited to results from within the URI roots. For example passing ``uris=['file:']`` will limit the search to the local backend. @@ -247,6 +241,15 @@ class LibraryController(object): .. versionadded:: 1.0 The ``exact`` keyword argument, which replaces :meth:`find_exact`. + + .. deprecated:: 1.0 + Previously, if the query was empty, and the backend could support + it, all available tracks were returned. This has not changed, but + it is strongly discouraged. No new code should rely on this + behavior. + + .. deprecated:: 1.1 + Providing the search query via ``kwargs`` is no longer supported. """ query = _normalize_query(query or kwargs) From 28237df30304bcb939f761fb4cf91cc36b35b5bf Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 31 Mar 2015 21:04:23 +0200 Subject: [PATCH 304/314] core: Fix deprecation message --- mopidy/utils/deprecation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/utils/deprecation.py b/mopidy/utils/deprecation.py index 31d0fdc1..57042347 100644 --- a/mopidy/utils/deprecation.py +++ b/mopidy/utils/deprecation.py @@ -23,7 +23,7 @@ _MESSAGES = { 'core.library.search:kwargs_query': 'library.search() with "kwargs" as query is deprecated', 'core.library.search:empty_query': - 'library.search() with empty "query" is argument deprecated', + 'library.search() with empty "query" argument deprecated', # Deprecated features in core playback: 'core.playback.get_mute': 'playback.get_mute() is deprecated', From 2bc63ec0279e4031486993ede899e9689509fa16 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 2 Apr 2015 20:53:29 +0200 Subject: [PATCH 305/314] audio: Skip MP3 tests if missing plugin --- tests/audio/test_scan.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index b2937a3f..c3fb4c47 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -41,17 +41,26 @@ class ScannerTest(unittest.TestCase): name = path_to_data_dir(name) self.assertEqual(self.tags[name][key], value) + def check_if_missing_plugin(self): + if any(['missing a plug-in' in str(e) for e in self.errors.values()]): + raise unittest.SkipTest('Missing MP3 support?') + def test_tags_is_set(self): self.scan(self.find('scanner/simple')) self.assert_(self.tags) def test_errors_is_not_set(self): self.scan(self.find('scanner/simple')) + + self.check_if_missing_plugin() + self.assert_(not self.errors) def test_duration_is_set(self): self.scan(self.find('scanner/simple')) + self.check_if_missing_plugin() + self.assertEqual( self.durations[path_to_data_dir('scanner/simple/song1.mp3')], 4680) self.assertEqual( @@ -59,16 +68,25 @@ class ScannerTest(unittest.TestCase): def test_artist_is_set(self): self.scan(self.find('scanner/simple')) + + self.check_if_missing_plugin() + self.check('scanner/simple/song1.mp3', 'artist', ['name']) self.check('scanner/simple/song1.ogg', 'artist', ['name']) def test_album_is_set(self): self.scan(self.find('scanner/simple')) + + self.check_if_missing_plugin() + self.check('scanner/simple/song1.mp3', 'album', ['albumname']) self.check('scanner/simple/song1.ogg', 'album', ['albumname']) def test_track_is_set(self): self.scan(self.find('scanner/simple')) + + self.check_if_missing_plugin() + self.check('scanner/simple/song1.mp3', 'title', ['trackname']) self.check('scanner/simple/song1.ogg', 'title', ['trackname']) @@ -82,6 +100,9 @@ class ScannerTest(unittest.TestCase): def test_log_file_that_gst_thinks_is_mpeg_1_is_ignored(self): self.scan([path_to_data_dir('scanner/example.log')]) + + self.check_if_missing_plugin() + self.assertLess( self.durations[path_to_data_dir('scanner/example.log')], 100) From c4940cbea2468226acda623c54b0172ed2cde408 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Apr 2015 00:05:26 +0200 Subject: [PATCH 306/314] autopep8: Add space after class signature/docstring --- docs/conf.py | 1 + mopidy/audio/actor.py | 7 +++++++ mopidy/audio/constants.py | 1 + mopidy/audio/listener.py | 1 + mopidy/audio/playlists.py | 1 + mopidy/audio/scan.py | 1 + mopidy/backend.py | 5 +++++ mopidy/commands.py | 5 +++++ mopidy/config/__init__.py | 1 + mopidy/config/schemas.py | 3 +++ mopidy/config/types.py | 18 ++++++++++++++++++ mopidy/core/actor.py | 1 + mopidy/core/listener.py | 1 + mopidy/exceptions.py | 2 ++ mopidy/ext.py | 2 ++ mopidy/http/handlers.py | 3 +++ mopidy/listener.py | 1 + mopidy/local/__init__.py | 1 + mopidy/local/commands.py | 2 ++ mopidy/local/library.py | 1 + mopidy/local/playback.py | 1 + mopidy/m3u/library.py | 1 + mopidy/mixer.py | 2 ++ mopidy/models.py | 11 +++++++++++ mopidy/mpd/actor.py | 1 + mopidy/mpd/dispatcher.py | 2 ++ mopidy/mpd/exceptions.py | 3 +++ mopidy/mpd/protocol/__init__.py | 1 + mopidy/mpd/session.py | 1 + mopidy/mpd/uri_mapper.py | 1 + mopidy/stream/actor.py | 2 ++ mopidy/utils/jsonrpc.py | 3 +++ mopidy/utils/log.py | 3 +++ mopidy/utils/network.py | 3 +++ mopidy/utils/path.py | 1 + mopidy/utils/process.py | 1 + mopidy/zeroconf.py | 1 + tests/__init__.py | 1 + tests/audio/test_actor.py | 6 ++++++ tests/audio/test_listener.py | 1 + tests/audio/test_playlists.py | 1 + tests/audio/test_scan.py | 1 + tests/audio/test_utils.py | 1 + tests/backend/test_listener.py | 1 + tests/config/test_config.py | 2 ++ tests/config/test_schemas.py | 3 +++ tests/config/test_types.py | 10 ++++++++++ tests/config/test_validator.py | 4 ++++ tests/core/test_actor.py | 1 + tests/core/test_events.py | 1 + tests/core/test_library.py | 5 +++++ tests/core/test_listener.py | 1 + tests/core/test_mixer.py | 4 ++++ tests/core/test_playback.py | 3 +++ tests/core/test_playlists.py | 4 ++++ tests/core/test_tracklist.py | 1 + tests/dummy_audio.py | 1 + tests/dummy_backend.py | 3 +++ tests/http/test_handlers.py | 2 ++ tests/http/test_server.py | 8 ++++++++ tests/local/test_search.py | 1 + tests/m3u/test_playlists.py | 1 + tests/m3u/test_translator.py | 1 + tests/mpd/protocol/__init__.py | 1 + tests/mpd/protocol/test_authentication.py | 2 ++ tests/mpd/protocol/test_channels.py | 1 + tests/mpd/protocol/test_command_list.py | 1 + tests/mpd/protocol/test_connection.py | 1 + tests/mpd/protocol/test_current_playlist.py | 11 +++++++++++ tests/mpd/protocol/test_idle.py | 1 + tests/mpd/protocol/test_music_db.py | 5 +++++ tests/mpd/protocol/test_playback.py | 2 ++ tests/mpd/protocol/test_reflection.py | 2 ++ tests/mpd/protocol/test_regression.py | 7 +++++++ tests/mpd/protocol/test_status.py | 1 + tests/mpd/protocol/test_stickers.py | 1 + tests/mpd/protocol/test_stored_playlists.py | 1 + tests/mpd/test_commands.py | 2 ++ tests/mpd/test_dispatcher.py | 1 + tests/mpd/test_exceptions.py | 1 + tests/mpd/test_status.py | 1 + tests/mpd/test_tokenizer.py | 1 + tests/mpd/test_translator.py | 1 + tests/stream/test_library.py | 1 + tests/test_commands.py | 5 +++++ tests/test_exceptions.py | 1 + tests/test_ext.py | 1 + tests/test_help.py | 1 + tests/test_mixer.py | 1 + tests/test_models.py | 9 +++++++++ tests/test_version.py | 1 + tests/utils/network/test_connection.py | 1 + tests/utils/network/test_lineprotocol.py | 1 + tests/utils/network/test_server.py | 1 + tests/utils/network/test_utils.py | 3 +++ tests/utils/test_deps.py | 1 + tests/utils/test_encoding.py | 1 + tests/utils/test_jsonrpc.py | 10 ++++++++++ tests/utils/test_path.py | 6 ++++++ 99 files changed, 252 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index fa75dd79..96209182 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,6 +15,7 @@ sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + '/../')) class Mock(object): + def __init__(self, *args, **kwargs): pass diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 802c67d1..19d52dc4 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -55,7 +55,9 @@ PLAYBIN_FLAGS = (1 << 1) | (1 << 4) class _Signals(object): + """Helper for tracking gobject signal registrations""" + def __init__(self): self._ids = {} @@ -84,7 +86,9 @@ class _Signals(object): # TODO: expose this as a property on audio? class _Appsrc(object): + """Helper class for dealing with appsrc based playback.""" + def __init__(self): self._signals = _Signals() self.reset() @@ -151,6 +155,7 @@ class _Appsrc(object): # TODO: expose this as a property on audio when #790 gets further along. class _Outputs(gst.Bin): + def __init__(self): gst.Bin.__init__(self) @@ -250,6 +255,7 @@ class SoftwareMixer(object): class _Handler(object): + def __init__(self, audio): self._audio = audio self._element = None @@ -418,6 +424,7 @@ class _Handler(object): # TODO: create a player class which replaces the actors internals class Audio(pykka.ThreadingActor): + """ Audio output through `GStreamer `_. """ diff --git a/mopidy/audio/constants.py b/mopidy/audio/constants.py index 718fde1b..bdcdf29f 100644 --- a/mopidy/audio/constants.py +++ b/mopidy/audio/constants.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals class PlaybackState(object): + """ Enum of playback states. """ diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py index 280d4f86..e4e3f427 100644 --- a/mopidy/audio/listener.py +++ b/mopidy/audio/listener.py @@ -4,6 +4,7 @@ from mopidy import listener class AudioListener(listener.Listener): + """ Marker interface for recipients of events sent by the audio actor. diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py index 61bcb7a1..58c7fe24 100644 --- a/mopidy/audio/playlists.py +++ b/mopidy/audio/playlists.py @@ -136,6 +136,7 @@ def register_typefinders(): class BasePlaylistElement(gst.Bin): + """Base class for creating GStreamer elements for playlist support. This element performs the following steps: diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 4234e748..384b4197 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -21,6 +21,7 @@ _RAW_AUDIO = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') # TODO: replace with a scan(uri, timeout=1000, proxy_config=None)? class Scanner(object): + """ Helper to get tags and other relevant info from URIs. diff --git a/mopidy/backend.py b/mopidy/backend.py index 63184853..fe8676ca 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -4,6 +4,7 @@ from mopidy import listener, models class Backend(object): + """Backend API If the backend has problems during initialization it should raise @@ -59,6 +60,7 @@ class Backend(object): class LibraryProvider(object): + """ :param backend: backend the controller is a part of :type backend: :class:`mopidy.backend.Backend` @@ -148,6 +150,7 @@ class LibraryProvider(object): class PlaybackProvider(object): + """ :param audio: the audio actor :type audio: actor proxy to an instance of :class:`mopidy.audio.Audio` @@ -280,6 +283,7 @@ class PlaybackProvider(object): class PlaylistsProvider(object): + """ A playlist provider exposes a collection of playlists, methods to create/change/delete playlists in this collection, and lookup of any @@ -391,6 +395,7 @@ class PlaylistsProvider(object): class BackendListener(listener.Listener): + """ Marker interface for recipients of events sent by the backend actors. diff --git a/mopidy/commands.py b/mopidy/commands.py index dd91f5de..e00fca3f 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -38,6 +38,7 @@ def config_override_type(value): class _ParserError(Exception): + def __init__(self, message): self.message = message @@ -47,11 +48,13 @@ class _HelpError(Exception): class _ArgumentParser(argparse.ArgumentParser): + def error(self, message): raise _ParserError(message) class _HelpAction(argparse.Action): + def __init__(self, option_strings, dest=None, help=None): super(_HelpAction, self).__init__( option_strings=option_strings, @@ -65,6 +68,7 @@ class _HelpAction(argparse.Action): class Command(object): + """Command parser and runner for building trees of commands. This class provides a wraper around :class:`argparse.ArgumentParser` @@ -227,6 +231,7 @@ class Command(object): class RootCommand(Command): + def __init__(self): super(RootCommand, self).__init__() self.set(base_verbosity_level=0) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index f6fd2709..fd914994 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -264,6 +264,7 @@ def _postprocess(config_string): class Proxy(collections.Mapping): + def __init__(self, data): self._data = data diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index 2b055663..6be10ff1 100644 --- a/mopidy/config/schemas.py +++ b/mopidy/config/schemas.py @@ -38,6 +38,7 @@ def _levenshtein(a, b): class ConfigSchema(collections.OrderedDict): + """Logical group of config values that correspond to a config section. Schemas are set up by assigning config keys with config values to @@ -47,6 +48,7 @@ class ConfigSchema(collections.OrderedDict): :meth:`serialize` for converting the values to a form suitable for persistence. """ + def __init__(self, name): super(ConfigSchema, self).__init__() self.name = name @@ -95,6 +97,7 @@ class ConfigSchema(collections.OrderedDict): class MapConfigSchema(object): + """Schema for handling multiple unknown keys with the same type. Does not sub-class :class:`ConfigSchema`, but implements the same diff --git a/mopidy/config/types.py b/mopidy/config/types.py index d074458b..8359766f 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -25,6 +25,7 @@ def encode(value): class ExpandedPath(bytes): + def __new__(cls, original, expanded): return super(ExpandedPath, cls).__new__(cls, expanded) @@ -37,6 +38,7 @@ class DeprecatedValue(object): class ConfigValue(object): + """Represents a config key's value and how to handle it. Normally you will only be interacting with sub-classes for config values @@ -65,6 +67,7 @@ class ConfigValue(object): class Deprecated(ConfigValue): + """Deprecated value Used for ignoring old config values that are no longer in use, but should @@ -79,10 +82,12 @@ class Deprecated(ConfigValue): class String(ConfigValue): + """String value. Is decoded as utf-8 and \\n \\t escapes should work and be preserved. """ + def __init__(self, optional=False, choices=None): self._required = not optional self._choices = choices @@ -102,6 +107,7 @@ class String(ConfigValue): class Secret(String): + """Secret string value. Is decoded as utf-8 and \\n \\t escapes should work and be preserved. @@ -109,6 +115,7 @@ class Secret(String): Should be used for passwords, auth tokens etc. Will mask value when being displayed. """ + def __init__(self, optional=False, choices=None): self._required = not optional self._choices = None # Choices doesn't make sense for secrets @@ -120,6 +127,7 @@ class Secret(String): class Integer(ConfigValue): + """Integer value.""" def __init__( @@ -141,6 +149,7 @@ class Integer(ConfigValue): class Boolean(ConfigValue): + """Boolean value. Accepts ``1``, ``yes``, ``true``, and ``on`` with any casing as @@ -173,11 +182,13 @@ class Boolean(ConfigValue): class List(ConfigValue): + """List value. Supports elements split by commas or newlines. Newlines take presedence and empty list items will be filtered out. """ + def __init__(self, optional=False): self._required = not optional @@ -198,6 +209,7 @@ class List(ConfigValue): class LogColor(ConfigValue): + def deserialize(self, value): validators.validate_choice(value.lower(), log.COLORS) return value.lower() @@ -209,6 +221,7 @@ class LogColor(ConfigValue): class LogLevel(ConfigValue): + """Log level value. Expects one of ``critical``, ``error``, ``warning``, ``info``, ``debug``, @@ -235,6 +248,7 @@ class LogLevel(ConfigValue): class Hostname(ConfigValue): + """Network hostname value.""" def __init__(self, optional=False): @@ -252,18 +266,21 @@ class Hostname(ConfigValue): class Port(Integer): + """Network port value. Expects integer in the range 0-65535, zero tells the kernel to simply allocate a port for us. """ # TODO: consider probing if port is free or not? + def __init__(self, choices=None, optional=False): super(Port, self).__init__( minimum=0, maximum=2 ** 16 - 1, choices=choices, optional=optional) class Path(ConfigValue): + """File system path The following expansions of the path will be done: @@ -278,6 +295,7 @@ class Path(ConfigValue): - ``$XDG_MUSIC_DIR`` according to the XDG spec """ + def __init__(self, optional=False): self._required = not optional diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index b21e9e20..475a8cb8 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -134,6 +134,7 @@ class Core( class Backends(list): + def __init__(self, backends): super(Backends, self).__init__(backends) diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index 3ae03925..45109bba 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -4,6 +4,7 @@ from mopidy import listener class CoreListener(listener.Listener): + """ Marker interface for recipients of events sent by the core actor. diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index 4c4a0f6d..32a2bd9a 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals class MopidyException(Exception): + def __init__(self, message, *args, **kwargs): super(MopidyException, self).__init__(message, *args, **kwargs) self._message = message @@ -25,6 +26,7 @@ class ExtensionError(MopidyException): class FindError(MopidyException): + def __init__(self, message, errno=None): super(FindError, self).__init__(message, errno) self.errno = errno diff --git a/mopidy/ext.py b/mopidy/ext.py index 2f02c43b..f5f15058 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -12,6 +12,7 @@ logger = logging.getLogger(__name__) class Extension(object): + """Base class for Mopidy extensions""" dist_name = None @@ -104,6 +105,7 @@ class Extension(object): class Registry(collections.Mapping): + """Registry of components provided by Mopidy extensions. Passed to the :meth:`~Extension.setup` method of all extensions. The diff --git a/mopidy/http/handlers.py b/mopidy/http/handlers.py index a5baf992..342108f8 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -142,6 +142,7 @@ def set_mopidy_headers(request_handler): class JsonRpcHandler(tornado.web.RequestHandler): + def initialize(self, core): self.jsonrpc = make_jsonrpc_wrapper(core) @@ -176,6 +177,7 @@ class JsonRpcHandler(tornado.web.RequestHandler): class ClientListHandler(tornado.web.RequestHandler): + def initialize(self, apps, statics): self.apps = apps self.statics = statics @@ -197,6 +199,7 @@ class ClientListHandler(tornado.web.RequestHandler): class StaticFileHandler(tornado.web.StaticFileHandler): + def set_extra_headers(self, path): set_mopidy_headers(self) diff --git a/mopidy/listener.py b/mopidy/listener.py index 286466a5..410558ac 100644 --- a/mopidy/listener.py +++ b/mopidy/listener.py @@ -35,6 +35,7 @@ def send(cls, event, **kwargs): class Listener(object): + def on_event(self, event, **kwargs): """ Called on all events. diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index dedb8632..ff61c17c 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -48,6 +48,7 @@ class Extension(ext.Extension): class Library(object): + """ Local library interface. diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index af8b0025..d9320d4a 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -29,6 +29,7 @@ def _get_library(args, config): class LocalCommand(commands.Command): + def __init__(self): super(LocalCommand, self).__init__() self.add_child('scan', ScanCommand()) @@ -162,6 +163,7 @@ class ScanCommand(commands.Command): class _Progress(object): + def __init__(self, batch_size, total): self.count = 0 self.batch_size = batch_size diff --git a/mopidy/local/library.py b/mopidy/local/library.py index 5e98964c..26e20774 100644 --- a/mopidy/local/library.py +++ b/mopidy/local/library.py @@ -8,6 +8,7 @@ logger = logging.getLogger(__name__) class LocalLibraryProvider(backend.LibraryProvider): + """Proxy library that delegates work to our active local library.""" root_directory = models.Ref.directory( diff --git a/mopidy/local/playback.py b/mopidy/local/playback.py index 82f27fdd..24038426 100644 --- a/mopidy/local/playback.py +++ b/mopidy/local/playback.py @@ -5,6 +5,7 @@ from mopidy.local import translator class LocalPlaybackProvider(backend.PlaybackProvider): + def translate_uri(self, uri): return translator.local_track_uri_to_file_uri( uri, self.backend.config['local']['media_dir']) diff --git a/mopidy/m3u/library.py b/mopidy/m3u/library.py index 3b5bded1..291a6194 100644 --- a/mopidy/m3u/library.py +++ b/mopidy/m3u/library.py @@ -8,6 +8,7 @@ logger = logging.getLogger(__name__) class M3ULibraryProvider(backend.LibraryProvider): + """Library for looking up M3U playlists.""" def __init__(self, backend): diff --git a/mopidy/mixer.py b/mopidy/mixer.py index e277fe55..b25688fb 100644 --- a/mopidy/mixer.py +++ b/mopidy/mixer.py @@ -9,6 +9,7 @@ logger = logging.getLogger(__name__) class Mixer(object): + """ Audio mixer API @@ -111,6 +112,7 @@ class Mixer(object): class MixerListener(listener.Listener): + """ Marker interface for recipients of events sent by the mixer actor. diff --git a/mopidy/models.py b/mopidy/models.py index f79b70e4..1ae26811 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -4,6 +4,7 @@ import json class ImmutableObject(object): + """ Superclass for immutable objects whose fields can only be modified via the constructor. @@ -102,6 +103,7 @@ class ImmutableObject(object): class ModelJSONEncoder(json.JSONEncoder): + """ Automatically serialize Mopidy models to JSON. @@ -112,6 +114,7 @@ class ModelJSONEncoder(json.JSONEncoder): '{"a_track": {"__model__": "Track", "name": "name"}}' """ + def default(self, obj): if isinstance(obj, ImmutableObject): return obj.serialize() @@ -143,6 +146,7 @@ def model_json_decoder(dct): class Ref(ImmutableObject): + """ Model to represent URI references with a human friendly name and type attached. This is intended for use a lightweight object "free" of metadata @@ -213,6 +217,7 @@ class Ref(ImmutableObject): class Image(ImmutableObject): + """ :param string uri: URI of the image :param int width: Optional width of image or :class:`None` @@ -230,6 +235,7 @@ class Image(ImmutableObject): class Artist(ImmutableObject): + """ :param uri: artist URI :type uri: string @@ -250,6 +256,7 @@ class Artist(ImmutableObject): class Album(ImmutableObject): + """ :param uri: album URI :type uri: string @@ -303,6 +310,7 @@ class Album(ImmutableObject): class Track(ImmutableObject): + """ :param uri: track URI :type uri: string @@ -395,6 +403,7 @@ class Track(ImmutableObject): class TlTrack(ImmutableObject): + """ A tracklist track. Wraps a regular track and it's tracklist ID. @@ -433,6 +442,7 @@ class TlTrack(ImmutableObject): class Playlist(ImmutableObject): + """ :param uri: playlist URI :type uri: string @@ -473,6 +483,7 @@ class Playlist(ImmutableObject): class SearchResult(ImmutableObject): + """ :param uri: search result URI :type uri: string diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index 2aecb6d1..36775578 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -13,6 +13,7 @@ logger = logging.getLogger(__name__) class MpdFrontend(pykka.ThreadingActor, CoreListener): + def __init__(self, config, core): super(MpdFrontend, self).__init__() diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index 4d1c6196..5abc1b4b 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -13,6 +13,7 @@ protocol.load_protocol_modules() class MpdDispatcher(object): + """ The MPD session feeds the MPD dispatcher with requests. The dispatcher finds the correct handler, processes the request and sends the response @@ -209,6 +210,7 @@ class MpdDispatcher(object): class MpdContext(object): + """ This object is passed as the first argument to all MPD command handlers to give the command handlers access to important parts of Mopidy. diff --git a/mopidy/mpd/exceptions.py b/mopidy/mpd/exceptions.py index 6fc925a3..3bd51567 100644 --- a/mopidy/mpd/exceptions.py +++ b/mopidy/mpd/exceptions.py @@ -4,6 +4,7 @@ from mopidy.exceptions import MopidyException class MpdAckError(MopidyException): + """See fields on this class for available MPD error codes""" ACK_ERROR_NOT_LIST = 1 @@ -59,6 +60,7 @@ class MpdUnknownError(MpdAckError): class MpdUnknownCommand(MpdUnknownError): + def __init__(self, *args, **kwargs): super(MpdUnknownCommand, self).__init__(*args, **kwargs) assert self.command is not None, 'command must be given explicitly' @@ -67,6 +69,7 @@ class MpdUnknownCommand(MpdUnknownError): class MpdNoCommand(MpdUnknownCommand): + def __init__(self, *args, **kwargs): kwargs['command'] = '' super(MpdNoCommand, self).__init__(*args, **kwargs) diff --git a/mopidy/mpd/protocol/__init__.py b/mopidy/mpd/protocol/__init__.py index ff04d435..e6b88dbd 100644 --- a/mopidy/mpd/protocol/__init__.py +++ b/mopidy/mpd/protocol/__init__.py @@ -83,6 +83,7 @@ def RANGE(value): # noqa: N802 class Commands(object): + """Collection of MPD commands to expose to users. Normally used through the global instance which command handlers have been diff --git a/mopidy/mpd/session.py b/mopidy/mpd/session.py index 9f7fabeb..adbf6cc3 100644 --- a/mopidy/mpd/session.py +++ b/mopidy/mpd/session.py @@ -9,6 +9,7 @@ logger = logging.getLogger(__name__) class MpdSession(network.LineProtocol): + """ The MPD client session. Keeps track of a single client session. Any requests from the client is passed on to the MPD request dispatcher. diff --git a/mopidy/mpd/uri_mapper.py b/mopidy/mpd/uri_mapper.py index 08c7f689..37e4b783 100644 --- a/mopidy/mpd/uri_mapper.py +++ b/mopidy/mpd/uri_mapper.py @@ -4,6 +4,7 @@ import re class MpdUriMapper(object): + """ Maintains the mappings between uniquified MPD names and URIs. """ diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index 47bfd58f..81e07b6d 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -15,6 +15,7 @@ logger = logging.getLogger(__name__) class StreamBackend(pykka.ThreadingActor, backend.Backend): + def __init__(self, config, audio): super(StreamBackend, self).__init__() @@ -30,6 +31,7 @@ class StreamBackend(pykka.ThreadingActor, backend.Backend): class StreamLibraryProvider(backend.LibraryProvider): + def __init__(self, backend, timeout, blacklist, proxy): super(StreamLibraryProvider, self).__init__(backend) self._scanner = scan.Scanner(timeout=timeout, proxy_config=proxy) diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index 13199b26..e567ef87 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -10,6 +10,7 @@ from mopidy import compat class JsonRpcWrapper(object): + """ Wrap objects and make them accessible through JSON-RPC 2.0 messaging. @@ -278,6 +279,7 @@ def get_combined_json_decoder(decoders): def get_combined_json_encoder(encoders): class JsonRpcEncoder(json.JSONEncoder): + def default(self, obj): for encoder in encoders: try: @@ -289,6 +291,7 @@ def get_combined_json_encoder(encoders): class JsonRpcInspector(object): + """ Inspects a group of classes and functions to create a description of what methods they can expose over JSON-RPC 2.0. diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index d2dcca70..9c40da4f 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -21,6 +21,7 @@ logging.addLevelName(TRACE_LOG_LEVEL, 'TRACE') class DelayedHandler(logging.Handler): + def __init__(self): logging.Handler.__init__(self) self._released = False @@ -101,6 +102,7 @@ def setup_debug_logging_to_file(config): class VerbosityFilter(logging.Filter): + def __init__(self, verbosity_level, loglevels): self.verbosity_level = verbosity_level self.loglevels = loglevels @@ -123,6 +125,7 @@ COLORS = [b'black', b'red', b'green', b'yellow', b'blue', b'magenta', b'cyan', class ColorizingStreamHandler(logging.StreamHandler): + """ Stream handler which colorizes the log using ANSI escape sequences. diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index f55649e3..000382e3 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -18,6 +18,7 @@ logger = logging.getLogger(__name__) class ShouldRetrySocketCall(Exception): + """Indicate that attempted socket call should be retried""" @@ -65,6 +66,7 @@ def format_hostname(hostname): class Server(object): + """Setup listener and register it with gobject's event loop.""" def __init__(self, host, port, protocol, protocol_kwargs=None, @@ -305,6 +307,7 @@ class Connection(object): class LineProtocol(pykka.ThreadingActor): + """ Base class for handling line based protocols. diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 8bca275d..e845cd95 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -227,6 +227,7 @@ def check_file_path_is_inside_base_dir(file_path, base_path): # FIXME replace with mock usage in tests. class Mtime(object): + def __init__(self): self.fake = None diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 5b2bb9c0..e826e43c 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -53,6 +53,7 @@ def stop_remaining_actors(): class BaseThread(threading.Thread): + def __init__(self): super(BaseThread, self).__init__() # No thread should block process from exiting diff --git a/mopidy/zeroconf.py b/mopidy/zeroconf.py index 0c42dd74..ddd155b6 100644 --- a/mopidy/zeroconf.py +++ b/mopidy/zeroconf.py @@ -31,6 +31,7 @@ def _convert_text_list_to_dbus_format(text_list): class Zeroconf(object): + """Publish a network service with Zeroconf. Currently, this only works on Linux using Avahi via D-Bus. diff --git a/tests/__init__.py b/tests/__init__.py index 4283e604..fc8d5dcf 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -15,6 +15,7 @@ def path_to_data_dir(name): class IsA(object): + def __init__(self, klass): self.klass = klass diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index fbc440de..8cfb6a88 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -79,6 +79,7 @@ class DummyMixin(object): class AudioTest(BaseTest): + def test_start_playback_existing_file(self): self.audio.prepare_change() self.audio.set_uri(self.uris[0]) @@ -134,6 +135,7 @@ class AudioDummyTest(DummyMixin, AudioTest): @mock.patch.object(audio.AudioListener, 'send') class AudioEventTest(BaseTest): + def setUp(self): # noqa: N802 super(AudioEventTest, self).setUp() self.audio.enable_sync_handler().get() @@ -435,11 +437,13 @@ class AudioEventTest(BaseTest): class AudioDummyEventTest(DummyMixin, AudioEventTest): + """Exercise the AudioEventTest against our mock audio classes.""" # TODO: move to mixer tests... class MixerTest(BaseTest): + @unittest.SkipTest def test_set_mute(self): for value in (True, False): @@ -460,6 +464,7 @@ class MixerTest(BaseTest): class AudioStateTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.audio = audio.Audio(config=None, mixer=None) @@ -505,6 +510,7 @@ class AudioStateTest(unittest.TestCase): class AudioBufferingTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.audio = audio.Audio(config=None, mixer=None) self.audio._playbin = mock.Mock(spec=['set_state']) diff --git a/tests/audio/test_listener.py b/tests/audio/test_listener.py index 5cac75bb..8d32e4c6 100644 --- a/tests/audio/test_listener.py +++ b/tests/audio/test_listener.py @@ -8,6 +8,7 @@ from mopidy import audio class AudioListenerTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.listener = audio.AudioListener() diff --git a/tests/audio/test_playlists.py b/tests/audio/test_playlists.py index f01568f8..769e1592 100644 --- a/tests/audio/test_playlists.py +++ b/tests/audio/test_playlists.py @@ -78,6 +78,7 @@ XSPF = b""" class TypeFind(object): + def __init__(self, data): self.data = data diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index c3fb4c47..1a4fec7e 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -14,6 +14,7 @@ from tests import path_to_data_dir class ScannerTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.errors = {} self.tags = {} diff --git a/tests/audio/test_utils.py b/tests/audio/test_utils.py index f1f15761..a49ead90 100644 --- a/tests/audio/test_utils.py +++ b/tests/audio/test_utils.py @@ -11,6 +11,7 @@ from mopidy.models import Album, Artist, Track # TODO: current test is trying to test everything at once with a complete tags # set, instead we might want to try with a minimal one making testing easier. class TagsToTrackTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.tags = { 'album': ['album'], diff --git a/tests/backend/test_listener.py b/tests/backend/test_listener.py index ae8bbffe..48d7fd22 100644 --- a/tests/backend/test_listener.py +++ b/tests/backend/test_listener.py @@ -8,6 +8,7 @@ from mopidy import backend class BackendListenerTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.listener = backend.BackendListener() diff --git a/tests/config/test_config.py b/tests/config/test_config.py index 8ee91d0d..139f3a69 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -12,6 +12,7 @@ from tests import path_to_data_dir class LoadConfigTest(unittest.TestCase): + def test_load_nothing(self): self.assertEqual({}, config._load([], [], [])) @@ -96,6 +97,7 @@ class LoadConfigTest(unittest.TestCase): class ValidateTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.schema = config.ConfigSchema('foo') self.schema['bar'] = config.ConfigValue() diff --git a/tests/config/test_schemas.py b/tests/config/test_schemas.py index 502bf61c..e84a3aff 100644 --- a/tests/config/test_schemas.py +++ b/tests/config/test_schemas.py @@ -11,6 +11,7 @@ from tests import any_unicode class ConfigSchemaTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.schema = schemas.ConfigSchema('test') self.schema['foo'] = mock.Mock() @@ -87,6 +88,7 @@ class ConfigSchemaTest(unittest.TestCase): class MapConfigSchemaTest(unittest.TestCase): + def test_conversion(self): schema = schemas.MapConfigSchema('test', types.LogLevel()) result, errors = schema.deserialize( @@ -97,6 +99,7 @@ class MapConfigSchemaTest(unittest.TestCase): class DidYouMeanTest(unittest.TestCase): + def test_suggestions(self): choices = ('enabled', 'username', 'password', 'bitrate', 'timeout') diff --git a/tests/config/test_types.py b/tests/config/test_types.py index 365fa9e0..be1ab829 100644 --- a/tests/config/test_types.py +++ b/tests/config/test_types.py @@ -15,6 +15,7 @@ from mopidy.config import types class ConfigValueTest(unittest.TestCase): + def test_deserialize_passes_through(self): value = types.ConfigValue() sentinel = object() @@ -36,6 +37,7 @@ class ConfigValueTest(unittest.TestCase): class DeprecatedTest(unittest.TestCase): + def test_deserialize_returns_deprecated_value(self): self.assertIsInstance(types.Deprecated().deserialize(b'foobar'), types.DeprecatedValue) @@ -46,6 +48,7 @@ class DeprecatedTest(unittest.TestCase): class StringTest(unittest.TestCase): + def test_deserialize_conversion_success(self): value = types.String() self.assertEqual('foo', value.deserialize(b' foo ')) @@ -117,6 +120,7 @@ class StringTest(unittest.TestCase): class SecretTest(unittest.TestCase): + def test_deserialize_decodes_utf8(self): value = types.Secret() result = value.deserialize('æøå'.encode('utf-8')) @@ -152,6 +156,7 @@ class SecretTest(unittest.TestCase): class IntegerTest(unittest.TestCase): + def test_deserialize_conversion_success(self): value = types.Integer() self.assertEqual(123, value.deserialize('123')) @@ -186,6 +191,7 @@ class IntegerTest(unittest.TestCase): class BooleanTest(unittest.TestCase): + def test_deserialize_conversion_success(self): value = types.Boolean() for true in ('1', 'yes', 'true', 'on'): @@ -312,6 +318,7 @@ class LogLevelTest(unittest.TestCase): class HostnameTest(unittest.TestCase): + @mock.patch('socket.getaddrinfo') def test_deserialize_conversion_success(self, getaddrinfo_mock): value = types.Hostname() @@ -339,6 +346,7 @@ class HostnameTest(unittest.TestCase): class PortTest(unittest.TestCase): + def test_valid_ports(self): value = types.Port() self.assertEqual(0, value.deserialize('0')) @@ -356,6 +364,7 @@ class PortTest(unittest.TestCase): class ExpandedPathTest(unittest.TestCase): + def test_is_bytes(self): self.assertIsInstance(types.ExpandedPath(b'/tmp', b'foo'), bytes) @@ -373,6 +382,7 @@ class ExpandedPathTest(unittest.TestCase): class PathTest(unittest.TestCase): + def test_deserialize_conversion_success(self): result = types.Path().deserialize(b'/foo') self.assertEqual('/foo', result) diff --git a/tests/config/test_validator.py b/tests/config/test_validator.py index 8172df0c..cafb1788 100644 --- a/tests/config/test_validator.py +++ b/tests/config/test_validator.py @@ -6,6 +6,7 @@ from mopidy.config import validators class ValidateChoiceTest(unittest.TestCase): + def test_no_choices_passes(self): validators.validate_choice('foo', None) @@ -25,6 +26,7 @@ class ValidateChoiceTest(unittest.TestCase): class ValidateMinimumTest(unittest.TestCase): + def test_no_minimum_passes(self): validators.validate_minimum(10, None) @@ -39,6 +41,7 @@ class ValidateMinimumTest(unittest.TestCase): class ValidateMaximumTest(unittest.TestCase): + def test_no_maximum_passes(self): validators.validate_maximum(5, None) @@ -53,6 +56,7 @@ class ValidateMaximumTest(unittest.TestCase): class ValidateRequiredTest(unittest.TestCase): + def test_passes_when_false(self): validators.validate_required('foo', False) validators.validate_required('', False) diff --git a/tests/core/test_actor.py b/tests/core/test_actor.py index e82962dc..520c5026 100644 --- a/tests/core/test_actor.py +++ b/tests/core/test_actor.py @@ -11,6 +11,7 @@ from mopidy.utils import versioning class CoreActorTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] diff --git a/tests/core/test_events.py b/tests/core/test_events.py index 443c1b7e..e916b670 100644 --- a/tests/core/test_events.py +++ b/tests/core/test_events.py @@ -15,6 +15,7 @@ from tests import dummy_backend @mock.patch.object(core.CoreListener, 'send') class BackendEventsTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.backend = dummy_backend.create_proxy() self.backend.library.dummy_library = [ diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 7e3c8698..8d2195a2 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -10,6 +10,7 @@ from mopidy.utils import deprecation class BaseCoreLibraryTest(unittest.TestCase): + def setUp(self): # noqa: N802 dummy1_root = Ref.directory(uri='dummy1:directory', name='dummy1') self.backend1 = mock.Mock() @@ -41,6 +42,7 @@ class BaseCoreLibraryTest(unittest.TestCase): # TODO: split by method class CoreLibraryTest(BaseCoreLibraryTest): + def test_get_images_returns_empty_dict_for_no_uris(self): self.assertEqual({}, self.core.library.get_images([])) @@ -273,6 +275,7 @@ class CoreLibraryTest(BaseCoreLibraryTest): class DeprecatedFindExactCoreLibraryTest(BaseCoreLibraryTest): + def run(self, result=None): with deprecation.ignore('core.library.find_exact'): return super(DeprecatedFindExactCoreLibraryTest, self).run(result) @@ -354,6 +357,7 @@ class DeprecatedFindExactCoreLibraryTest(BaseCoreLibraryTest): class DeprecatedLookupCoreLibraryTest(BaseCoreLibraryTest): + def run(self, result=None): with deprecation.ignore('core.library.lookup:uri_arg'): return super(DeprecatedLookupCoreLibraryTest, self).run(result) @@ -379,6 +383,7 @@ class DeprecatedLookupCoreLibraryTest(BaseCoreLibraryTest): class LegacyFindExactToSearchLibraryTest(unittest.TestCase): + def run(self, result=None): with deprecation.ignore('core.library.find_exact'): return super(LegacyFindExactToSearchLibraryTest, self).run(result) diff --git a/tests/core/test_listener.py b/tests/core/test_listener.py index 8ec3a843..95c4da51 100644 --- a/tests/core/test_listener.py +++ b/tests/core/test_listener.py @@ -9,6 +9,7 @@ from mopidy.models import Playlist, TlTrack class CoreListenerTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.listener = CoreListener() diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py index c4126eaa..c4ef7fe9 100644 --- a/tests/core/test_mixer.py +++ b/tests/core/test_mixer.py @@ -11,6 +11,7 @@ from tests import dummy_mixer class CoreMixerTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.mixer = mock.Mock(spec=mixer.Mixer) self.core = core.Core(mixer=self.mixer, backends=[]) @@ -39,6 +40,7 @@ class CoreMixerTest(unittest.TestCase): class CoreNoneMixerTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.core = core.Core(mixer=None, backends=[]) @@ -57,6 +59,7 @@ class CoreNoneMixerTest(unittest.TestCase): @mock.patch.object(mixer.MixerListener, 'send') class CoreMixerListenerTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.mixer = dummy_mixer.create_proxy() self.core = core.Core(mixer=self.mixer, backends=[]) @@ -78,6 +81,7 @@ class CoreMixerListenerTest(unittest.TestCase): @mock.patch.object(mixer.MixerListener, 'send') class CoreNoneMixerListenerTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.core = core.Core(mixer=None, backends=[]) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index d09950b2..a113e034 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -15,6 +15,7 @@ from tests import dummy_audio as audio # TODO: split into smaller easier to follow tests. setup is way to complex. # TODO: just mock tracklist? class CorePlaybackTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] @@ -601,6 +602,7 @@ class TestBackend(pykka.ThreadingActor, backend.Backend): class TestStream(unittest.TestCase): + def setUp(self): # noqa: N802 self.audio = audio.DummyAudio.start().proxy() self.backend = TestBackend.start(config={}, audio=self.audio).proxy() @@ -684,6 +686,7 @@ class TestStream(unittest.TestCase): class CorePlaybackWithOldBackendTest(unittest.TestCase): + def test_type_error_from_old_backend_does_not_crash_core(self): b = mock.Mock() b.uri_schemes.get.return_value = ['dummy1'] diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index b842ae44..4ca3d6df 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -10,6 +10,7 @@ from mopidy.utils import deprecation class BasePlaylistsTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.plr1a = Ref.playlist(name='A', uri='dummy1:pl:a') self.plr1b = Ref.playlist(name='B', uri='dummy1:pl:b') @@ -52,6 +53,7 @@ class BasePlaylistsTest(unittest.TestCase): class PlaylistTest(BasePlaylistsTest): + def test_as_list_combines_result_from_backends(self): result = self.core.playlists.as_list() @@ -231,6 +233,7 @@ class PlaylistTest(BasePlaylistsTest): class DeprecatedFilterPlaylistsTest(BasePlaylistsTest): + def run(self, result=None): with deprecation.ignore(ids=['core.playlists.filter', 'core.playlists.get_playlists']): @@ -248,6 +251,7 @@ class DeprecatedFilterPlaylistsTest(BasePlaylistsTest): class DeprecatedGetPlaylistsTest(BasePlaylistsTest): + def run(self, result=None): with deprecation.ignore('core.playlists.get_playlists'): return super(DeprecatedGetPlaylistsTest, self).run(result) diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 96de0f80..24a9ef0f 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -10,6 +10,7 @@ from mopidy.utils import deprecation class TracklistTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.tracks = [ Track(uri='dummy1:a', name='foo'), diff --git a/tests/dummy_audio.py b/tests/dummy_audio.py index dcf90ffa..7c48d9f0 100644 --- a/tests/dummy_audio.py +++ b/tests/dummy_audio.py @@ -16,6 +16,7 @@ def create_proxy(config=None, mixer=None): class DummyAudio(pykka.ThreadingActor): + def __init__(self, config=None, mixer=None): super(DummyAudio, self).__init__() self.state = audio.PlaybackState.STOPPED diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index 61c26c5f..9ce8e38f 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -17,6 +17,7 @@ def create_proxy(config=None, audio=None): class DummyBackend(pykka.ThreadingActor, backend.Backend): + def __init__(self, config, audio): super(DummyBackend, self).__init__() @@ -57,6 +58,7 @@ class DummyLibraryProvider(backend.LibraryProvider): class DummyPlaybackProvider(backend.PlaybackProvider): + def __init__(self, *args, **kwargs): super(DummyPlaybackProvider, self).__init__(*args, **kwargs) self._uri = None @@ -93,6 +95,7 @@ class DummyPlaybackProvider(backend.PlaybackProvider): class DummyPlaylistsProvider(backend.PlaylistsProvider): + def __init__(self, backend): super(DummyPlaylistsProvider, self).__init__(backend) self._playlists = [] diff --git a/tests/http/test_handlers.py b/tests/http/test_handlers.py index 8bd82e11..78071fb2 100644 --- a/tests/http/test_handlers.py +++ b/tests/http/test_handlers.py @@ -13,6 +13,7 @@ from mopidy.http import handlers class StaticFileHandlerTest(tornado.testing.AsyncHTTPTestCase): + def get_app(self): return tornado.web.Application([ (r'/(.*)', handlers.StaticFileHandler, { @@ -43,6 +44,7 @@ class StaticFileHandlerTest(tornado.testing.AsyncHTTPTestCase): # We aren't bothering with skipIf as then we would need to "backport" gen_test if hasattr(tornado.websocket, 'websocket_connect'): class WebSocketHandlerTest(tornado.testing.AsyncHTTPTestCase): + def get_app(self): self.core = mock.Mock() return tornado.web.Application([ diff --git a/tests/http/test_server.py b/tests/http/test_server.py index 3c7d7c88..bb1d8cf0 100644 --- a/tests/http/test_server.py +++ b/tests/http/test_server.py @@ -12,6 +12,7 @@ from mopidy.http import actor, handlers class HttpServerTest(tornado.testing.AsyncHTTPTestCase): + def get_config(self): return { 'http': { @@ -43,6 +44,7 @@ class HttpServerTest(tornado.testing.AsyncHTTPTestCase): class RootRedirectTest(HttpServerTest): + def test_should_redirect_to_mopidy_app(self): response = self.fetch('/', method='GET', follow_redirects=False) @@ -51,6 +53,7 @@ class RootRedirectTest(HttpServerTest): class LegacyStaticDirAppTest(HttpServerTest): + def get_config(self): config = super(LegacyStaticDirAppTest, self).get_config() config['http']['static_dir'] = os.path.dirname(__file__) @@ -73,6 +76,7 @@ class LegacyStaticDirAppTest(HttpServerTest): class MopidyAppTest(HttpServerTest): + def test_should_return_index(self): response = self.fetch('/mopidy/', method='GET') body = tornado.escape.to_unicode(response.body) @@ -103,6 +107,7 @@ class MopidyAppTest(HttpServerTest): class MopidyWebSocketHandlerTest(HttpServerTest): + def test_should_return_ws(self): response = self.fetch('/mopidy/ws', method='GET') @@ -119,6 +124,7 @@ class MopidyWebSocketHandlerTest(HttpServerTest): class MopidyRPCHandlerTest(HttpServerTest): + def test_should_return_rpc_error(self): cmd = tornado.escape.json_encode({'action': 'get_version'}) @@ -164,6 +170,7 @@ class MopidyRPCHandlerTest(HttpServerTest): class HttpServerWithStaticFilesTest(tornado.testing.AsyncHTTPTestCase): + def get_app(self): config = { 'http': { @@ -214,6 +221,7 @@ def wsgi_app_factory(config, core): class HttpServerWithWsgiAppTest(tornado.testing.AsyncHTTPTestCase): + def get_app(self): config = { 'http': { diff --git a/tests/local/test_search.py b/tests/local/test_search.py index 2a704e48..bb741125 100644 --- a/tests/local/test_search.py +++ b/tests/local/test_search.py @@ -7,6 +7,7 @@ from mopidy.models import Album, Track class LocalLibrarySearchTest(unittest.TestCase): + def test_find_exact_with_album_query(self): expected_tracks = [Track(album=Album(name='foo'))] tracks = [Track(), Track(album=Album(name='bar'))] + expected_tracks diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index ecb3d40e..a294e6cf 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -272,6 +272,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): class DeprecatedM3UPlaylistsProviderTest(M3UPlaylistsProviderTest): + def run(self, result=None): with deprecation.ignore(ids=['core.playlists.filter', 'core.playlists.get_playlists']): diff --git a/tests/m3u/test_translator.py b/tests/m3u/test_translator.py index fc7fc958..c84f12bf 100644 --- a/tests/m3u/test_translator.py +++ b/tests/m3u/test_translator.py @@ -30,6 +30,7 @@ encoded_ext_track = encoded_track.copy(name='æøå') # FIXME use mock instead of tempfile.NamedTemporaryFile class M3UToUriTest(unittest.TestCase): + def parse(self, name): return translator.parse_m3u(name, data_dir) diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index 9ebe99b0..4b009407 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -14,6 +14,7 @@ from tests import dummy_backend, dummy_mixer class MockConnection(mock.Mock): + def __init__(self, *args, **kwargs): super(MockConnection, self).__init__(*args, **kwargs) self.host = mock.sentinel.host diff --git a/tests/mpd/protocol/test_authentication.py b/tests/mpd/protocol/test_authentication.py index ac6e71da..325fca18 100644 --- a/tests/mpd/protocol/test_authentication.py +++ b/tests/mpd/protocol/test_authentication.py @@ -4,6 +4,7 @@ from tests.mpd import protocol class AuthenticationActiveTest(protocol.BaseTestCase): + def get_config(self): config = super(AuthenticationActiveTest, self).get_config() config['mpd']['password'] = 'topsecret' @@ -52,6 +53,7 @@ class AuthenticationActiveTest(protocol.BaseTestCase): class AuthenticationInactiveTest(protocol.BaseTestCase): + def test_authentication_with_anything_when_password_check_turned_off(self): self.send_request('any request at all') self.assertTrue(self.dispatcher.authenticated) diff --git a/tests/mpd/protocol/test_channels.py b/tests/mpd/protocol/test_channels.py index c29b2b57..90c425fd 100644 --- a/tests/mpd/protocol/test_channels.py +++ b/tests/mpd/protocol/test_channels.py @@ -4,6 +4,7 @@ from tests.mpd import protocol class ChannelsHandlerTest(protocol.BaseTestCase): + def test_subscribe(self): self.send_request('subscribe "topic"') self.assertEqualResponse('ACK [0@0] {subscribe} Not implemented') diff --git a/tests/mpd/protocol/test_command_list.py b/tests/mpd/protocol/test_command_list.py index bd9a9e6c..2aeab3b0 100644 --- a/tests/mpd/protocol/test_command_list.py +++ b/tests/mpd/protocol/test_command_list.py @@ -4,6 +4,7 @@ from tests.mpd import protocol class CommandListsTest(protocol.BaseTestCase): + def test_command_list_begin(self): response = self.send_request('command_list_begin') self.assertEqual([], response) diff --git a/tests/mpd/protocol/test_connection.py b/tests/mpd/protocol/test_connection.py index da25153d..9c7edb4b 100644 --- a/tests/mpd/protocol/test_connection.py +++ b/tests/mpd/protocol/test_connection.py @@ -6,6 +6,7 @@ from tests.mpd import protocol class ConnectionHandlerTest(protocol.BaseTestCase): + def test_close_closes_the_client_connection(self): with patch.object(self.session, 'close') as close_mock: self.send_request('close') diff --git a/tests/mpd/protocol/test_current_playlist.py b/tests/mpd/protocol/test_current_playlist.py index 4fa1926a..6ec53adc 100644 --- a/tests/mpd/protocol/test_current_playlist.py +++ b/tests/mpd/protocol/test_current_playlist.py @@ -7,6 +7,7 @@ from tests.mpd import protocol class AddCommandsTest(protocol.BaseTestCase): + def setUp(self): # noqa: N802 super(AddCommandsTest, self).setUp() @@ -92,6 +93,7 @@ class AddCommandsTest(protocol.BaseTestCase): class BasePopulatedTracklistTestCase(protocol.BaseTestCase): + def setUp(self): # noqa: N802 super(BasePopulatedTracklistTestCase, self).setUp() tracks = [Track(uri='dummy:/%s' % x, name=x) for x in 'abcdef'] @@ -100,6 +102,7 @@ class BasePopulatedTracklistTestCase(protocol.BaseTestCase): class DeleteCommandsTest(BasePopulatedTracklistTestCase): + def test_clear(self): self.send_request('clear') self.assertEqual(len(self.core.tracklist.tracks.get()), 0) @@ -155,6 +158,7 @@ class DeleteCommandsTest(BasePopulatedTracklistTestCase): class MoveCommandsTest(BasePopulatedTracklistTestCase): + def test_move_songpos(self): self.send_request('move "1" "0"') result = [t.name for t in self.core.tracklist.tracks.get()] @@ -186,6 +190,7 @@ class MoveCommandsTest(BasePopulatedTracklistTestCase): class PlaylistFindCommandTest(protocol.BaseTestCase): + def test_playlistfind(self): self.send_request('playlistfind "tag" "needle"') self.assertEqualResponse('ACK [0@0] {playlistfind} Not implemented') @@ -211,6 +216,7 @@ class PlaylistFindCommandTest(protocol.BaseTestCase): class PlaylistIdCommandTest(BasePopulatedTracklistTestCase): + def test_playlistid_without_songid(self): self.send_request('playlistid') self.assertInResponse('Title: a') @@ -231,6 +237,7 @@ class PlaylistIdCommandTest(BasePopulatedTracklistTestCase): class PlaylistInfoCommandTest(BasePopulatedTracklistTestCase): + def test_playlist_returns_same_as_playlistinfo(self): with deprecation.ignore('mpd.protocol.current_playlist.playlist'): playlist_response = self.send_request('playlist') @@ -318,6 +325,7 @@ class PlaylistInfoCommandTest(BasePopulatedTracklistTestCase): class PlaylistSearchCommandTest(protocol.BaseTestCase): + def test_playlistsearch(self): self.send_request('playlistsearch "any" "needle"') self.assertEqualResponse('ACK [0@0] {playlistsearch} Not implemented') @@ -328,6 +336,7 @@ class PlaylistSearchCommandTest(protocol.BaseTestCase): class PlChangeCommandTest(BasePopulatedTracklistTestCase): + def test_plchanges_with_lower_version_returns_changes(self): self.send_request('plchanges "0"') self.assertInResponse('Title: a') @@ -379,6 +388,7 @@ class PlChangeCommandTest(BasePopulatedTracklistTestCase): # TODO: we only seem to be testing that don't touch the non shuffled region :/ class ShuffleCommandTest(BasePopulatedTracklistTestCase): + def test_shuffle_without_range(self): version = self.core.tracklist.version.get() @@ -409,6 +419,7 @@ class ShuffleCommandTest(BasePopulatedTracklistTestCase): class SwapCommandTest(BasePopulatedTracklistTestCase): + def test_swap(self): self.send_request('swap "1" "4"') result = [t.name for t in self.core.tracklist.tracks.get()] diff --git a/tests/mpd/protocol/test_idle.py b/tests/mpd/protocol/test_idle.py index e3c6ad38..075da845 100644 --- a/tests/mpd/protocol/test_idle.py +++ b/tests/mpd/protocol/test_idle.py @@ -8,6 +8,7 @@ from tests.mpd import protocol class IdleHandlerTest(protocol.BaseTestCase): + def idle_event(self, subsystem): self.session.on_idle(subsystem) diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index ee8d386d..ca043d3c 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -11,6 +11,7 @@ from tests.mpd import protocol class QueryFromMpdSearchFormatTest(unittest.TestCase): + def test_dates_are_extracted(self): result = music_db._query_from_mpd_search_parameters( ['Date', '1974-01-02', 'Date', '1975'], music_db._SEARCH_MAPPING) @@ -37,6 +38,7 @@ class QueryFromMpdListFormatTest(unittest.TestCase): # TODO: why isn't core.playlists.filter getting deprecation warnings? class MusicDatabaseHandlerTest(protocol.BaseTestCase): + def test_count(self): self.send_request('count "artist" "needle"') self.assertInResponse('songs: 0') @@ -430,6 +432,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): class MusicDatabaseFindTest(protocol.BaseTestCase): + def test_find_includes_fake_artist_and_album_tracks(self): self.backend.library.dummy_find_exact_result = SearchResult( albums=[Album(uri='dummy:album:a', name='A', date='2001')], @@ -620,6 +623,7 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): class MusicDatabaseListTest(protocol.BaseTestCase): + def test_list(self): self.backend.library.dummy_get_distinct_result = { 'artist': set(['A Artist'])} @@ -1061,6 +1065,7 @@ class MusicDatabaseListTest(protocol.BaseTestCase): class MusicDatabaseSearchTest(protocol.BaseTestCase): + def test_search(self): self.backend.library.dummy_search_result = SearchResult( albums=[Album(uri='dummy:album:a', name='A')], diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index 328fe136..6121f540 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -15,6 +15,7 @@ STOPPED = PlaybackState.STOPPED class PlaybackOptionsHandlerTest(protocol.BaseTestCase): + def test_consume_off(self): self.send_request('consume "0"') self.assertFalse(self.core.tracklist.consume.get()) @@ -173,6 +174,7 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): class PlaybackControlHandlerTest(protocol.BaseTestCase): + def setUp(self): # noqa: N802 super(PlaybackControlHandlerTest, self).setUp() self.tracks = [Track(uri='dummy:a', length=40000), diff --git a/tests/mpd/protocol/test_reflection.py b/tests/mpd/protocol/test_reflection.py index 5c44c464..4641a8f4 100644 --- a/tests/mpd/protocol/test_reflection.py +++ b/tests/mpd/protocol/test_reflection.py @@ -4,6 +4,7 @@ from tests.mpd import protocol class ReflectionHandlerTest(protocol.BaseTestCase): + def test_config_is_not_allowed_across_the_network(self): self.send_request('config') self.assertEqualResponse( @@ -49,6 +50,7 @@ class ReflectionHandlerTest(protocol.BaseTestCase): class ReflectionWhenNotAuthedTest(protocol.BaseTestCase): + def get_config(self): config = super(ReflectionWhenNotAuthedTest, self).get_config() config['mpd']['password'] = 'topsecret' diff --git a/tests/mpd/protocol/test_regression.py b/tests/mpd/protocol/test_regression.py index b8a5d1d5..7591d55c 100644 --- a/tests/mpd/protocol/test_regression.py +++ b/tests/mpd/protocol/test_regression.py @@ -8,6 +8,7 @@ from tests.mpd import protocol class IssueGH17RegressionTest(protocol.BaseTestCase): + """ The issue: http://github.com/mopidy/mopidy/issues/17 @@ -17,6 +18,7 @@ class IssueGH17RegressionTest(protocol.BaseTestCase): - Turn on random mode - Press next until you get to the unplayable track """ + def test(self): tracks = [ Track(uri='dummy:a'), @@ -51,6 +53,7 @@ class IssueGH17RegressionTest(protocol.BaseTestCase): class IssueGH18RegressionTest(protocol.BaseTestCase): + """ The issue: http://github.com/mopidy/mopidy/issues/18 @@ -89,6 +92,7 @@ class IssueGH18RegressionTest(protocol.BaseTestCase): class IssueGH22RegressionTest(protocol.BaseTestCase): + """ The issue: http://github.com/mopidy/mopidy/issues/22 @@ -123,6 +127,7 @@ class IssueGH22RegressionTest(protocol.BaseTestCase): class IssueGH69RegressionTest(protocol.BaseTestCase): + """ The issue: https://github.com/mopidy/mopidy/issues/69 @@ -151,6 +156,7 @@ class IssueGH69RegressionTest(protocol.BaseTestCase): class IssueGH113RegressionTest(protocol.BaseTestCase): + """ The issue: https://github.com/mopidy/mopidy/issues/113 @@ -176,6 +182,7 @@ class IssueGH113RegressionTest(protocol.BaseTestCase): class IssueGH137RegressionTest(protocol.BaseTestCase): + """ The issue: https://github.com/mopidy/mopidy/issues/137 diff --git a/tests/mpd/protocol/test_status.py b/tests/mpd/protocol/test_status.py index bec54466..ea4137de 100644 --- a/tests/mpd/protocol/test_status.py +++ b/tests/mpd/protocol/test_status.py @@ -6,6 +6,7 @@ from tests.mpd import protocol class StatusHandlerTest(protocol.BaseTestCase): + def test_clearerror(self): self.send_request('clearerror') self.assertEqualResponse('ACK [0@0] {clearerror} Not implemented') diff --git a/tests/mpd/protocol/test_stickers.py b/tests/mpd/protocol/test_stickers.py index 0844c461..57f941da 100644 --- a/tests/mpd/protocol/test_stickers.py +++ b/tests/mpd/protocol/test_stickers.py @@ -4,6 +4,7 @@ from tests.mpd import protocol class StickersHandlerTest(protocol.BaseTestCase): + def test_sticker_get(self): self.send_request( 'sticker get "song" "file:///dev/urandom" "a_name"') diff --git a/tests/mpd/protocol/test_stored_playlists.py b/tests/mpd/protocol/test_stored_playlists.py index 6018686e..90b25a70 100644 --- a/tests/mpd/protocol/test_stored_playlists.py +++ b/tests/mpd/protocol/test_stored_playlists.py @@ -6,6 +6,7 @@ from tests.mpd import protocol class PlaylistsHandlerTest(protocol.BaseTestCase): + def test_listplaylist(self): self.backend.playlists.set_dummy_playlists([ Playlist( diff --git a/tests/mpd/test_commands.py b/tests/mpd/test_commands.py index a281d10e..0a8daf30 100644 --- a/tests/mpd/test_commands.py +++ b/tests/mpd/test_commands.py @@ -8,6 +8,7 @@ from mopidy.mpd import exceptions, protocol class TestConverts(unittest.TestCase): + def test_integer(self): self.assertEqual(123, protocol.INT('123')) self.assertEqual(-123, protocol.INT('-123')) @@ -55,6 +56,7 @@ class TestConverts(unittest.TestCase): class TestCommands(unittest.TestCase): + def setUp(self): # noqa: N802 self.commands = protocol.Commands() diff --git a/tests/mpd/test_dispatcher.py b/tests/mpd/test_dispatcher.py index 9e2838b7..be2bf608 100644 --- a/tests/mpd/test_dispatcher.py +++ b/tests/mpd/test_dispatcher.py @@ -13,6 +13,7 @@ from tests import dummy_backend class MpdDispatcherTest(unittest.TestCase): + def setUp(self): # noqa: N802 config = { 'mpd': { diff --git a/tests/mpd/test_exceptions.py b/tests/mpd/test_exceptions.py index 123bae5d..e3759e4e 100644 --- a/tests/mpd/test_exceptions.py +++ b/tests/mpd/test_exceptions.py @@ -8,6 +8,7 @@ from mopidy.mpd.exceptions import ( class MpdExceptionsTest(unittest.TestCase): + def test_mpd_not_implemented_is_a_mpd_ack_error(self): try: raise MpdNotImplemented diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index 080cbfc6..6f134df5 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -23,6 +23,7 @@ STOPPED = PlaybackState.STOPPED class StatusHandlerTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.mixer = dummy_mixer.create_proxy() self.backend = dummy_backend.create_proxy() diff --git a/tests/mpd/test_tokenizer.py b/tests/mpd/test_tokenizer.py index b4d46719..2e3a6558 100644 --- a/tests/mpd/test_tokenizer.py +++ b/tests/mpd/test_tokenizer.py @@ -8,6 +8,7 @@ from mopidy.mpd import exceptions, tokenize class TestTokenizer(unittest.TestCase): + def assertTokenizeEquals(self, expected, line): # noqa: N802 self.assertEqual(expected, tokenize.split(line)) diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index 527cfef8..bf50687d 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -116,6 +116,7 @@ class TrackMpdFormatTest(unittest.TestCase): class PlaylistMpdFormatTest(unittest.TestCase): + def test_mpd_format(self): playlist = Playlist(tracks=[ Track(track_no=1), Track(track_no=2), Track(track_no=3)]) diff --git a/tests/stream/test_library.py b/tests/stream/test_library.py index 93292376..462136e4 100644 --- a/tests/stream/test_library.py +++ b/tests/stream/test_library.py @@ -19,6 +19,7 @@ from tests import path_to_data_dir class LibraryProviderTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.backend = mock.Mock() self.backend.uri_schemes = ['file'] diff --git a/tests/test_commands.py b/tests/test_commands.py index 0942b3a0..e16a660c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -9,6 +9,7 @@ from mopidy import commands class ConfigOverrideTypeTest(unittest.TestCase): + def test_valid_override(self): expected = (b'section', b'key', b'value') self.assertEqual( @@ -44,6 +45,7 @@ class ConfigOverrideTypeTest(unittest.TestCase): class CommandParsingTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.exit_patcher = mock.patch.object(commands.Command, 'exit') self.exit_mock = self.exit_patcher.start() @@ -258,6 +260,7 @@ class CommandParsingTest(unittest.TestCase): class UsageTest(unittest.TestCase): + @mock.patch('sys.argv') def test_prog_name_default_and_override(self, argv_mock): argv_mock.__getitem__.return_value = '/usr/bin/foo' @@ -294,6 +297,7 @@ class UsageTest(unittest.TestCase): class HelpTest(unittest.TestCase): + @mock.patch('sys.argv') def test_prog_name_default_and_override(self, argv_mock): argv_mock.__getitem__.return_value = '/usr/bin/foo' @@ -485,6 +489,7 @@ class HelpTest(unittest.TestCase): class RunTest(unittest.TestCase): + def test_default_implmentation_raises_error(self): with self.assertRaises(NotImplementedError): commands.Command().run() diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 3420891e..d684d8f5 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -6,6 +6,7 @@ from mopidy import exceptions class ExceptionsTest(unittest.TestCase): + def test_exception_can_include_message_string(self): exc = exceptions.MopidyException('foo') diff --git a/tests/test_ext.py b/tests/test_ext.py index f4e247b6..c58f6b20 100644 --- a/tests/test_ext.py +++ b/tests/test_ext.py @@ -6,6 +6,7 @@ from mopidy import config, ext class ExtensionTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.ext = ext.Extension() diff --git a/tests/test_help.py b/tests/test_help.py index d8058cb7..6dbf1da9 100644 --- a/tests/test_help.py +++ b/tests/test_help.py @@ -9,6 +9,7 @@ import mopidy class HelpTest(unittest.TestCase): + def test_help_has_mopidy_options(self): mopidy_dir = os.path.dirname(mopidy.__file__) args = [sys.executable, mopidy_dir, '--help'] diff --git a/tests/test_mixer.py b/tests/test_mixer.py index c57d861a..b9e05650 100644 --- a/tests/test_mixer.py +++ b/tests/test_mixer.py @@ -8,6 +8,7 @@ from mopidy import mixer class MixerListenerTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.listener = mixer.MixerListener() diff --git a/tests/test_models.py b/tests/test_models.py index 7711f00d..e9a8f439 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -9,6 +9,7 @@ from mopidy.models import ( class GenericCopyTest(unittest.TestCase): + def compare(self, orig, other): self.assertEqual(orig, other) self.assertNotEqual(id(orig), id(other)) @@ -58,6 +59,7 @@ class GenericCopyTest(unittest.TestCase): class RefTest(unittest.TestCase): + def test_uri(self): uri = 'an_uri' ref = Ref(uri=uri) @@ -131,6 +133,7 @@ class RefTest(unittest.TestCase): class ImageTest(unittest.TestCase): + def test_uri(self): uri = 'an_uri' image = Image(uri=uri) @@ -156,6 +159,7 @@ class ImageTest(unittest.TestCase): class ArtistTest(unittest.TestCase): + def test_uri(self): uri = 'an_uri' artist = Artist(uri=uri) @@ -286,6 +290,7 @@ class ArtistTest(unittest.TestCase): class AlbumTest(unittest.TestCase): + def test_uri(self): uri = 'an_uri' album = Album(uri=uri) @@ -498,6 +503,7 @@ class AlbumTest(unittest.TestCase): class TrackTest(unittest.TestCase): + def test_uri(self): uri = 'an_uri' track = Track(uri=uri) @@ -796,6 +802,7 @@ class TrackTest(unittest.TestCase): class TlTrackTest(unittest.TestCase): + def test_tlid(self): tlid = 123 tl_track = TlTrack(tlid=tlid) @@ -874,6 +881,7 @@ class TlTrackTest(unittest.TestCase): class PlaylistTest(unittest.TestCase): + def test_uri(self): uri = 'an_uri' playlist = Playlist(uri=uri) @@ -1065,6 +1073,7 @@ class PlaylistTest(unittest.TestCase): class SearchResultTest(unittest.TestCase): + def test_uri(self): uri = 'an_uri' result = SearchResult(uri=uri) diff --git a/tests/test_version.py b/tests/test_version.py index 932cc639..de4f8d4f 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -7,6 +7,7 @@ from mopidy import __version__ class VersionTest(unittest.TestCase): + def assertVersionLess(self, first, second): # noqa: N802 self.assertLess(StrictVersion(first), StrictVersion(second)) diff --git a/tests/utils/network/test_connection.py b/tests/utils/network/test_connection.py index 0ccaea0a..3ad1df6b 100644 --- a/tests/utils/network/test_connection.py +++ b/tests/utils/network/test_connection.py @@ -17,6 +17,7 @@ from tests import any_int, any_unicode class ConnectionTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.mock = Mock(spec=network.Connection) diff --git a/tests/utils/network/test_lineprotocol.py b/tests/utils/network/test_lineprotocol.py index 1b584e47..d3548117 100644 --- a/tests/utils/network/test_lineprotocol.py +++ b/tests/utils/network/test_lineprotocol.py @@ -14,6 +14,7 @@ from tests import any_unicode class LineProtocolTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.mock = Mock(spec=network.LineProtocol) diff --git a/tests/utils/network/test_server.py b/tests/utils/network/test_server.py index d85d6c27..5ea64fca 100644 --- a/tests/utils/network/test_server.py +++ b/tests/utils/network/test_server.py @@ -14,6 +14,7 @@ from tests import any_int class ServerTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.mock = Mock(spec=network.Server) diff --git a/tests/utils/network/test_utils.py b/tests/utils/network/test_utils.py index d5f558b4..55d68a99 100644 --- a/tests/utils/network/test_utils.py +++ b/tests/utils/network/test_utils.py @@ -9,6 +9,7 @@ from mopidy.utils import network class FormatHostnameTest(unittest.TestCase): + @patch('mopidy.utils.network.has_ipv6', True) def test_format_hostname_prefixes_ipv4_addresses_when_ipv6_available(self): network.has_ipv6 = True @@ -22,6 +23,7 @@ class FormatHostnameTest(unittest.TestCase): class TryIPv6SocketTest(unittest.TestCase): + @patch('socket.has_ipv6', False) def test_system_that_claims_no_ipv6_support(self): self.assertFalse(network.try_ipv6_socket()) @@ -40,6 +42,7 @@ class TryIPv6SocketTest(unittest.TestCase): class CreateSocketTest(unittest.TestCase): + @patch('mopidy.utils.network.has_ipv6', False) @patch('socket.socket') def test_ipv4_socket(self, socket_mock): diff --git a/tests/utils/test_deps.py b/tests/utils/test_deps.py index 95f5b982..0639d296 100644 --- a/tests/utils/test_deps.py +++ b/tests/utils/test_deps.py @@ -16,6 +16,7 @@ from mopidy.utils import deps class DepsTest(unittest.TestCase): + def test_format_dependency_list(self): adapters = [ lambda: dict(name='Python', version='FooPython 2.7.3'), diff --git a/tests/utils/test_encoding.py b/tests/utils/test_encoding.py index 68634855..2ec7e529 100644 --- a/tests/utils/test_encoding.py +++ b/tests/utils/test_encoding.py @@ -9,6 +9,7 @@ from mopidy.utils.encoding import locale_decode @mock.patch('mopidy.utils.encoding.locale.getpreferredencoding') class LocaleDecodeTest(unittest.TestCase): + def test_can_decode_utf8_strings_with_french_content(self, mock): mock.return_value = 'UTF-8' diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index 411c0db4..160afc4d 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -14,6 +14,7 @@ from tests import dummy_backend class Calculator(object): + def __init__(self): self._mem = None @@ -50,6 +51,7 @@ class Calculator(object): class JsonRpcTestBase(unittest.TestCase): + def setUp(self): # noqa: N802 self.backend = dummy_backend.create_proxy() self.calc = Calculator() @@ -74,12 +76,14 @@ class JsonRpcTestBase(unittest.TestCase): class JsonRpcSetupTest(JsonRpcTestBase): + def test_empty_object_mounts_is_not_allowed(self): with self.assertRaises(AttributeError): jsonrpc.JsonRpcWrapper(objects={'': Calculator()}) class JsonRpcSerializationTest(JsonRpcTestBase): + def test_handle_json_converts_from_and_to_json(self): self.jrw.handle_data = mock.Mock() self.jrw.handle_data.return_value = {'foo': 'response'} @@ -145,6 +149,7 @@ class JsonRpcSerializationTest(JsonRpcTestBase): class JsonRpcSingleCommandTest(JsonRpcTestBase): + def test_call_method_on_root(self): request = { 'jsonrpc': '2.0', @@ -249,6 +254,7 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase): class JsonRpcSingleNotificationTest(JsonRpcTestBase): + def test_notification_does_not_return_a_result(self): request = { 'jsonrpc': '2.0', @@ -283,6 +289,7 @@ class JsonRpcSingleNotificationTest(JsonRpcTestBase): class JsonRpcBatchTest(JsonRpcTestBase): + def test_batch_of_only_commands_returns_all(self): self.core.tracklist.set_random(True).get() @@ -331,6 +338,7 @@ class JsonRpcBatchTest(JsonRpcTestBase): class JsonRpcSingleCommandErrorTest(JsonRpcTestBase): + def test_application_error_response(self): request = { 'jsonrpc': '2.0', @@ -500,6 +508,7 @@ class JsonRpcSingleCommandErrorTest(JsonRpcTestBase): class JsonRpcBatchErrorTest(JsonRpcTestBase): + def test_empty_batch_list_causes_invalid_request_error(self): request = [] response = self.jrw.handle_data(request) @@ -566,6 +575,7 @@ class JsonRpcBatchErrorTest(JsonRpcTestBase): class JsonRpcInspectorTest(JsonRpcTestBase): + def test_empty_object_mounts_is_not_allowed(self): with self.assertRaises(AttributeError): jsonrpc.JsonRpcInspector(objects={'': Calculator}) diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index 6fd4f8d1..1acd7271 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -16,6 +16,7 @@ import tests class GetOrCreateDirTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.parent = tempfile.mkdtemp() @@ -67,6 +68,7 @@ class GetOrCreateDirTest(unittest.TestCase): class GetOrCreateFileTest(unittest.TestCase): + def setUp(self): # noqa: N802 self.parent = tempfile.mkdtemp() @@ -135,6 +137,7 @@ class GetOrCreateFileTest(unittest.TestCase): class PathToFileURITest(unittest.TestCase): + def test_simple_path(self): result = path.path_to_uri('/etc/fstab') self.assertEqual(result, 'file:///etc/fstab') @@ -157,6 +160,7 @@ class PathToFileURITest(unittest.TestCase): class UriToPathTest(unittest.TestCase): + def test_simple_uri(self): result = path.uri_to_path('file:///etc/fstab') self.assertEqual(result, '/etc/fstab'.encode('utf-8')) @@ -175,6 +179,7 @@ class UriToPathTest(unittest.TestCase): class SplitPathTest(unittest.TestCase): + def test_empty_path(self): self.assertEqual([], path.split_path('')) @@ -378,6 +383,7 @@ class FindMTimesTest(unittest.TestCase): # TODO: kill this in favour of just os.path.getmtime + mocks class MtimeTest(unittest.TestCase): + def tearDown(self): # noqa: N802 path.mtime.undo_fake() From b34b1c26207bbdd1955f7cb55c3c32334128a818 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Apr 2015 00:06:47 +0200 Subject: [PATCH 307/314] Fix indentation issues found with autopep8 --- mopidy/local/search.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/local/search.py b/mopidy/local/search.py index fdbe871c..322cdd1e 100644 --- a/mopidy/local/search.py +++ b/mopidy/local/search.py @@ -43,8 +43,8 @@ def find_exact(tracks, query=None, limit=100, offset=0, uris=None): return filter(lambda a: q == a.name, t.artists) def albumartist_filter(t): - return any([q == a.name for a in getattr(t.album, - 'artists', [])]) + return any([ + q == a.name for a in getattr(t.album, 'artists', [])]) def composer_filter(t): return any([q == a.name for a in getattr(t, 'composers', [])]) @@ -150,8 +150,8 @@ def search(tracks, query=None, limit=100, offset=0, uris=None): q in t.album.name.lower()) def artist_filter(t): - return bool(filter(lambda a: - bool(a.name and q in a.name.lower()), t.artists)) + return bool(filter( + lambda a: bool(a.name and q in a.name.lower()), t.artists)) def albumartist_filter(t): return any([a.name and q in a.name.lower() From 2234a04fc7e1507307f3f70dc825188cca289375 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 31 Mar 2015 21:27:08 +0200 Subject: [PATCH 308/314] audio: Make outputs helper only handle tee-ing. The queue which is needed for gapless has been moved up to a audio-sink bin which also wraps the outputs. --- mopidy/audio/actor.py | 46 +++++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index b4c78ecb..3d8c8ef8 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -152,26 +152,12 @@ class _Appsrc(object): # TODO: expose this as a property on audio when #790 gets further along. class _Outputs(gst.Bin): def __init__(self): - gst.Bin.__init__(self) + gst.Bin.__init__(self, 'outputs') self._tee = gst.element_factory_make('tee') self.add(self._tee) - # Queue element to buy us time between the about to finish event and - # the actual switch, i.e. about to switch can block for longer thanks - # to this queue. - # TODO: make the min-max values a setting? - # TODO: this does not belong in this class. - queue = gst.element_factory_make('queue') - queue.set_property('max-size-buffers', 0) - queue.set_property('max-size-bytes', 0) - queue.set_property('max-size-time', 5 * gst.SECOND) - queue.set_property('min-threshold-time', 3 * gst.SECOND) - self.add(queue) - - queue.link(self._tee) - - ghost_pad = gst.GhostPad('sink', queue.get_pad('sink')) + ghost_pad = gst.GhostPad('sink', self._tee.get_pad('sink')) self.add_pad(ghost_pad) # Add an always connected fakesink which respects the clock so the tee @@ -451,8 +437,9 @@ class Audio(pykka.ThreadingActor): try: self._setup_preferences() self._setup_playbin() - self._setup_output() + self._setup_outputs() self._setup_mixer() + self._setup_audio_sink() except gobject.GError as ex: logger.exception(ex) process.exit_process() @@ -492,7 +479,7 @@ class Audio(pykka.ThreadingActor): self._signals.disconnect(self._playbin, 'source-setup') self._playbin.set_state(gst.STATE_NULL) - def _setup_output(self): + def _setup_outputs(self): # We don't want to use outputs for regular testing, so just install # an unsynced fakesink when someone asks for a 'testoutput'. if self._config['audio']['output'] == 'testoutput': @@ -505,12 +492,33 @@ class Audio(pykka.ThreadingActor): process.exit_process() # TODO: move this up the chain self._handler.setup_event_handling(self._outputs.get_pad('sink')) - self._playbin.set_property('audio-sink', self._outputs) def _setup_mixer(self): if self.mixer: self.mixer.setup(self._playbin, self.actor_ref.proxy().mixer) + def _setup_audio_sink(self): + audio_sink = gst.Bin('audio-sink') + + # Queue element to buy us time between the about to finish event and + # the actual switch, i.e. about to switch can block for longer thanks + # to this queue. + # TODO: make the min-max values a setting? + queue = gst.element_factory_make('queue') + queue.set_property('max-size-buffers', 0) + queue.set_property('max-size-bytes', 0) + queue.set_property('max-size-time', 5 * gst.SECOND) + queue.set_property('min-threshold-time', 3 * gst.SECOND) + + audio_sink.add(queue) + audio_sink.add(self._outputs) + queue.link(self._outputs) + + ghost_pad = gst.GhostPad('sink', queue.get_pad('sink')) + audio_sink.add_pad(ghost_pad) + + self._playbin.set_property('audio-sink', audio_sink) + def _teardown_mixer(self): if self.mixer: self.mixer.teardown() From 8236417e9d2c4d9c400b60dcbc4714de52b2bcbe Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 2 Apr 2015 23:26:05 +0200 Subject: [PATCH 309/314] audio: Move software volume into audiosink. This turns off playbin controlled volume, which implies that pulsesink volume can no longer be controlled by Mopidy. This is likely something we have to break, or at least rethink for multiple output support any way. With this change we now have software volume after our large queue, which means volume changes should happen much faster. --- mopidy/audio/actor.py | 34 ++++++++++------------------------ 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 3d8c8ef8..66718368 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -36,23 +36,6 @@ _GST_STATE_MAPPING = { MB = 1 << 20 -# GST_PLAY_FLAG_VIDEO (1<<0) -# GST_PLAY_FLAG_AUDIO (1<<1) -# GST_PLAY_FLAG_TEXT (1<<2) -# GST_PLAY_FLAG_VIS (1<<3) -# GST_PLAY_FLAG_SOFT_VOLUME (1<<4) -# GST_PLAY_FLAG_NATIVE_AUDIO (1<<5) -# GST_PLAY_FLAG_NATIVE_VIDEO (1<<6) -# GST_PLAY_FLAG_DOWNLOAD (1<<7) -# GST_PLAY_FLAG_BUFFERING (1<<8) -# GST_PLAY_FLAG_DEINTERLACE (1<<9) -# GST_PLAY_FLAG_SOFT_COLORBALANCE (1<<10) - -# Default flags to use for playbin: AUDIO, SOFT_VOLUME -# TODO: consider removing soft volume when we do multi outputs and handling it -# ourselves. -PLAYBIN_FLAGS = (1 << 1) | (1 << 4) - class _Signals(object): """Helper for tracking gobject signal registrations""" @@ -438,7 +421,6 @@ class Audio(pykka.ThreadingActor): self._setup_preferences() self._setup_playbin() self._setup_outputs() - self._setup_mixer() self._setup_audio_sink() except gobject.GError as ex: logger.exception(ex) @@ -459,7 +441,7 @@ class Audio(pykka.ThreadingActor): def _setup_playbin(self): playbin = gst.element_factory_make('playbin2') - playbin.set_property('flags', PLAYBIN_FLAGS) + playbin.set_property('flags', 2) # GST_PLAY_FLAG_AUDIO # TODO: turn into config values... playbin.set_property('buffer-size', 2 * 1024 * 1024) @@ -493,10 +475,6 @@ class Audio(pykka.ThreadingActor): self._handler.setup_event_handling(self._outputs.get_pad('sink')) - def _setup_mixer(self): - if self.mixer: - self.mixer.setup(self._playbin, self.actor_ref.proxy().mixer) - def _setup_audio_sink(self): audio_sink = gst.Bin('audio-sink') @@ -512,7 +490,15 @@ class Audio(pykka.ThreadingActor): audio_sink.add(queue) audio_sink.add(self._outputs) - queue.link(self._outputs) + + if self.mixer: + volume = gst.element_factory_make('volume') + audio_sink.add(volume) + queue.link(volume) + volume.link(self._outputs) + self.mixer.setup(volume, self.actor_ref.proxy().mixer) + else: + queue.link(self._outputs) ghost_pad = gst.GhostPad('sink', queue.get_pad('sink')) audio_sink.add_pad(ghost_pad) From e76c3c90120df63a3cb06dd42a222f44f2e23e78 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 2 Apr 2015 23:35:15 +0200 Subject: [PATCH 310/314] audio: Remove notify::mute/volume from software mixer These will never be triggered externally when using plain software volume. --- mopidy/audio/actor.py | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 66718368..77b64d58 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -183,10 +183,6 @@ class SoftwareMixer(object): def setup(self, element, mixer_ref): self._element = element - - self._signals.connect(element, 'notify::volume', self._volume_changed) - self._signals.connect(element, 'notify::mute', self._mute_changed) - self._mixer.setup(mixer_ref) def teardown(self): @@ -198,24 +194,16 @@ class SoftwareMixer(object): def set_volume(self, volume): self._element.set_property('volume', volume / 100.0) + self._mixer.trigger_volume_changed(volume) def get_mute(self): return self._element.get_property('mute') def set_mute(self, mute): - return self._element.set_property('mute', bool(mute)) - - def _volume_changed(self, element, property_): - old_volume, self._last_volume = self._last_volume, self.get_volume() - if old_volume != self._last_volume: - gst_logger.debug('Notify volume: %s', self._last_volume / 100.0) - self._mixer.trigger_volume_changed(self._last_volume) - - def _mute_changed(self, element, property_): - old_mute, self._last_mute = self._last_mute, self.get_mute() - if old_mute != self._last_mute: - gst_logger.debug('Notify mute: %s', self._last_mute) - self._mixer.trigger_mute_changed(self._last_mute) + result = self._element.set_property('mute', bool(mute)) + if result: + self._mixer.trigger_mute_changed(bool(mute)) + return result class _Handler(object): From 9f90b37aa53748302a3fc9fb3f39b8c9f3438e0d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 2 Apr 2015 23:40:58 +0200 Subject: [PATCH 311/314] audio: Limit post tee queue size Not sure how small we can safely make this, but basically with the volume element in front of the tee we "need" this as small as possible so the volume changes fell snappy. Alternative would be one volume element per tee branch. --- mopidy/audio/actor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 77b64d58..b4e5ebbc 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -164,7 +164,9 @@ class _Outputs(gst.Bin): def _add(self, element): # All tee branches need a queue in front of them. + # But keep the queue short so the volume change isn't to slow: queue = gst.element_factory_make('queue') + queue.set_property('max-size-buffers', 5) self.add(element) self.add(queue) queue.link(element) From db48845e91212d1a0f5577b06f0fdf7fbd390023 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 2 Apr 2015 23:48:27 +0200 Subject: [PATCH 312/314] audio: Adjust queue sizes. These are mostly just gut feeling guesses. We should really start exposing at least a few of these as settings soon. --- mopidy/audio/actor.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index b4e5ebbc..35bd215f 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -34,8 +34,6 @@ _GST_STATE_MAPPING = { gst.STATE_PAUSED: PlaybackState.PAUSED, gst.STATE_NULL: PlaybackState.STOPPED} -MB = 1 << 20 - class _Signals(object): """Helper for tracking gobject signal registrations""" @@ -97,7 +95,7 @@ class _Appsrc(object): source.set_property('caps', self._caps) source.set_property('format', b'time') source.set_property('stream-type', b'seekable') - source.set_property('max-bytes', 1 * MB) + source.set_property('max-bytes', 1 << 20) # 1MB source.set_property('min-percent', 50) if self._need_data_callback: @@ -434,8 +432,8 @@ class Audio(pykka.ThreadingActor): playbin.set_property('flags', 2) # GST_PLAY_FLAG_AUDIO # TODO: turn into config values... - playbin.set_property('buffer-size', 2 * 1024 * 1024) - playbin.set_property('buffer-duration', 2 * gst.SECOND) + playbin.set_property('buffer-size', 5 << 20) # 5MB + playbin.set_property('buffer-duration', 5 * gst.SECOND) self._signals.connect(playbin, 'source-setup', self._on_source_setup) self._signals.connect(playbin, 'about-to-finish', @@ -475,8 +473,8 @@ class Audio(pykka.ThreadingActor): queue = gst.element_factory_make('queue') queue.set_property('max-size-buffers', 0) queue.set_property('max-size-bytes', 0) - queue.set_property('max-size-time', 5 * gst.SECOND) - queue.set_property('min-threshold-time', 3 * gst.SECOND) + queue.set_property('max-size-time', 3 * gst.SECOND) + queue.set_property('min-threshold-time', 1 * gst.SECOND) audio_sink.add(queue) audio_sink.add(self._outputs) From bee0a4c4d5fdc0843eeb8a71ac72688af196ea32 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 2 Apr 2015 23:59:46 +0200 Subject: [PATCH 313/314] docs: Add audio volume changes to changelog --- docs/changelog.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index b976c169..01a5dc6d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,19 @@ Changelog This changelog is used to track all major changes to Mopidy. +v1.0.1 (unreleased) +=================== + +Audio +----- + +- Software volume control has been reworked to greatly reduce the delay between + changing the volume and the change taking effect. (Fixes: :issue:`1097`) + +- Software volume is no longer tied to the PulseAudio application volume when + using ``pulsesink``. This behavior was confusing for many users and doesn't + work well with the plans for multiple outputs. + v1.0.0 (2015-03-25) =================== From 5d94a265cd240c1f0eab43286e1dae7848442c48 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 5 Apr 2015 02:13:30 +0200 Subject: [PATCH 314/314] docs: Tweak changelog --- docs/changelog.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 01a5dc6d..ce7be87b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,18 +5,18 @@ Changelog This changelog is used to track all major changes to Mopidy. -v1.0.1 (unreleased) +v1.0.1 (UNRELEASED) =================== -Audio ------ +- Audio: Software volume control has been reworked to greatly reduce the delay + between changing the volume and the change taking effect. (Fixes: + :issue:`1097`) -- Software volume control has been reworked to greatly reduce the delay between - changing the volume and the change taking effect. (Fixes: :issue:`1097`) +- Audio: As a side effect of the previous bug fix, software volume is no longer + tied to the PulseAudio application volume when using ``pulsesink``. This + behavior was confusing for many users and doesn't work well with the plans + for multiple outputs. -- Software volume is no longer tied to the PulseAudio application volume when - using ``pulsesink``. This behavior was confusing for many users and doesn't - work well with the plans for multiple outputs. v1.0.0 (2015-03-25) ===================