From 3cf1b13d4945c6200eca893e4b1f900101530d80 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 16 Sep 2012 17:47:51 +0200 Subject: [PATCH 001/233] Cleanup mopidy.utils.settings. - Move to module import for stdlib - Extract path manipulation code to a method - Avoid uneeded copying of settings dict by binding current localy. --- mopidy/utils/settings.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 726917c6..4c2da4bc 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -1,10 +1,12 @@ # Absolute import needed to import ~/.mopidy/settings.py and not ourselves from __future__ import absolute_import -from copy import copy + +import copy import getpass import logging import os -from pprint import pformat +import pprint +import string import sys from mopidy import SettingsError, SETTINGS_PATH, SETTINGS_FILE @@ -39,7 +41,7 @@ class SettingsProxy(object): @property def current(self): - current = copy(self.default) + current = copy.copy(self.default) current.update(self.local) current.update(self.runtime) return current @@ -47,16 +49,18 @@ class SettingsProxy(object): def __getattr__(self, attr): if not self._is_setting(attr): return - if attr not in self.current: + + current = self.current # bind locally to avoid copying+updates + if attr not in current: raise SettingsError(u'Setting "%s" is not set.' % attr) - value = self.current[attr] + + value = current[attr] if isinstance(value, basestring) and len(value) == 0: raise SettingsError(u'Setting "%s" is empty.' % attr) if not value: return value if attr.endswith('_PATH') or attr.endswith('_FILE'): - value = os.path.expanduser(value) - value = os.path.abspath(value) + value = self.expandpath(value) return value def __setattr__(self, attr, value): @@ -65,6 +69,11 @@ class SettingsProxy(object): else: super(SettingsProxy, self).__setattr__(attr, value) + def expandpath(self, value): + value = os.path.expanduser(value) + value = os.path.abspath(value) + return value + def validate(self, interactive): if interactive: self._read_missing_settings_from_stdin(self.current, self.runtime) @@ -194,7 +203,8 @@ def format_settings_list(settings): for (key, value) in sorted(settings.current.iteritems()): default_value = settings.default.get(key) masked_value = mask_value_if_secret(key, value) - lines.append(u'%s: %s' % (key, indent(pformat(masked_value), places=2))) + lines.append(u'%s: %s' % (key, indent( + pprint.pformat(masked_value), places=2))) if value != default_value and default_value is not None: lines.append(u' Default: %s' % indent(pformat(default_value), places=4)) From 355ff811af3e5f8c4ae38765a9d75ceab61d7ba4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 16 Sep 2012 18:03:07 +0200 Subject: [PATCH 002/233] Add $XDG_name_DIR substitution to _FILE and _PATH settings. This change removes the practice of hardcoding fallbacks to these paths outside of the base settings file. We can probably get rid of some of the location CONSTANTS that are currently in use in mopidy/__init__.py --- mopidy/backends/local/__init__.py | 18 +++++------------- mopidy/backends/spotify/session_manager.py | 5 ++--- mopidy/settings.py | 21 ++++++++++++--------- mopidy/utils/settings.py | 9 ++++++++- 4 files changed, 27 insertions(+), 26 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index c7126824..975ec458 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -15,13 +15,6 @@ from .translator import parse_m3u, parse_mpd_tag_cache logger = logging.getLogger(u'mopidy.backends.local') -DEFAULT_PLAYLIST_PATH = os.path.join(DATA_PATH, 'playlists') -DEFAULT_TAG_CACHE_FILE = os.path.join(DATA_PATH, 'tag_cache') -DEFAULT_MUSIC_PATH = str(glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC)) - -if not DEFAULT_MUSIC_PATH or DEFAULT_MUSIC_PATH == os.path.expanduser(u'~'): - DEFAULT_MUSIC_PATH = os.path.expanduser(u'~/music') - class LocalBackend(ThreadingActor, base.Backend): """ @@ -81,7 +74,7 @@ class LocalPlaybackController(core.PlaybackController): class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def __init__(self, *args, **kwargs): super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs) - self._folder = settings.LOCAL_PLAYLIST_PATH or DEFAULT_PLAYLIST_PATH + self._folder = settings.LOCAL_PLAYLIST_PATH self.refresh() def lookup(self, uri): @@ -158,12 +151,11 @@ class LocalLibraryProvider(base.BaseLibraryProvider): self.refresh() def refresh(self, uri=None): - tag_cache = settings.LOCAL_TAG_CACHE_FILE or DEFAULT_TAG_CACHE_FILE - music_folder = settings.LOCAL_MUSIC_PATH or DEFAULT_MUSIC_PATH + tracks = parse_mpd_tag_cache(settings.LOCAL_TAG_CACHE_FILE, + settings.LOCAL_MUSIC_PATH) - tracks = parse_mpd_tag_cache(tag_cache, music_folder) - - logger.info('Loading tracks in %s from %s', music_folder, tag_cache) + logger.info('Loading tracks in %s from %s', settings.LOCAL_MUSIC_PATH, + settings.LOCAL_TAG_CACHE_FILE) for track in tracks: self._uri_mapping[track.uri] = track diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index aa3734ae..856257f1 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -6,7 +6,7 @@ from spotify.manager import SpotifySessionManager as PyspotifySessionManager from pykka.registry import ActorRegistry -from mopidy import audio, get_version, settings, CACHE_PATH +from mopidy import audio, get_version, settings from mopidy.backends.base import Backend from mopidy.backends.spotify import BITRATES from mopidy.backends.spotify.container_manager import SpotifyContainerManager @@ -22,8 +22,7 @@ logger = logging.getLogger('mopidy.backends.spotify.session_manager') class SpotifySessionManager(BaseThread, PyspotifySessionManager): - cache_location = (settings.SPOTIFY_CACHE_PATH - or os.path.join(CACHE_PATH, 'spotify')) + cache_location = settings.SPOTIFY_CACHE_PATH settings_location = cache_location appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key') user_agent = 'Mopidy %s' % get_version() diff --git a/mopidy/settings.py b/mopidy/settings.py index 0612fc24..a2270707 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -85,9 +85,8 @@ LASTFM_PASSWORD = u'' #: #: Default:: #: -#: # Defaults to asking glib where music is stored, fallback is ~/music -#: LOCAL_MUSIC_PATH = None -LOCAL_MUSIC_PATH = None +#: LOCAL_MUSIC_PATH = u'$XDG_MUSIC_DIR' +LOCAL_MUSIC_PATH = u'$XDG_MUSIC_DIR' #: Path to playlist folder with m3u files for local music. #: @@ -95,8 +94,8 @@ LOCAL_MUSIC_PATH = None #: #: Default:: #: -#: LOCAL_PLAYLIST_PATH = None # Implies $XDG_DATA_DIR/mopidy/playlists -LOCAL_PLAYLIST_PATH = None +#: LOCAL_PLAYLIST_PATH = u'$XDG_DATA_DIR/mopidy/playlists' +LOCAL_PLAYLIST_PATH = u'$XDG_DATA_DIR/mopidy/playlists' #: Path to tag cache for local music. #: @@ -104,8 +103,8 @@ LOCAL_PLAYLIST_PATH = None #: #: Default:: #: -#: LOCAL_TAG_CACHE_FILE = None # Implies $XDG_DATA_DIR/mopidy/tag_cache -LOCAL_TAG_CACHE_FILE = None +#: LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache' +LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache' #: Sound mixer to use. #: @@ -177,7 +176,11 @@ OUTPUT = u'autoaudiosink' #: Path to the Spotify cache. #: #: Used by :mod:`mopidy.backends.spotify`. -SPOTIFY_CACHE_PATH = None +#: +#: Default:: +#: +#: SPOTIFY_CACHE_PATH = u'$XDG_CACHE_DIR/mopidy/spotify' +SPOTIFY_CACHE_PATH = u'$XDG_CACHE_DIR/mopidy/spotify' #: Your Spotify Premium username. #: @@ -194,7 +197,7 @@ SPOTIFY_PASSWORD = u'' #: Available values are 96, 160, and 320. #: #: Used by :mod:`mopidy.backends.spotify`. -# +#: #: Default:: #: #: SPOTIFY_BITRATE = 160 diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 4c2da4bc..fae4278f 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -3,6 +3,7 @@ from __future__ import absolute_import import copy import getpass +import glib import logging import os import pprint @@ -14,6 +15,12 @@ from mopidy.utils.log import indent logger = logging.getLogger('mopidy.utils.settings') +XDG_DIRS = { + 'XDG_CACHE_DIR': glib.get_user_cache_dir(), + 'XDG_DATA_DIR': glib.get_user_data_dir(), + 'XDG_MUSIC_DIR': glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC), +} + class SettingsProxy(object): def __init__(self, default_settings_module): @@ -72,7 +79,7 @@ class SettingsProxy(object): def expandpath(self, value): value = os.path.expanduser(value) value = os.path.abspath(value) - return value + return string.Template(value).safe_substitute(XDG_DIRS) def validate(self, interactive): if interactive: From 7ceb53006408f568da9d9ba600c46f03580fcd99 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 16 Sep 2012 18:14:11 +0200 Subject: [PATCH 003/233] Updated find files to ignore hidden files and folders. --- mopidy/utils/path.py | 20 +++++++++++++++----- tests/data/.blank.mp3 | Bin 0 -> 9360 bytes tests/data/.hidden/.gitignore | 0 tests/utils/path_test.py | 6 ++++++ 4 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 tests/data/.blank.mp3 create mode 100644 tests/data/.hidden/.gitignore diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 5d99ac12..b276a027 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -47,21 +47,31 @@ def split_path(path): break return parts -# pylint: disable = W0612 -# Unused variable 'dirnames' def find_files(path): if os.path.isfile(path): if not isinstance(path, unicode): path = path.decode('utf-8') - yield path + if not os.path.basename(path).startswith('.'): + yield path else: for dirpath, dirnames, filenames in os.walk(path): + # Filter out hidden folders by modifying dirnames in place. + for dirname in dirnames: + if dirname.startswith('.'): + dirnames.remove(dirname) + for filename in filenames: + # Skip hidden files. + if filename.startswith('.'): + continue + filename = os.path.join(dirpath, filename) if not isinstance(filename, unicode): - filename = filename.decode('utf-8') + try: + filename = filename.decode('utf-8') + except UnicodeDecodeError: + filename = filename.decode('latin1') yield filename -# pylint: enable = W0612 # FIXME replace with mock usage in tests. class Mtime(object): diff --git a/tests/data/.blank.mp3 b/tests/data/.blank.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..ef159a700449f6a2bf4c03fc206be8f2ff1c7469 GIT binary patch literal 9360 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 i`}Ggczgz$M7U3;$Kn@+<3 literal 0 HcmV?d00001 diff --git a/tests/data/.hidden/.gitignore b/tests/data/.hidden/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index 19bae375..184970ae 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -156,6 +156,12 @@ class FindFilesTest(unittest.TestCase): self.assert_(is_unicode(name), '%s is not unicode object' % repr(name)) + def test_ignores_hidden_folders(self): + self.assertEqual(self.find('.hidden'), []) + + def test_ignores_hidden_files(self): + self.assertEqual(self.find('.blank.mp3'), []) + class MtimeTest(unittest.TestCase): def tearDown(self): From c2e1b0d6727ff0a0c0f2efdc23e88ce685e7a731 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 16 Sep 2012 18:17:28 +0200 Subject: [PATCH 004/233] Use find_files() as an iterator in scanner. --- mopidy/scanner.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 3bcf03d9..29511c80 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -52,7 +52,7 @@ def translator(data): class Scanner(object): def __init__(self, folder, data_callback, error_callback=None): - self.uris = [path_to_uri(f) for f in find_files(folder)] + self.files = find_files(folder) self.data_callback = data_callback self.error_callback = error_callback self.loop = gobject.MainLoop() @@ -114,18 +114,19 @@ class Scanner(object): return None def next_uri(self): - if not self.uris: - return self.stop() - + try: + uri = path_to_uri(self.files.next()) + except StopIteration: + self.stop() + return False self.pipe.set_state(gst.STATE_NULL) - self.uribin.set_property('uri', self.uris.pop()) + self.uribin.set_property('uri', uri) self.pipe.set_state(gst.STATE_PAUSED) + return True def start(self): - if not self.uris: - return - self.next_uri() - self.loop.run() + if self.next_uri(): + self.loop.run() def stop(self): self.pipe.set_state(gst.STATE_NULL) From 6cc57701f96e582317ee1e219ac53e2d838a3f47 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 16 Sep 2012 19:28:15 +0200 Subject: [PATCH 005/233] Update parse_m3u to allow caller to decide what location playlist is relative to. --- mopidy/backends/local/__init__.py | 4 ++-- mopidy/backends/local/translator.py | 6 ++---- tests/backends/local/translator_test.py | 28 +++++++++++++++++-------- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 975ec458..db86e56f 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -7,7 +7,7 @@ import shutil from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry -from mopidy import audio, core, settings, DATA_PATH +from mopidy import audio, core, settings from mopidy.backends import base from mopidy.models import Playlist, Track, Album @@ -88,7 +88,7 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): for m3u in glob.glob(os.path.join(self._folder, '*.m3u')): name = os.path.basename(m3u)[:-len('.m3u')] tracks = [] - for uri in parse_m3u(m3u): + for uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): try: tracks.append(self.backend.library.lookup(uri)) except LookupError, e: diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 3b610a94..1fea555c 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -7,7 +7,7 @@ from mopidy.models import Track, Artist, Album from mopidy.utils import locale_decode from mopidy.utils.path import path_to_uri -def parse_m3u(file_path): +def parse_m3u(file_path, music_folder): """ Convert M3U file list of uris @@ -29,8 +29,6 @@ def parse_m3u(file_path): """ uris = [] - folder = os.path.dirname(file_path) - try: with open(file_path) as m3u: contents = m3u.readlines() @@ -48,7 +46,7 @@ def parse_m3u(file_path): if line.startswith('file://'): uris.append(line) else: - path = path_to_uri(folder, line) + path = path_to_uri(music_folder, line) uris.append(path) return uris diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py index 1dceb737..08f29c1b 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/backends/local/translator_test.py @@ -9,6 +9,7 @@ from mopidy.models import Track, Artist, Album from tests import unittest, path_to_data_dir +data_dir = path_to_data_dir('') song1_path = path_to_data_dir('song1.mp3') song2_path = path_to_data_dir('song2.mp3') encoded_path = path_to_data_dir(u'æøå.mp3') @@ -21,22 +22,32 @@ encoded_uri = path_to_uri(encoded_path) class M3UToUriTest(unittest.TestCase): def test_empty_file(self): - uris = parse_m3u(path_to_data_dir('empty.m3u')) + uris = parse_m3u(path_to_data_dir('empty.m3u'), data_dir) self.assertEqual([], uris) def test_basic_file(self): - uris = parse_m3u(path_to_data_dir('one.m3u')) + uris = parse_m3u(path_to_data_dir('one.m3u'), data_dir) self.assertEqual([song1_uri], uris) def test_file_with_comment(self): - uris = parse_m3u(path_to_data_dir('comment.m3u')) + uris = parse_m3u(path_to_data_dir('comment.m3u'), data_dir) self.assertEqual([song1_uri], uris) + def test_file_is_relative_to_correct_folder(self): + with tempfile.NamedTemporaryFile(delete=False) as tmp: + tmp.write('song1.mp3') + try: + uris = parse_m3u(tmp.name, data_dir) + self.assertEqual([song1_uri], uris) + finally: + if os.path.exists(tmp.name): + os.remove(tmp.name) + def test_file_with_absolute_files(self): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write(song1_path) try: - uris = parse_m3u(tmp.name) + uris = parse_m3u(tmp.name, data_dir) self.assertEqual([song1_uri], uris) finally: if os.path.exists(tmp.name): @@ -48,29 +59,28 @@ class M3UToUriTest(unittest.TestCase): tmp.write('# comment \n') tmp.write(song2_path) try: - uris = parse_m3u(tmp.name) + uris = parse_m3u(tmp.name, data_dir) self.assertEqual([song1_uri, song2_uri], uris) finally: if os.path.exists(tmp.name): os.remove(tmp.name) - def test_file_with_uri(self): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write(song1_uri) try: - uris = parse_m3u(tmp.name) + uris = parse_m3u(tmp.name, data_dir) self.assertEqual([song1_uri], uris) finally: if os.path.exists(tmp.name): os.remove(tmp.name) def test_encoding_is_latin1(self): - uris = parse_m3u(path_to_data_dir('encoding.m3u')) + uris = parse_m3u(path_to_data_dir('encoding.m3u'), data_dir) self.assertEqual([encoded_uri], uris) def test_open_missing_file(self): - uris = parse_m3u(path_to_data_dir('non-existant.m3u')) + uris = parse_m3u(path_to_data_dir('non-existant.m3u'), data_dir) self.assertEqual([], uris) From f9a9d264dccfd0db339589c5789bd58613b0cdcc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 16 Sep 2012 20:28:01 +0200 Subject: [PATCH 006/233] Log and exit if output setup causes LinkError --- mopidy/audio/__init__.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index dd98dfa8..7d5b626c 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -90,16 +90,18 @@ class Audio(ThreadingActor): try: self._output = gst.parse_bin_from_description( settings.OUTPUT, ghost_unconnected_pads=True) + self._pipeline.add(self._output) + gst.element_link_many(self._pipeline.get_by_name('queue'), + self._output) + logger.info('Output set to %s', settings.OUTPUT) except gobject.GError as ex: logger.error('Failed to create output "%s": %s', settings.OUTPUT, ex) process.exit_process() - return - - self._pipeline.add(self._output) - gst.element_link_many(self._pipeline.get_by_name('queue'), - self._output) - logger.info('Output set to %s', settings.OUTPUT) + except gst.LinkError as ex: + logger.error('Failed to link output "%s": %s', + settings.OUTPUT, ex) + process.exit_process() def _setup_mixer(self): if not settings.MIXER: From fdde2eb2bf2920ebdb2c560d76d9c080a78a6d4b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 16 Sep 2012 20:29:24 +0200 Subject: [PATCH 007/233] docs: Don't guesstime release to include multi-backend --- docs/settings.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/settings.rst b/docs/settings.rst index 0c1a3c7e..a79dfd78 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -56,7 +56,7 @@ You may also want to change some of the ``LOCAL_*`` settings. See Currently, Mopidy supports using Spotify *or* local storage as a music source. We're working on using both sources simultaneously, and will - hopefully have support for this in the 0.6 release. + have support for this in a future release. .. _generating_a_tag_cache: From 0c674c5341ff53daeced9844b88767924f66d36a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 16 Sep 2012 21:30:02 +0200 Subject: [PATCH 008/233] Remove dead code --- mopidy/audio/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 7d5b626c..448412b4 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -98,10 +98,6 @@ class Audio(ThreadingActor): logger.error('Failed to create output "%s": %s', settings.OUTPUT, ex) process.exit_process() - except gst.LinkError as ex: - logger.error('Failed to link output "%s": %s', - settings.OUTPUT, ex) - process.exit_process() def _setup_mixer(self): if not settings.MIXER: From dda5e5261a448c57892e9ef3d7efa9447deea6fd Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 16 Sep 2012 22:07:59 +0200 Subject: [PATCH 009/233] Move and rename expand_path to mopidy.utils.path Also switches a bit move of mopidy.utils.settings over to module imports and double spaces between functions. --- mopidy/utils/path.py | 23 ++++++++++++++++++++++- mopidy/utils/settings.py | 24 ++++++------------------ 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index b276a027..ee8f3c65 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -1,11 +1,20 @@ +import glib import logging import os -import sys import re +import string +import sys import urllib logger = logging.getLogger('mopidy.utils.path') +XDG_DIRS = { + 'XDG_CACHE_DIR': glib.get_user_cache_dir(), + 'XDG_DATA_DIR': glib.get_user_data_dir(), + 'XDG_MUSIC_DIR': glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC), +} + + def get_or_create_folder(folder): folder = os.path.expanduser(folder) if os.path.isfile(folder): @@ -16,6 +25,7 @@ def get_or_create_folder(folder): os.makedirs(folder, 0755) return folder + def get_or_create_file(filename): filename = os.path.expanduser(filename) if not os.path.isfile(filename): @@ -23,6 +33,7 @@ def get_or_create_file(filename): open(filename, 'w') return filename + def path_to_uri(*paths): path = os.path.join(*paths) path = path.encode('utf-8') @@ -30,6 +41,7 @@ def path_to_uri(*paths): return 'file:' + urllib.pathname2url(path) return 'file://' + urllib.pathname2url(path) + def uri_to_path(uri): if sys.platform == 'win32': path = urllib.url2pathname(re.sub('^file:', '', uri)) @@ -37,6 +49,7 @@ def uri_to_path(uri): path = urllib.url2pathname(re.sub('^file://', '', uri)) return path.encode('latin1').decode('utf-8') # Undo double encoding + def split_path(path): parts = [] while True: @@ -47,6 +60,13 @@ def split_path(path): break return parts + +def expand_path(path): + path = os.path.expanduser(path) + path = os.path.abspath(path) + return string.Template(path).safe_substitute(XDG_DIRS) + + def find_files(path): if os.path.isfile(path): if not isinstance(path, unicode): @@ -73,6 +93,7 @@ def find_files(path): filename = filename.decode('latin1') yield filename + # FIXME replace with mock usage in tests. class Mtime(object): def __init__(self): diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index fae4278f..e6c35ce1 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -3,24 +3,17 @@ from __future__ import absolute_import import copy import getpass -import glib import logging import os import pprint -import string import sys from mopidy import SettingsError, SETTINGS_PATH, SETTINGS_FILE -from mopidy.utils.log import indent +from mopidy.utils import log +from mopidy.utils import path logger = logging.getLogger('mopidy.utils.settings') -XDG_DIRS = { - 'XDG_CACHE_DIR': glib.get_user_cache_dir(), - 'XDG_DATA_DIR': glib.get_user_data_dir(), - 'XDG_MUSIC_DIR': glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC), -} - class SettingsProxy(object): def __init__(self, default_settings_module): @@ -67,7 +60,7 @@ class SettingsProxy(object): if not value: return value if attr.endswith('_PATH') or attr.endswith('_FILE'): - value = self.expandpath(value) + value = path.expand_path(value) return value def __setattr__(self, attr, value): @@ -76,17 +69,12 @@ class SettingsProxy(object): else: super(SettingsProxy, self).__setattr__(attr, value) - def expandpath(self, value): - value = os.path.expanduser(value) - value = os.path.abspath(value) - return string.Template(value).safe_substitute(XDG_DIRS) - def validate(self, interactive): if interactive: self._read_missing_settings_from_stdin(self.current, self.runtime) if self.get_errors(): logger.error(u'Settings validation errors: %s', - indent(self.get_errors_as_string())) + log.indent(self.get_errors_as_string())) raise SettingsError(u'Settings validation failed.') def _read_missing_settings_from_stdin(self, current, runtime): @@ -210,11 +198,11 @@ def format_settings_list(settings): for (key, value) in sorted(settings.current.iteritems()): default_value = settings.default.get(key) masked_value = mask_value_if_secret(key, value) - lines.append(u'%s: %s' % (key, indent( + lines.append(u'%s: %s' % (key, log.indent( pprint.pformat(masked_value), places=2))) if value != default_value and default_value is not None: lines.append(u' Default: %s' % - indent(pformat(default_value), places=4)) + log.indent(pformat(default_value), places=4)) if errors.get(key) is not None: lines.append(u' Error: %s' % errors[key]) return '\n'.join(lines) From a707daf45814c36b24d959e7415f834d64861d4e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 16 Sep 2012 22:26:44 +0200 Subject: [PATCH 010/233] Add tests for expand_path and fix ordering. Expansions need to happen before abspath is called or else result is wrong. --- mopidy/utils/path.py | 3 ++- tests/utils/path_test.py | 27 ++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index ee8f3c65..7f1b9233 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -62,9 +62,10 @@ def split_path(path): def expand_path(path): + path = string.Template(path).safe_substitute(XDG_DIRS) path = os.path.expanduser(path) path = os.path.abspath(path) - return string.Template(path).safe_substitute(XDG_DIRS) + return path def find_files(path): diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index 184970ae..d6b2b5a7 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -1,12 +1,13 @@ # encoding: utf-8 +import glib import os import shutil import sys import tempfile from mopidy.utils.path import (get_or_create_folder, mtime, - path_to_uri, uri_to_path, split_path, find_files) + path_to_uri, uri_to_path, expand_path, split_path, find_files) from tests import unittest, path_to_data_dir @@ -135,6 +136,30 @@ class SplitPathTest(unittest.TestCase): self.assertEqual([], split_path('/')) +class ExpandPathTest(unittest.TestCase): + # TODO: test via mocks? + + def test_empty_path(self): + self.assertEqual(os.path.abspath('.'), expand_path('')) + + def test_absolute_path(self): + self.assertEqual('/tmp/foo', expand_path('/tmp/foo')) + + def test_home_dir_expansion(self): + self.assertEqual(os.path.expanduser('~/foo'), expand_path('~/foo')) + + def test_abspath(self): + self.assertEqual(os.path.abspath('./foo'), expand_path('./foo')) + + def test_xdg_subsititution(self): + self.assertEqual(glib.get_user_data_dir() + '/foo', + expand_path('$XDG_DATA_DIR/foo')) + + def test_xdg_subsititution_unknown(self): + self.assertEqual('/tmp/$XDG_INVALID_DIR/foo', + expand_path('/tmp/$XDG_INVALID_DIR/foo')) + + class FindFilesTest(unittest.TestCase): def find(self, path): return list(find_files(path_to_data_dir(path))) From 5a47dfe159db09061aa6b16d8fd8c9673bcd4487 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 16 Sep 2012 22:44:15 +0200 Subject: [PATCH 011/233] Update import style in tests.utils.path --- tests/utils/path_test.py | 75 ++++++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index d6b2b5a7..d782aa15 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -6,8 +6,7 @@ import shutil import sys import tempfile -from mopidy.utils.path import (get_or_create_folder, mtime, - path_to_uri, uri_to_path, expand_path, split_path, find_files) +from mopidy.utils import path from tests import unittest, path_to_data_dir @@ -24,7 +23,7 @@ class GetOrCreateFolderTest(unittest.TestCase): folder = os.path.join(self.parent, 'test') self.assert_(not os.path.exists(folder)) self.assert_(not os.path.isdir(folder)) - created = get_or_create_folder(folder) + created = path.get_or_create_folder(folder) self.assert_(os.path.exists(folder)) self.assert_(os.path.isdir(folder)) self.assertEqual(created, folder) @@ -36,7 +35,7 @@ class GetOrCreateFolderTest(unittest.TestCase): self.assert_(not os.path.isdir(level2_folder)) self.assert_(not os.path.exists(level3_folder)) self.assert_(not os.path.isdir(level3_folder)) - created = get_or_create_folder(level3_folder) + created = path.get_or_create_folder(level3_folder) self.assert_(os.path.exists(level2_folder)) self.assert_(os.path.isdir(level2_folder)) self.assert_(os.path.exists(level3_folder)) @@ -44,7 +43,7 @@ class GetOrCreateFolderTest(unittest.TestCase): self.assertEqual(created, level3_folder) def test_creating_existing_folder(self): - created = get_or_create_folder(self.parent) + created = path.get_or_create_folder(self.parent) self.assert_(os.path.exists(self.parent)) self.assert_(os.path.isdir(self.parent)) self.assertEqual(created, self.parent) @@ -53,116 +52,116 @@ class GetOrCreateFolderTest(unittest.TestCase): conflicting_file = os.path.join(self.parent, 'test') open(conflicting_file, 'w').close() folder = os.path.join(self.parent, 'test') - self.assertRaises(OSError, get_or_create_folder, folder) + self.assertRaises(OSError, path.get_or_create_folder, folder) class PathToFileURITest(unittest.TestCase): def test_simple_path(self): if sys.platform == 'win32': - result = path_to_uri(u'C:/WINDOWS/clock.avi') + result = path.path_to_uri(u'C:/WINDOWS/clock.avi') self.assertEqual(result, 'file:///C://WINDOWS/clock.avi') else: - result = path_to_uri(u'/etc/fstab') + result = path.path_to_uri(u'/etc/fstab') self.assertEqual(result, 'file:///etc/fstab') def test_folder_and_path(self): if sys.platform == 'win32': - result = path_to_uri(u'C:/WINDOWS/', u'clock.avi') + result = path.path_to_uri(u'C:/WINDOWS/', u'clock.avi') self.assertEqual(result, 'file:///C://WINDOWS/clock.avi') else: - result = path_to_uri(u'/etc', u'fstab') + result = path.path_to_uri(u'/etc', u'fstab') self.assertEqual(result, u'file:///etc/fstab') def test_space_in_path(self): if sys.platform == 'win32': - result = path_to_uri(u'C:/test this') + result = path.path_to_uri(u'C:/test this') self.assertEqual(result, 'file:///C://test%20this') else: - result = path_to_uri(u'/tmp/test this') + result = path.path_to_uri(u'/tmp/test this') self.assertEqual(result, u'file:///tmp/test%20this') def test_unicode_in_path(self): if sys.platform == 'win32': - result = path_to_uri(u'C:/æøå') + result = path.path_to_uri(u'C:/æøå') self.assertEqual(result, 'file:///C://%C3%A6%C3%B8%C3%A5') else: - result = path_to_uri(u'/tmp/æøå') + result = path.path_to_uri(u'/tmp/æøå') self.assertEqual(result, u'file:///tmp/%C3%A6%C3%B8%C3%A5') class UriToPathTest(unittest.TestCase): def test_simple_uri(self): if sys.platform == 'win32': - result = uri_to_path('file:///C://WINDOWS/clock.avi') + result = path.uri_to_path('file:///C://WINDOWS/clock.avi') self.assertEqual(result, u'C:/WINDOWS/clock.avi') else: - result = uri_to_path('file:///etc/fstab') + result = path.uri_to_path('file:///etc/fstab') self.assertEqual(result, u'/etc/fstab') def test_space_in_uri(self): if sys.platform == 'win32': - result = uri_to_path('file:///C://test%20this') + result = path.uri_to_path('file:///C://test%20this') self.assertEqual(result, u'C:/test this') else: - result = uri_to_path(u'file:///tmp/test%20this') + result = path.uri_to_path(u'file:///tmp/test%20this') self.assertEqual(result, u'/tmp/test this') def test_unicode_in_uri(self): if sys.platform == 'win32': - result = uri_to_path( 'file:///C://%C3%A6%C3%B8%C3%A5') + result = path.uri_to_path( 'file:///C://%C3%A6%C3%B8%C3%A5') self.assertEqual(result, u'C:/æøå') else: - result = uri_to_path(u'file:///tmp/%C3%A6%C3%B8%C3%A5') + result = path.uri_to_path(u'file:///tmp/%C3%A6%C3%B8%C3%A5') self.assertEqual(result, u'/tmp/æøå') class SplitPathTest(unittest.TestCase): def test_empty_path(self): - self.assertEqual([], split_path('')) + self.assertEqual([], path.split_path('')) def test_single_folder(self): - self.assertEqual(['foo'], split_path('foo')) + self.assertEqual(['foo'], path.split_path('foo')) def test_folders(self): - self.assertEqual(['foo', 'bar', 'baz'], split_path('foo/bar/baz')) + self.assertEqual(['foo', 'bar', 'baz'], path.split_path('foo/bar/baz')) def test_folders(self): - self.assertEqual(['foo', 'bar', 'baz'], split_path('foo/bar/baz')) + self.assertEqual(['foo', 'bar', 'baz'], path.split_path('foo/bar/baz')) def test_initial_slash_is_ignored(self): - self.assertEqual(['foo', 'bar', 'baz'], split_path('/foo/bar/baz')) + self.assertEqual(['foo', 'bar', 'baz'], path.split_path('/foo/bar/baz')) def test_only_slash(self): - self.assertEqual([], split_path('/')) + self.assertEqual([], path.split_path('/')) class ExpandPathTest(unittest.TestCase): # TODO: test via mocks? def test_empty_path(self): - self.assertEqual(os.path.abspath('.'), expand_path('')) + self.assertEqual(os.path.abspath('.'), path.expand_path('')) def test_absolute_path(self): - self.assertEqual('/tmp/foo', expand_path('/tmp/foo')) + self.assertEqual('/tmp/foo', path.expand_path('/tmp/foo')) def test_home_dir_expansion(self): - self.assertEqual(os.path.expanduser('~/foo'), expand_path('~/foo')) + self.assertEqual(os.path.expanduser('~/foo'), path.expand_path('~/foo')) def test_abspath(self): - self.assertEqual(os.path.abspath('./foo'), expand_path('./foo')) + self.assertEqual(os.path.abspath('./foo'), path.expand_path('./foo')) def test_xdg_subsititution(self): self.assertEqual(glib.get_user_data_dir() + '/foo', - expand_path('$XDG_DATA_DIR/foo')) + path.expand_path('$XDG_DATA_DIR/foo')) def test_xdg_subsititution_unknown(self): self.assertEqual('/tmp/$XDG_INVALID_DIR/foo', - expand_path('/tmp/$XDG_INVALID_DIR/foo')) + path.expand_path('/tmp/$XDG_INVALID_DIR/foo')) class FindFilesTest(unittest.TestCase): - def find(self, path): - return list(find_files(path_to_data_dir(path))) + def find(self, value): + return list(path.find_files(path_to_data_dir(value))) def test_basic_folder(self): self.assert_(self.find('')) @@ -190,12 +189,12 @@ class FindFilesTest(unittest.TestCase): class MtimeTest(unittest.TestCase): def tearDown(self): - mtime.undo_fake() + path.mtime.undo_fake() def test_mtime_of_current_dir(self): mtime_dir = int(os.stat('.').st_mtime) - self.assertEqual(mtime_dir, mtime('.')) + self.assertEqual(mtime_dir, path.mtime('.')) def test_fake_time_is_returned(self): - mtime.set_fake_time(123456) - self.assertEqual(mtime('.'), 123456) + path.mtime.set_fake_time(123456) + self.assertEqual(path.mtime('.'), 123456) From 049840daaf2312602870390881019fdcc87dc686 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 16 Sep 2012 22:53:58 +0200 Subject: [PATCH 012/233] Update changelog. --- docs/changes.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 43b930b8..27b8731b 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -63,6 +63,16 @@ v0.8 (in development) - Support tracks with only release year, and not a full release date, like e.g. Spotify tracks. +- Default value of ``LOCAL_MUSIC_PATH`` has been updated to be + ``$XDG_MUSIC_DIR``, which on most systems this is set to ``$HOME``. Users of + local backend that relied on the old default ``~/music`` need to update their + settings. Note that the code responsible for finding this music now also + ignores UNIX hidden files and folders. + +- File and path settings now support ``$XDG_CACHE_DIR``, ``$XDG_DATA_DIR`` and + ``$XDG_MUSIC_DIR`` substitution. Defaults for such settings have been updated + to use this instead of hidden away defaults. + **Bug fixes** - :issue:`72`: Created a Spotify track proxy that will switch to using loaded @@ -80,6 +90,12 @@ v0.8 (in development) - Fixed crash on lookup of unknown path when using local backend. +- :issue:`189` ``LOCAL_MUSIC_PATH`` and path handling in rest of settings has + been updated so all of the code now uses the correct value. + +- Fixed incorrect track URIs generated by ``parse_m3u`` code, generated tracks + are now relative to ``LOCAL_MUSIC_PATH``. + v0.7.3 (2012-08-11) =================== From 9b6c17db96879839d9ebae7b419007a2dcf304a1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 17 Sep 2012 00:03:48 +0200 Subject: [PATCH 013/233] docs: Cleanup changelog --- docs/changes.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 27b8731b..1c516a0d 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -78,23 +78,23 @@ v0.8 (in development) - :issue:`72`: Created a Spotify track proxy that will switch to using loaded data as soon as it becomes available. -- :issue:`162`: Fixed bug when the MPD command ``playlistinfo`` is used with a - track position. Track position and CPID was intermixed, so it would cause a - crash if a CPID matching the track position didn't exist. - - :issue:`150`: Fix bug which caused some clients to block Mopidy completely. The bug was caused by some clients sending ``close`` and then shutting down the connection right away. This trigged a situation in which the connection cleanup code would wait for an response that would never come inside the event loop, blocking everything else. +- :issue:`162`: Fixed bug when the MPD command ``playlistinfo`` is used with a + track position. Track position and CPID was intermixed, so it would cause a + crash if a CPID matching the track position didn't exist. + - Fixed crash on lookup of unknown path when using local backend. -- :issue:`189` ``LOCAL_MUSIC_PATH`` and path handling in rest of settings has +- :issue:`189`: ``LOCAL_MUSIC_PATH`` and path handling in rest of settings has been updated so all of the code now uses the correct value. -- Fixed incorrect track URIs generated by ``parse_m3u`` code, generated tracks - are now relative to ``LOCAL_MUSIC_PATH``. +- Fixed incorrect track URIs generated by M3U playlist parsing code. Generated + tracks are now relative to ``LOCAL_MUSIC_PATH``. v0.7.3 (2012-08-11) From 71682d3d9fff78ede42dc5bff1107a2d0d4d4955 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 18 Sep 2012 22:50:57 +0200 Subject: [PATCH 014/233] Switched over to playbin2 for audio playback. Covers first half of #171 which is simply an port of the functionality we used to have. Second half is actually taking advantage of playbin2 with respect to EOT handling etc. --- mopidy/audio/__init__.py | 86 ++++++++++++++-------------------------- 1 file changed, 29 insertions(+), 57 deletions(-) diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 448412b4..498fbdc9 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -42,17 +42,14 @@ class Audio(ThreadingActor): signed=(boolean)true, rate=(int)44100""") - self._pipeline = None - self._source = None - self._uridecodebin = None - self._output = None + self._playbin = None self._mixer = None self._message_processor_set_up = False def on_start(self): try: - self._setup_pipeline() + self._setup_playbin() self._setup_output() self._setup_mixer() self._setup_message_processor() @@ -63,36 +60,22 @@ class Audio(ThreadingActor): def on_stop(self): self._teardown_message_processor() self._teardown_mixer() - self._teardown_pipeline() + self._teardown_playbin() - def _setup_pipeline(self): - # TODO: replace with and input bin so we simply have an input bin we - # connect to an output bin with a mixer on the side. set_uri on bin? - description = ' ! '.join([ - 'uridecodebin name=uri', - 'audioconvert name=convert', - 'audioresample name=resample', - 'queue name=queue']) + def _setup_playbin(self): + self._playbin = gst.element_factory_make('playbin2') - logger.debug(u'Setting up base GStreamer pipeline: %s', description) + fakesink = gst.element_factory_make('fakesink') + self._playbin.set_property('video-sink', fakesink) - self._pipeline = gst.parse_launch(description) - self._uridecodebin = self._pipeline.get_by_name('uri') - - self._uridecodebin.connect('notify::source', self._on_new_source) - self._uridecodebin.connect('pad-added', self._on_new_pad, - self._pipeline.get_by_name('queue').get_pad('sink')) - - def _teardown_pipeline(self): - self._pipeline.set_state(gst.STATE_NULL) + def _teardown_playbin(self): + self._playbin.set_state(gst.STATE_NULL) def _setup_output(self): try: - self._output = gst.parse_bin_from_description( + output = gst.parse_bin_from_description( settings.OUTPUT, ghost_unconnected_pads=True) - self._pipeline.add(self._output) - gst.element_link_many(self._pipeline.get_by_name('queue'), - self._output) + self._playbin.set_property('audio-sink', output) logger.info('Output set to %s', settings.OUTPUT) except gobject.GError as ex: logger.error('Failed to create output "%s": %s', @@ -148,29 +131,16 @@ class Audio(ThreadingActor): mixer.set_state(gst.STATE_NULL) def _setup_message_processor(self): - bus = self._pipeline.get_bus() + bus = self._playbin.get_bus() bus.add_signal_watch() bus.connect('message', self._on_message) self._message_processor_set_up = True def _teardown_message_processor(self): if self._message_processor_set_up: - bus = self._pipeline.get_bus() + bus = self._playbin.get_bus() bus.remove_signal_watch() - def _on_new_source(self, element, pad): - self._source = element.get_property('source') - try: - self._source.set_property('caps', self._default_caps) - except TypeError: - pass - - def _on_new_pad(self, source, pad, target_pad): - if not pad.is_linked(): - if target_pad.is_linked(): - target_pad.get_peer().unlink(target_pad) - pad.link(target_pad) - def _on_message(self, bus, message): if message.type == gst.MESSAGE_EOS: self._notify_backend_of_eos() @@ -200,7 +170,7 @@ class Audio(ThreadingActor): :param uri: the URI to play :type uri: string """ - self._uridecodebin.set_property('uri', uri) + self._playbin.set_property('uri', uri) def emit_data(self, capabilities, data): """ @@ -215,18 +185,20 @@ class Audio(ThreadingActor): caps = gst.caps_from_string(capabilities) buffer_ = gst.Buffer(buffer(data)) buffer_.set_caps(caps) - self._source.set_property('caps', caps) - self._source.emit('push-buffer', buffer_) + + source = self._playbin.get_property('source') + source.set_property('caps', caps) + source.emit('push-buffer', buffer_) def emit_end_of_stream(self): """ - Put an end-of-stream token on the pipeline. This is typically used in + Put an end-of-stream token on the playbin. This is typically used in combination with :meth:`emit_data`. We will get a GStreamer message when the stream playback reaches the token, and can then do any end-of-stream related tasks. """ - self._source.emit('end-of-stream') + self._playbin.get_property('source').emit('end-of-stream') def get_position(self): """ @@ -234,10 +206,10 @@ class Audio(ThreadingActor): :rtype: int """ - if self._pipeline.get_state()[1] == gst.STATE_NULL: + if self._playbin.get_state()[1] == gst.STATE_NULL: return 0 try: - position = self._pipeline.query_position(gst.FORMAT_TIME)[0] + position = self._playbin.query_position(gst.FORMAT_TIME)[0] return position // gst.MSECOND except gst.QueryError, e: logger.error('time_position failed: %s', e) @@ -251,10 +223,10 @@ class Audio(ThreadingActor): :type volume: int :rtype: :class:`True` if successful, else :class:`False` """ - self._pipeline.get_state() # block until state changes are done - handeled = self._pipeline.seek_simple(gst.Format(gst.FORMAT_TIME), + self._playbin.get_state() # block until state changes are done + handeled = self._playbin.seek_simple(gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, position * gst.MSECOND) - self._pipeline.get_state() # block until seek is done + self._playbin.get_state() # block until seek is done return handeled def start_playback(self): @@ -308,12 +280,12 @@ class Audio(ThreadingActor): "READY" -> "NULL" "READY" -> "PAUSED" - :param state: State to set pipeline to. One of: `gst.STATE_NULL`, + :param state: State to set playbin to. One of: `gst.STATE_NULL`, `gst.STATE_READY`, `gst.STATE_PAUSED` and `gst.STATE_PLAYING`. :type state: :class:`gst.State` :rtype: :class:`True` if successfull, else :class:`False` """ - result = self._pipeline.set_state(state) + result = self._playbin.set_state(state) if result == gst.STATE_CHANGE_FAILURE: logger.warning('Setting GStreamer state to %s: failed', state.value_name) @@ -382,7 +354,7 @@ class Audio(ThreadingActor): Set track metadata for currently playing song. Only needs to be called by sources such as `appsrc` which do not - already inject tags in pipeline, e.g. when using :meth:`emit_data` to + already inject tags in playbin, e.g. when using :meth:`emit_data` to deliver raw audio data to GStreamer. :param track: the current track @@ -407,4 +379,4 @@ class Audio(ThreadingActor): taglist[gst.TAG_ALBUM] = track.album.name event = gst.event_new_tag(taglist) - self._pipeline.send_event(event) + self._playbin.send_event(event) From 413c22e117d588ed649cf5488515e312310d1bc2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 18 Sep 2012 23:41:39 +0200 Subject: [PATCH 015/233] Move mixer track out to it's own attribute. --- mopidy/audio/__init__.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 498fbdc9..8a40692b 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -44,6 +44,7 @@ class Audio(ThreadingActor): self._playbin = None self._mixer = None + self._mixer_track = None self._message_processor_set_up = False @@ -110,7 +111,8 @@ class Audio(ThreadingActor): logger.warning('Could not find usable mixer track.') return - self._mixer = (mixer, track) + self._mixer = mixer + self._mixer_track = track logger.info('Mixer set to %s using track called %s', mixer.get_factory().get_name(), track.label) @@ -127,8 +129,7 @@ class Audio(ThreadingActor): def _teardown_mixer(self): if self._mixer is not None: - (mixer, track) = self._mixer - mixer.set_state(gst.STATE_NULL) + self._mixer.set_state(gst.STATE_NULL) def _setup_message_processor(self): bus = self._playbin.get_bus() @@ -317,13 +318,11 @@ class Audio(ThreadingActor): if self._mixer is None: return None - mixer, track = self._mixer - - volumes = mixer.get_volume(track) + volumes = self._mixer.get_volume(self._mixer_track) avg_volume = float(sum(volumes)) / len(volumes) new_scale = (0, 100) - old_scale = (track.min_volume, track.max_volume) + old_scale = (self._mixer_track.min_volume, self._mixer_track.max_volume) return utils.rescale(avg_volume, old=old_scale, new=new_scale) def set_volume(self, volume): @@ -337,17 +336,15 @@ class Audio(ThreadingActor): if self._mixer is None: return False - mixer, track = self._mixer - old_scale = (0, 100) - new_scale = (track.min_volume, track.max_volume) + new_scale = (self._mixer_track.min_volume, self._mixer_track.max_volume) volume = utils.rescale(volume, old=old_scale, new=new_scale) - volumes = (volume,) * track.num_channels - mixer.set_volume(track, volumes) + volumes = (volume,) * self._mixer_track.num_channels + self._mixer.set_volume(self._mixer_track, volumes) - return mixer.get_volume(track) == volumes + return self._mixer.get_volume(self._mixer_track) == volumes def set_metadata(self, track): """ From 9866d78c6598561cc7e71957ab781fde435940c6 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 18 Sep 2012 23:49:29 +0200 Subject: [PATCH 016/233] Re-add software mixing, fixes #203. --- mopidy/audio/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 8a40692b..df5efb92 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -45,6 +45,7 @@ class Audio(ThreadingActor): self._playbin = None self._mixer = None self._mixer_track = None + self._software_mixing = False self._message_processor_set_up = False @@ -88,6 +89,11 @@ class Audio(ThreadingActor): logger.info('Not setting up mixer.') return + if settings.MIXER == 'software': + self._software_mixing = True + logger.info('Mixer set to software mixing.') + return + try: mixerbin = gst.parse_bin_from_description(settings.MIXER, ghost_unconnected_pads=False) @@ -315,6 +321,9 @@ class Audio(ThreadingActor): :rtype: int in range [0..100] or :class:`None` """ + if self._software_mixing: + return round(self._playbin.get_property('volume') * 100) + if self._mixer is None: return None @@ -333,6 +342,10 @@ class Audio(ThreadingActor): :type volume: int :rtype: :class:`True` if successful, else :class:`False` """ + if self._software_mixing: + self._playbin.set_property('volume', volume / 100.0) + return True + if self._mixer is None: return False From 8f045b6d6bfa8b53ffa60e4090718cbee3ee6ee5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 18 Sep 2012 23:56:58 +0200 Subject: [PATCH 017/233] Update documentaion and changelog with respect to software mixing and playbin2 switch. --- docs/changes.rst | 7 ++++++- mopidy/settings.py | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 1c516a0d..5df46066 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -23,7 +23,7 @@ v0.8 (in development) mixer that will work on your system. If this picks the wrong mixer you can of course override it. Setting the mixer to :class:`None` is also supported. MPD protocol support for volume has also been updated to return -1 when we have - no mixer set. + no mixer set. ``software`` can be used to force software mixing. - Removed the Denon hardware mixer, as it is not maintained. @@ -73,6 +73,9 @@ v0.8 (in development) ``$XDG_MUSIC_DIR`` substitution. Defaults for such settings have been updated to use this instead of hidden away defaults. +- Playback is no done using ``playbin2`` from GStreamer instead of rolling our + own. This is the first step towards resolving :issue:`171`. + **Bug fixes** - :issue:`72`: Created a Spotify track proxy that will switch to using loaded @@ -96,6 +99,8 @@ v0.8 (in development) - Fixed incorrect track URIs generated by M3U playlist parsing code. Generated tracks are now relative to ``LOCAL_MUSIC_PATH``. +- :issue:`203`: Re-add support for software mixing. + v0.7.3 (2012-08-11) =================== diff --git a/mopidy/settings.py b/mopidy/settings.py index a2270707..98f7e05e 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -111,7 +111,8 @@ LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache' #: Expects a GStreamer mixer to use, typical values are: #: ``alsamixer``, ``pulsemixer``, ``ossmixer``, and ``oss4mixer``. #: -#: Setting this to :class:`None` turns off volume control. +#: Setting this to :class:`None` turns off volume control. ``software`` +#: can be used to force software mixing in the application. #: #: Default:: #: From 8ff98195c768acb61c2c725f33f7c405f524991f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 19 Sep 2012 00:09:23 +0200 Subject: [PATCH 018/233] Document settings profiles hack. --- docs/development.rst | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index c5020bd9..c60580e1 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -196,8 +196,9 @@ of writing. See ``--help`` for available options. Sample session:: +ACK [2@0] {listallinfo} incorrect arguments To ensure that Mopidy and MPD have comparable state it is suggested you setup -both to use ``tests/data/library_tag_cache`` for their tag cache and -``tests/data`` for music/playlist folders. +both to use ``tests/data/advanced_tag_cache`` for their tag cache and +``tests/data/scanner/advanced/`` for the music folder and ``tests/data`` for +playlists. Writing documentation @@ -246,3 +247,32 @@ Creating releases python setup.py sdist upload #. Spread the word. + + +Setting profiles during development +=================================== + +While developing Mopidy switching settings back and forth can become an all too +frequent occurrence. As a quick hack to get around this you can structure your +settings file in the following way:: + + import os + profile = os.environ.get('PROFILE', '').split(',') + + if 'spotify' in profile: + BACKENDS = (u'mopidy.backends.spotify.SpotifyBackend',) + elif 'local' in profile: + BACKENDS = (u'mopidy.backends.local.LocalBackend',) + LOCAL_MUSIC_PATH = u'~/music' + + if 'shoutcast' in profile: + OUTPUT = u'lame ! shout2send mount="/stream"' + elif 'silent' in profile: + OUTPUT = 'fakesink' + MIXER = None + + SPOTIFY_USERNAME = 'xxxxx' + SPOTIFY_PASSWORD = 'xxxxx' + +Using this setup you can now run Mopidy with ``PROFILE=silent,spotify mopidy`` +if you for instance want to test Spotify without any actual audio output. From 9339833266268f3eafb8082242b85a602ea5e5b4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Sep 2012 00:47:32 +0200 Subject: [PATCH 019/233] docs: Fix typo --- docs/changes.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index 5df46066..5fde020c 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -73,7 +73,7 @@ v0.8 (in development) ``$XDG_MUSIC_DIR`` substitution. Defaults for such settings have been updated to use this instead of hidden away defaults. -- Playback is no done using ``playbin2`` from GStreamer instead of rolling our +- Playback is now done using ``playbin2`` from GStreamer instead of rolling our own. This is the first step towards resolving :issue:`171`. **Bug fixes** From d1d5a084a273686153f56cfe6917b46913002c0d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Sep 2012 01:05:02 +0200 Subject: [PATCH 020/233] Log Spotify playlist loading completion on INFO level --- mopidy/backends/spotify/session_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 856257f1..ce1226d8 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -153,7 +153,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): self.session.playlist_container()) playlists = filter(None, playlists) self.backend.stored_playlists.playlists = playlists - logger.debug(u'Refreshed %d stored playlist(s)', len(playlists)) + logger.info(u'Loaded %d Spotify playlist(s)', len(playlists)) def search(self, query, queue): """Search method used by Mopidy backend""" From 52c7726de2d1b47091f8a47337f26c9785bcfe03 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Sep 2012 01:50:48 +0200 Subject: [PATCH 021/233] MPD command 'close' does not return 'OK' Test broke when Pykka actors started processing the actor inbox before stopping themselves. --- tests/frontends/mpd/protocol/authentication_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/frontends/mpd/protocol/authentication_test.py b/tests/frontends/mpd/protocol/authentication_test.py index 20422f5b..0f0d9c86 100644 --- a/tests/frontends/mpd/protocol/authentication_test.py +++ b/tests/frontends/mpd/protocol/authentication_test.py @@ -38,7 +38,6 @@ class AuthenticationTest(protocol.BaseTestCase): self.sendRequest(u'close') self.assertFalse(self.dispatcher.authenticated) - self.assertInResponse(u'OK') def test_commands_is_allowed_without_authentication(self): settings.MPD_SERVER_PASSWORD = u'topsecret' From 36698d1ae6e38482af65bd2a0ac9bdc97f2bd04a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Sep 2012 02:03:47 +0200 Subject: [PATCH 022/233] Hack to fix random test failure With Pykka 0.16, test_status_method_when_playing_contains_time_with_length fails now and then because play_time_started is not initialized before it is used as an int. I'm allowing myself to fix this in the simplest way possible instead of tracking the issue down, since I'm already working on a refactor of the time position code. --- mopidy/core/playback.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index dfd1676e..31a1acc5 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -88,7 +88,7 @@ class PlaybackController(object): self._shuffled = [] self._first_shuffle = True self.play_time_accumulated = 0 - self.play_time_started = None + self.play_time_started = 0 def _get_cpid(self, cp_track): if cp_track is None: From 772185ddc9c0bae6b94cf123719193f5e895eef7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Sep 2012 02:40:51 +0200 Subject: [PATCH 023/233] Make first log line prettier --- mopidy/utils/log.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 0e5dfc29..0e353117 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -9,8 +9,9 @@ def setup_logging(verbosity_level, save_debug_log): if save_debug_log: setup_debug_logging_to_file() logger = logging.getLogger('mopidy.utils.log') - logger.info(u'Starting Mopidy %s on %s %s', - get_version(), get_platform(), get_python()) + logger.info(u'Starting Mopidy %s', get_version()) + logger.info(u'OS: %s', get_platform()) + logger.info(u'Python: %s', get_python()) def setup_root_logger(): root = logging.getLogger('') From 0c9966197b12d76a65740c7c77715832ceaed38d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Sep 2012 13:34:18 +0200 Subject: [PATCH 024/233] Make log output consistent with --list-deps output --- mopidy/utils/log.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 0e353117..191efa2f 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -10,7 +10,7 @@ def setup_logging(verbosity_level, save_debug_log): setup_debug_logging_to_file() logger = logging.getLogger('mopidy.utils.log') logger.info(u'Starting Mopidy %s', get_version()) - logger.info(u'OS: %s', get_platform()) + logger.info(u'Platform: %s', get_platform()) logger.info(u'Python: %s', get_python()) def setup_root_logger(): From 402e3043f6857ac3a9cfa71865dbd6be719b0d9b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Sep 2012 23:51:31 +0200 Subject: [PATCH 025/233] Steps before log setup should be outside try-except If the steps before the log setup are inside the try-except and they fail, the error will not be visible since the log system hasn't been set up yet. It is better to not catch the exception so that the error will be visible. --- mopidy/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 416429bc..35518874 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -46,8 +46,8 @@ logger = logging.getLogger('mopidy.main') def main(): signal.signal(signal.SIGTERM, exit_handler) loop = gobject.MainLoop() + options = parse_options() try: - options = parse_options() setup_logging(options.verbosity_level, options.save_debug_log) check_old_folders() setup_settings(options.interactive) From 49201def742c828480818a71e103fb9dc8fb31c6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Sep 2012 23:54:41 +0200 Subject: [PATCH 026/233] Fix NameError caused by change to module imports --- mopidy/utils/settings.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index e6c35ce1..5468b9bf 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -198,11 +198,11 @@ def format_settings_list(settings): for (key, value) in sorted(settings.current.iteritems()): default_value = settings.default.get(key) masked_value = mask_value_if_secret(key, value) - lines.append(u'%s: %s' % (key, log.indent( - pprint.pformat(masked_value), places=2))) + lines.append(u'%s: %s' % ( + key, log.indent(pprint.pformat(masked_value), places=2))) if value != default_value and default_value is not None: lines.append(u' Default: %s' % - log.indent(pformat(default_value), places=4)) + log.indent(pprint.pformat(default_value), places=4)) if errors.get(key) is not None: lines.append(u' Error: %s' % errors[key]) return '\n'.join(lines) From 0800e86a0587d3a32f68d1d0154cf10c3e9d7ec6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Sep 2012 23:58:38 +0200 Subject: [PATCH 027/233] docs: Use unicode literals in settings examples --- docs/development.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index c60580e1..49d8add5 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -264,15 +264,15 @@ settings file in the following way:: elif 'local' in profile: BACKENDS = (u'mopidy.backends.local.LocalBackend',) LOCAL_MUSIC_PATH = u'~/music' - + if 'shoutcast' in profile: OUTPUT = u'lame ! shout2send mount="/stream"' elif 'silent' in profile: - OUTPUT = 'fakesink' + OUTPUT = u'fakesink' MIXER = None - SPOTIFY_USERNAME = 'xxxxx' - SPOTIFY_PASSWORD = 'xxxxx' + SPOTIFY_USERNAME = u'xxxxx' + SPOTIFY_PASSWORD = u'xxxxx' Using this setup you can now run Mopidy with ``PROFILE=silent,spotify mopidy`` if you for instance want to test Spotify without any actual audio output. From 6451519d2ae332c8e06a5929fb189d74ec9e7d19 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Sep 2012 00:28:30 +0200 Subject: [PATCH 028/233] MPD: Support 'playid 0' without quotes around id --- mopidy/frontends/mpd/protocol/playback.py | 4 ++-- tests/frontends/mpd/protocol/playback_test.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index b0c299c8..356196e6 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -121,8 +121,8 @@ def play(context): """ return context.backend.playback.play().get() -@handle_request(r'^playid "(?P\d+)"$') -@handle_request(r'^playid "(?P-1)"$') +@handle_request(r'^playid (?P-?\d+)$') +@handle_request(r'^playid "(?P-?\d+)"$') def playid(context, cpid): """ *musicpd.org, playback section:* diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 88452d3d..4f8f7430 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -287,6 +287,13 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertEqual(PLAYING, self.backend.playback.state.get()) self.assertInResponse(u'OK') + def test_playid_without_quotes(self): + self.backend.current_playlist.append([Track()]) + + self.sendRequest(u'playid 0') + self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertInResponse(u'OK') + def test_playid_minus_one_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.backend.playback.current_track.get(), None) self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) From 7fda9dc1981c4843ec755851c48bd0a023007316 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Sep 2012 00:28:46 +0200 Subject: [PATCH 029/233] MPD: Fix copy-paste error in docs --- mopidy/frontends/mpd/protocol/playback.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 356196e6..4152f11e 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -161,11 +161,11 @@ def playpos(context, songpos): *Clarifications:* - - ``playid "-1"`` when playing is ignored. - - ``playid "-1"`` when paused resumes playback. - - ``playid "-1"`` when stopped with a current track starts playback at the + - ``play "-1"`` when playing is ignored. + - ``play "-1"`` when paused resumes playback. + - ``play "-1"`` when stopped with a current track starts playback at the current track. - - ``playid "-1"`` when stopped without a current track, e.g. after playlist + - ``play "-1"`` when stopped without a current track, e.g. after playlist replacement, starts playback at the first track. *BitMPC:* From 7d4b605ee5780fca4be789a2922d64a2fd1bccfe Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Sep 2012 01:01:38 +0200 Subject: [PATCH 030/233] Update version number to 0.8.0 --- mopidy/__init__.py | 2 +- tests/version_test.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 11293446..26e5b904 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -8,7 +8,7 @@ from subprocess import PIPE, Popen import glib -__version__ = '0.7.3' +__version__ = '0.8.0' DATA_PATH = os.path.join(str(glib.get_user_data_dir()), 'mopidy') CACHE_PATH = os.path.join(str(glib.get_user_cache_dir()), 'mopidy') diff --git a/tests/version_test.py b/tests/version_test.py index 85b182f0..c3eb00c1 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -27,8 +27,9 @@ class VersionTest(unittest.TestCase): self.assert_(SV('0.6.1') < SV('0.7.0')) self.assert_(SV('0.7.0') < SV('0.7.1')) self.assert_(SV('0.7.1') < SV('0.7.2')) - self.assert_(SV('0.7.2') < SV(__version__)) - self.assert_(SV(__version__) < SV('0.8.0')) + self.assert_(SV('0.7.2') < SV('0.7.3')) + self.assert_(SV('0.7.3') < SV(__version__)) + self.assert_(SV(__version__) < SV('0.8.1')) def test_get_platform_contains_platform(self): self.assertIn(platform.platform(), get_platform()) From 5d3a2fcba5dc79046d5fa2efd5c19b3c3f35173f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Sep 2012 01:04:41 +0200 Subject: [PATCH 031/233] Update changelog for v0.8.0 --- docs/changes.rst | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 5fde020c..bd90111e 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -4,8 +4,13 @@ Changes This change log is used to track all major changes to Mopidy. -v0.8 (in development) -===================== + +v0.8.0 (2012-09-20) +=================== + +This release does not include any major new features. We've done a major +cleanup of how audio outputs and audio mixers work, and on the way we've +resolved a bunch of related issues. **Audio output and mixer changes** From 84f55d6853ba9260b9e57ff003aa335ce3feed5c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Sep 2012 01:07:38 +0200 Subject: [PATCH 032/233] Start changelog for v0.9 --- docs/changes.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index bd90111e..caec53ba 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,6 +5,12 @@ Changes This change log is used to track all major changes to Mopidy. +v0.9.0 (in development) +======================= + +- Nothing so far. + + v0.8.0 (2012-09-20) =================== From 1ed78c5ceb4ccdb08dd86eb469f69a163a8b8b2e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Sep 2012 01:22:28 +0200 Subject: [PATCH 033/233] docs: Avoid frequent repetition of 'most' word --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 0b0f6965..72a60a45 100644 --- a/README.rst +++ b/README.rst @@ -6,8 +6,8 @@ Mopidy Mopidy is a music server which can play music from `Spotify `_ or from your local hard drive. To search for music -in Spotify's vast archive, manage playlists, and play music, you can use most -`MPD clients `_. MPD clients are available for most +in Spotify's vast archive, manage playlists, and play music, you can use any +`MPD client `_. MPD clients are available for most platforms, including Windows, Mac OS X, Linux, Android and iOS. To install Mopidy, check out From 5a6fe0eb0af847845c1672dbf40c60e22bc9973e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Sep 2012 01:22:38 +0200 Subject: [PATCH 034/233] docs: Add link to CI server --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index 72a60a45..e7ecd614 100644 --- a/README.rst +++ b/README.rst @@ -16,5 +16,6 @@ To install Mopidy, check out - `Documentation `_ - `Source code `_ - `Issue tracker `_ +- `CI server `_ - IRC: ``#mopidy`` at `irc.freenode.net `_ - `Download development snapshot `_ From b3f3cfe2a08f4d3852de24f2c464c4fb2a00117f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 25 Sep 2012 11:11:15 +0200 Subject: [PATCH 035/233] Use assert{Less,Greater}[EEqual] in tests --- tests/backends/base/current_playlist.py | 4 +- tests/backends/base/playback.py | 10 +-- .../mpd/protocol/current_playlist_test.py | 6 +- tests/frontends/mpd/protocol/playback_test.py | 32 +++++--- tests/frontends/mpd/status_test.py | 24 +++--- .../frontends/mpris/player_interface_test.py | 78 +++++++++---------- tests/version_test.py | 38 ++++----- 7 files changed, 101 insertions(+), 91 deletions(-) diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index 430e4c40..a42e7eac 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -205,7 +205,7 @@ class CurrentPlaylistControllerTest(object): track2 = self.controller.tracks[2] version = self.controller.version self.controller.remove(uri=track1.uri) - self.assert_(version < self.controller.version) + self.assertLess(version, self.controller.version) self.assertNotIn(track1, self.controller.tracks) self.assertEqual(track2, self.controller.tracks[1]) @@ -281,4 +281,4 @@ class CurrentPlaylistControllerTest(object): def test_version_increases_when_appending_something(self): version = self.controller.version self.controller.append([Track()]) - self.assert_(version < self.controller.version) + self.assertLess(version, self.controller.version) diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 1e434e35..e052a907 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -618,7 +618,7 @@ class PlaybackControllerTest(object): def test_seek_when_stopped_updates_position(self): self.playback.seek(1000) position = self.playback.time_position - self.assert_(position >= 990, position) + self.assertGreaterEqual(position, 990) def test_seek_on_empty_playlist(self): self.assertFalse(self.playback.seek(0)) @@ -644,7 +644,7 @@ class PlaybackControllerTest(object): self.playback.play() self.playback.seek(length - 1000) position = self.playback.time_position - self.assert_(position >= length - 1010, position) + self.assertGreaterEqual(position, length - 1010) @populate_playlist def test_seek_when_paused(self): @@ -660,7 +660,7 @@ class PlaybackControllerTest(object): self.playback.pause() self.playback.seek(length - 1000) position = self.playback.time_position - self.assert_(position >= length - 1010, position) + self.assertGreaterEqual(position, length - 1010) @populate_playlist def test_seek_when_paused_triggers_play(self): @@ -702,7 +702,7 @@ class PlaybackControllerTest(object): self.playback.play() self.playback.seek(-1000) position = self.playback.time_position - self.assert_(position >= 0, position) + self.assertGreaterEqual(position, 0) self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist @@ -749,7 +749,7 @@ class PlaybackControllerTest(object): first = self.playback.time_position time.sleep(1) second = self.playback.time_position - self.assert_(second > first, '%s - %s' % (first, second)) + self.assertGreater(second, first) @unittest.SkipTest # Uses sleep @populate_playlist diff --git a/tests/frontends/mpd/protocol/current_playlist_test.py b/tests/frontends/mpd/protocol/current_playlist_test.py index 21889e82..4aed5de1 100644 --- a/tests/frontends/mpd/protocol/current_playlist_test.py +++ b/tests/frontends/mpd/protocol/current_playlist_test.py @@ -415,7 +415,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): version = self.backend.current_playlist.version.get() self.sendRequest(u'shuffle') - self.assert_(version < self.backend.current_playlist.version.get()) + self.assertLess(version, self.backend.current_playlist.version.get()) self.assertInResponse(u'OK') def test_shuffle_with_open_range(self): @@ -426,7 +426,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): version = self.backend.current_playlist.version.get() self.sendRequest(u'shuffle "4:"') - self.assert_(version < self.backend.current_playlist.version.get()) + self.assertLess(version, self.backend.current_playlist.version.get()) tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'b') @@ -442,7 +442,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): version = self.backend.current_playlist.version.get() self.sendRequest(u'shuffle "1:3"') - self.assert_(version < self.backend.current_playlist.version.get()) + self.assertLess(version, self.backend.current_playlist.version.get()) tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[3].name, 'd') diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 4f8f7430..112a13ae 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -259,25 +259,29 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_play_minus_is_ignored_if_playing(self): self.backend.current_playlist.append([Track(length=40000)]) self.backend.playback.seek(30000) - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position.get(), + 30000) self.assertEquals(PLAYING, self.backend.playback.state.get()) self.sendRequest(u'play "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position.get(), + 30000) self.assertInResponse(u'OK') def test_play_minus_one_resumes_if_paused(self): self.backend.current_playlist.append([Track(length=40000)]) self.backend.playback.seek(30000) - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position.get(), + 30000) self.assertEquals(PLAYING, self.backend.playback.state.get()) self.backend.playback.pause() self.assertEquals(PAUSED, self.backend.playback.state.get()) self.sendRequest(u'play "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position.get(), + 30000) self.assertInResponse(u'OK') def test_playid(self): @@ -327,25 +331,29 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_playid_minus_is_ignored_if_playing(self): self.backend.current_playlist.append([Track(length=40000)]) self.backend.playback.seek(30000) - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position.get(), + 30000) self.assertEquals(PLAYING, self.backend.playback.state.get()) self.sendRequest(u'playid "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position.get(), + 30000) self.assertInResponse(u'OK') def test_playid_minus_one_resumes_if_paused(self): self.backend.current_playlist.append([Track(length=40000)]) self.backend.playback.seek(30000) - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position.get(), + 30000) self.assertEquals(PLAYING, self.backend.playback.state.get()) self.backend.playback.pause() self.assertEquals(PAUSED, self.backend.playback.state.get()) self.sendRequest(u'playid "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position.get(), + 30000) self.assertInResponse(u'OK') def test_playid_which_does_not_exist(self): @@ -363,7 +371,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.sendRequest(u'seek "0"') self.sendRequest(u'seek "0" "30"') - self.assert_(self.backend.playback.time_position >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position, 30000) self.assertInResponse(u'OK') def test_seek_with_songpos(self): @@ -380,13 +388,15 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.sendRequest(u'seek 0') self.sendRequest(u'seek 0 30') - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position.get(), + 30000) self.assertInResponse(u'OK') def test_seekid(self): self.backend.current_playlist.append([Track(length=40000)]) self.sendRequest(u'seekid "0" "30"') - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position.get(), + 30000) self.assertInResponse(u'OK') def test_seekid_with_cpid(self): diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 2bc3488b..59418a3b 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -27,19 +27,19 @@ class StatusHandlerTest(unittest.TestCase): def test_stats_method(self): result = status.stats(self.context) self.assertIn('artists', result) - self.assert_(int(result['artists']) >= 0) + self.assertGreaterEqual(int(result['artists']), 0) self.assertIn('albums', result) - self.assert_(int(result['albums']) >= 0) + self.assertGreaterEqual(int(result['albums']), 0) self.assertIn('songs', result) - self.assert_(int(result['songs']) >= 0) + self.assertGreaterEqual(int(result['songs']), 0) self.assertIn('uptime', result) - self.assert_(int(result['uptime']) >= 0) + self.assertGreaterEqual(int(result['uptime']), 0) self.assertIn('db_playtime', result) - self.assert_(int(result['db_playtime']) >= 0) + self.assertGreaterEqual(int(result['db_playtime']), 0) self.assertIn('db_update', result) - self.assert_(int(result['db_update']) >= 0) + self.assertGreaterEqual(int(result['db_update']), 0) self.assertIn('playtime', result) - self.assert_(int(result['playtime']) >= 0) + self.assertGreaterEqual(int(result['playtime']), 0) def test_status_method_contains_volume_with_na_value(self): result = dict(status.status(self.context)) @@ -98,12 +98,12 @@ class StatusHandlerTest(unittest.TestCase): def test_status_method_contains_playlistlength(self): result = dict(status.status(self.context)) self.assertIn('playlistlength', result) - self.assert_(int(result['playlistlength']) >= 0) + self.assertGreaterEqual(int(result['playlistlength']), 0) def test_status_method_contains_xfade(self): result = dict(status.status(self.context)) self.assertIn('xfade', result) - self.assert_(int(result['xfade']) >= 0) + self.assertGreaterEqual(int(result['xfade']), 0) def test_status_method_contains_state_is_play(self): self.backend.playback.state = PLAYING @@ -129,7 +129,7 @@ class StatusHandlerTest(unittest.TestCase): self.backend.playback.play() result = dict(status.status(self.context)) self.assertIn('song', result) - self.assert_(int(result['song']) >= 0) + self.assertGreaterEqual(int(result['song']), 0) def test_status_method_when_playlist_loaded_contains_cpid_as_songid(self): self.backend.current_playlist.append([Track()]) @@ -146,7 +146,7 @@ class StatusHandlerTest(unittest.TestCase): (position, total) = result['time'].split(':') position = int(position) total = int(total) - self.assert_(position <= total) + self.assertLessEqual(position, total) def test_status_method_when_playing_contains_time_with_length(self): self.backend.current_playlist.append([Track(length=10000)]) @@ -156,7 +156,7 @@ class StatusHandlerTest(unittest.TestCase): (position, total) = result['time'].split(':') position = int(position) total = int(total) - self.assert_(position <= total) + self.assertLessEqual(position, total) def test_status_method_when_playing_contains_elapsed(self): self.backend.playback.state = PAUSED diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index db7f9265..89f7f1d4 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -89,12 +89,12 @@ class PlayerInterfaceTest(unittest.TestCase): def test_get_rate_is_greater_or_equal_than_minimum_rate(self): rate = self.mpris.Get(objects.PLAYER_IFACE, 'Rate') minimum_rate = self.mpris.Get(objects.PLAYER_IFACE, 'MinimumRate') - self.assert_(rate >= minimum_rate) + self.assertGreaterEqual(rate, minimum_rate) def test_get_rate_is_less_or_equal_than_maximum_rate(self): rate = self.mpris.Get(objects.PLAYER_IFACE, 'Rate') maximum_rate = self.mpris.Get(objects.PLAYER_IFACE, 'MaximumRate') - self.assert_(rate >= maximum_rate) + self.assertGreaterEqual(rate, maximum_rate) def test_set_rate_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False @@ -246,7 +246,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.playback.seek(10000) result_in_microseconds = self.mpris.Get(objects.PLAYER_IFACE, 'Position') result_in_milliseconds = result_in_microseconds // 1000 - self.assert_(result_in_milliseconds >= 10000) + self.assertGreaterEqual(result_in_milliseconds, 10000) def test_get_position_when_no_current_track_should_be_zero(self): result_in_microseconds = self.mpris.Get(objects.PLAYER_IFACE, 'Position') @@ -255,11 +255,11 @@ class PlayerInterfaceTest(unittest.TestCase): def test_get_minimum_rate_is_one_or_less(self): result = self.mpris.Get(objects.PLAYER_IFACE, 'MinimumRate') - self.assert_(result <= 1.0) + self.assertLessEqual(result, 1.0) def test_get_maximum_rate_is_one_or_more(self): result = self.mpris.Get(objects.PLAYER_IFACE, 'MaximumRate') - self.assert_(result >= 1.0) + self.assertGreaterEqual(result, 1.0) def test_can_go_next_is_true_if_can_control_and_other_next_track(self): self.mpris.get_CanControl = lambda *_: True @@ -490,13 +490,13 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEquals(self.backend.playback.state.get(), PAUSED) at_pause = self.backend.playback.time_position.get() - self.assert_(at_pause >= 0) + self.assertGreaterEqual(at_pause, 0) self.mpris.PlayPause() self.assertEquals(self.backend.playback.state.get(), PLAYING) after_pause = self.backend.playback.time_position.get() - self.assert_(after_pause >= at_pause) + self.assertGreaterEqual(after_pause, at_pause) def test_playpause_when_stopped_should_start_playback(self): self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) @@ -545,17 +545,17 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.playback.play() before_pause = self.backend.playback.time_position.get() - self.assert_(before_pause >= 0) + self.assertGreaterEqual(before_pause, 0) self.mpris.Pause() self.assertEquals(self.backend.playback.state.get(), PAUSED) at_pause = self.backend.playback.time_position.get() - self.assert_(at_pause >= before_pause) + self.assertGreaterEqual(at_pause, before_pause) self.mpris.Play() self.assertEquals(self.backend.playback.state.get(), PLAYING) after_pause = self.backend.playback.time_position.get() - self.assert_(after_pause >= at_pause) + self.assertGreaterEqual(after_pause, at_pause) def test_play_when_there_is_no_track_has_no_effect(self): self.backend.current_playlist.clear() @@ -569,7 +569,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.playback.play() before_seek = self.backend.playback.time_position.get() - self.assert_(before_seek >= 0) + self.assertGreaterEqual(before_seek, 0) milliseconds_to_seek = 10000 microseconds_to_seek = milliseconds_to_seek * 1000 @@ -577,15 +577,15 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Seek(microseconds_to_seek) after_seek = self.backend.playback.time_position.get() - self.assert_(before_seek <= after_seek < ( - before_seek + milliseconds_to_seek)) + self.assertLessEqual(before_seek, after_seek) + self.assertLess(after_seek, before_seek + milliseconds_to_seek) def test_seek_seeks_given_microseconds_forward_in_the_current_track(self): self.backend.current_playlist.append([Track(uri='a', length=40000)]) self.backend.playback.play() before_seek = self.backend.playback.time_position.get() - self.assert_(before_seek >= 0) + self.assertGreaterEqual(before_seek, 0) milliseconds_to_seek = 10000 microseconds_to_seek = milliseconds_to_seek * 1000 @@ -595,7 +595,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEquals(self.backend.playback.state.get(), PLAYING) after_seek = self.backend.playback.time_position.get() - self.assert_(after_seek >= (before_seek + milliseconds_to_seek)) + self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) def test_seek_seeks_given_microseconds_backward_if_negative(self): self.backend.current_playlist.append([Track(uri='a', length=40000)]) @@ -603,7 +603,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.playback.seek(20000) before_seek = self.backend.playback.time_position.get() - self.assert_(before_seek >= 20000) + self.assertGreaterEqual(before_seek, 20000) milliseconds_to_seek = -10000 microseconds_to_seek = milliseconds_to_seek * 1000 @@ -613,8 +613,8 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEquals(self.backend.playback.state.get(), PLAYING) after_seek = self.backend.playback.time_position.get() - self.assert_(after_seek >= (before_seek + milliseconds_to_seek)) - self.assert_(after_seek < before_seek) + self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) + self.assertLess(after_seek, before_seek) def test_seek_seeks_to_start_of_track_if_new_position_is_negative(self): self.backend.current_playlist.append([Track(uri='a', length=40000)]) @@ -622,7 +622,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.playback.seek(20000) before_seek = self.backend.playback.time_position.get() - self.assert_(before_seek >= 20000) + self.assertGreaterEqual(before_seek, 20000) milliseconds_to_seek = -30000 microseconds_to_seek = milliseconds_to_seek * 1000 @@ -632,9 +632,9 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEquals(self.backend.playback.state.get(), PLAYING) after_seek = self.backend.playback.time_position.get() - self.assert_(after_seek >= (before_seek + milliseconds_to_seek)) - self.assert_(after_seek < before_seek) - self.assert_(after_seek >= 0) + self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) + self.assertLess(after_seek, before_seek) + self.assertGreaterEqual(after_seek, 0) def test_seek_skips_to_next_track_if_new_position_larger_than_track_length(self): self.backend.current_playlist.append([Track(uri='a', length=40000), @@ -643,7 +643,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.playback.seek(20000) before_seek = self.backend.playback.time_position.get() - self.assert_(before_seek >= 20000) + self.assertGreaterEqual(before_seek, 20000) self.assertEquals(self.backend.playback.state.get(), PLAYING) self.assertEquals(self.backend.playback.current_track.get().uri, 'a') @@ -656,8 +656,8 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEquals(self.backend.playback.current_track.get().uri, 'b') after_seek = self.backend.playback.time_position.get() - self.assert_(after_seek >= 0) - self.assert_(after_seek < before_seek) + self.assertGreaterEqual(after_seek, 0) + self.assertLess(after_seek, before_seek) def test_set_position_is_ignored_if_can_seek_is_false(self): self.mpris.get_CanSeek = lambda *_: False @@ -665,7 +665,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.playback.play() before_set_position = self.backend.playback.time_position.get() - self.assert_(before_set_position <= 5000) + self.assertLessEqual(before_set_position, 5000) track_id = 'a' @@ -675,15 +675,15 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.SetPosition(track_id, position_to_set_in_microseconds) after_set_position = self.backend.playback.time_position.get() - self.assert_(before_set_position <= after_set_position < - position_to_set_in_milliseconds) + self.assertLessEqual(before_set_position, after_set_position) + self.assertLess(after_set_position, position_to_set_in_milliseconds) def test_set_position_sets_the_current_track_position_in_microsecs(self): self.backend.current_playlist.append([Track(uri='a', length=40000)]) self.backend.playback.play() before_set_position = self.backend.playback.time_position.get() - self.assert_(before_set_position <= 5000) + self.assertLessEqual(before_set_position, 5000) self.assertEquals(self.backend.playback.state.get(), PLAYING) track_id = '/com/mopidy/track/0' @@ -696,7 +696,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEquals(self.backend.playback.state.get(), PLAYING) after_set_position = self.backend.playback.time_position.get() - self.assert_(after_set_position >= position_to_set_in_milliseconds) + self.assertGreaterEqual(after_set_position, position_to_set_in_milliseconds) def test_set_position_does_nothing_if_the_position_is_negative(self): self.backend.current_playlist.append([Track(uri='a', length=40000)]) @@ -704,8 +704,8 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.playback.seek(20000) before_set_position = self.backend.playback.time_position.get() - self.assert_(before_set_position >= 20000) - self.assert_(before_set_position <= 25000) + self.assertGreaterEqual(before_set_position, 20000) + self.assertLessEqual(before_set_position, 25000) self.assertEquals(self.backend.playback.state.get(), PLAYING) self.assertEquals(self.backend.playback.current_track.get().uri, 'a') @@ -717,7 +717,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.SetPosition(track_id, position_to_set_in_microseconds) after_set_position = self.backend.playback.time_position.get() - self.assert_(after_set_position >= before_set_position) + self.assertGreaterEqual(after_set_position, before_set_position) self.assertEquals(self.backend.playback.state.get(), PLAYING) self.assertEquals(self.backend.playback.current_track.get().uri, 'a') @@ -727,8 +727,8 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.playback.seek(20000) before_set_position = self.backend.playback.time_position.get() - self.assert_(before_set_position >= 20000) - self.assert_(before_set_position <= 25000) + self.assertGreaterEqual(before_set_position, 20000) + self.assertLessEqual(before_set_position, 25000) self.assertEquals(self.backend.playback.state.get(), PLAYING) self.assertEquals(self.backend.playback.current_track.get().uri, 'a') @@ -740,7 +740,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.SetPosition(track_id, position_to_set_in_microseconds) after_set_position = self.backend.playback.time_position.get() - self.assert_(after_set_position >= before_set_position) + self.assertGreaterEqual(after_set_position, before_set_position) self.assertEquals(self.backend.playback.state.get(), PLAYING) self.assertEquals(self.backend.playback.current_track.get().uri, 'a') @@ -750,8 +750,8 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.playback.seek(20000) before_set_position = self.backend.playback.time_position.get() - self.assert_(before_set_position >= 20000) - self.assert_(before_set_position <= 25000) + self.assertGreaterEqual(before_set_position, 20000) + self.assertLessEqual(before_set_position, 25000) self.assertEquals(self.backend.playback.state.get(), PLAYING) self.assertEquals(self.backend.playback.current_track.get().uri, 'a') @@ -763,7 +763,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.SetPosition(track_id, position_to_set_in_microseconds) after_set_position = self.backend.playback.time_position.get() - self.assert_(after_set_position >= before_set_position) + self.assertGreaterEqual(after_set_position, before_set_position) self.assertEquals(self.backend.playback.state.get(), PLAYING) self.assertEquals(self.backend.playback.current_track.get().uri, 'a') diff --git a/tests/version_test.py b/tests/version_test.py index c3eb00c1..678dc221 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -11,25 +11,25 @@ class VersionTest(unittest.TestCase): SV(__version__) def test_versions_can_be_strictly_ordered(self): - self.assert_(SV('0.1.0a0') < SV('0.1.0a1')) - self.assert_(SV('0.1.0a1') < SV('0.1.0a2')) - self.assert_(SV('0.1.0a2') < SV('0.1.0a3')) - self.assert_(SV('0.1.0a3') < SV('0.1.0')) - self.assert_(SV('0.1.0') < SV('0.2.0')) - self.assert_(SV('0.1.0') < SV('1.0.0')) - self.assert_(SV('0.2.0') < SV('0.3.0')) - self.assert_(SV('0.3.0') < SV('0.3.1')) - self.assert_(SV('0.3.1') < SV('0.4.0')) - self.assert_(SV('0.4.0') < SV('0.4.1')) - self.assert_(SV('0.4.1') < SV('0.5.0')) - self.assert_(SV('0.5.0') < SV('0.6.0')) - self.assert_(SV('0.6.0') < SV('0.6.1')) - self.assert_(SV('0.6.1') < SV('0.7.0')) - self.assert_(SV('0.7.0') < SV('0.7.1')) - self.assert_(SV('0.7.1') < SV('0.7.2')) - self.assert_(SV('0.7.2') < SV('0.7.3')) - self.assert_(SV('0.7.3') < SV(__version__)) - self.assert_(SV(__version__) < SV('0.8.1')) + self.assertLess(SV('0.1.0a0'), SV('0.1.0a1')) + self.assertLess(SV('0.1.0a1'), SV('0.1.0a2')) + self.assertLess(SV('0.1.0a2'), SV('0.1.0a3')) + self.assertLess(SV('0.1.0a3'), SV('0.1.0')) + self.assertLess(SV('0.1.0'), SV('0.2.0')) + self.assertLess(SV('0.1.0'), SV('1.0.0')) + self.assertLess(SV('0.2.0'), SV('0.3.0')) + self.assertLess(SV('0.3.0'), SV('0.3.1')) + self.assertLess(SV('0.3.1'), SV('0.4.0')) + self.assertLess(SV('0.4.0'), SV('0.4.1')) + self.assertLess(SV('0.4.1'), SV('0.5.0')) + self.assertLess(SV('0.5.0'), SV('0.6.0')) + self.assertLess(SV('0.6.0'), SV('0.6.1')) + self.assertLess(SV('0.6.1'), SV('0.7.0')) + self.assertLess(SV('0.7.0'), SV('0.7.1')) + self.assertLess(SV('0.7.1'), SV('0.7.2')) + self.assertLess(SV('0.7.2'), SV('0.7.3')) + self.assertLess(SV('0.7.3'), SV(__version__)) + self.assertLess(SV(__version__), SV('0.8.1')) def test_get_platform_contains_platform(self): self.assertIn(platform.platform(), get_platform()) From 28e5ed8b2e29124d303ac5010a68509fbc8e827d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 14 Sep 2012 23:23:58 +0200 Subject: [PATCH 036/233] Send old and new state to playback_state_changed listeners --- mopidy/core/playback.py | 7 ++++--- mopidy/frontends/mpd/__init__.py | 2 +- mopidy/listeners.py | 7 ++++++- tests/listeners_test.py | 4 +++- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 31a1acc5..73866f16 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -290,7 +290,7 @@ class PlaybackController(object): (old_state, self._state) = (self.state, new_state) logger.debug(u'Changing state: %s -> %s', old_state, new_state) - self._trigger_playback_state_changed() + self._trigger_playback_state_changed(old_state, new_state) # FIXME play_time stuff assumes backend does not have a better way of # handeling this stuff :/ @@ -544,9 +544,10 @@ class PlaybackController(object): track=self.current_track, time_position=self.time_position) - def _trigger_playback_state_changed(self): + def _trigger_playback_state_changed(self, old_state, new_state): logger.debug(u'Triggering playback state change event') - BackendListener.send('playback_state_changed') + BackendListener.send('playback_state_changed', + old_state=old_state, new_state=new_state) def _trigger_options_changed(self): logger.debug(u'Triggering options changed event') diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index e8b2aabe..3d739c51 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -51,7 +51,7 @@ class MpdFrontend(actor.ThreadingActor, listeners.BackendListener): 'kwargs': {}, }, target_class=MpdSession) - def playback_state_changed(self): + def playback_state_changed(self, old_state, new_state): self.send_idle('player') def playlist_changed(self): diff --git a/mopidy/listeners.py b/mopidy/listeners.py index ee360bf3..8958ac2c 100644 --- a/mopidy/listeners.py +++ b/mopidy/listeners.py @@ -74,11 +74,16 @@ class BackendListener(object): """ pass - def playback_state_changed(self): + def playback_state_changed(self, old_state, new_state): """ Called whenever playback state is changed. *MAY* be implemented by actor. + + :param old_state: the state before the change + :type old_state: string from :class:`mopidy.core.PlaybackState` field + :param new_state: the state after the change + :type new_state: string from :class:`mopidy.core.PlaybackState` field """ pass diff --git a/tests/listeners_test.py b/tests/listeners_test.py index 486dcf9c..7a1c6fb9 100644 --- a/tests/listeners_test.py +++ b/tests/listeners_test.py @@ -1,3 +1,4 @@ +from mopidy.core import PlaybackState from mopidy.listeners import BackendListener from mopidy.models import Track @@ -21,7 +22,8 @@ class BackendListenerTest(unittest.TestCase): self.listener.track_playback_ended(Track(), 0) def test_listener_has_default_impl_for_playback_state_changed(self): - self.listener.playback_state_changed() + self.listener.playback_state_changed( + PlaybackState.STOPPED, PlaybackState.PLAYING) def test_listener_has_default_impl_for_playlist_changed(self): self.listener.playlist_changed() From b60e6806ced7a5f3059dc6ea256b2e5b80895b1e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Sep 2012 13:38:40 +0200 Subject: [PATCH 037/233] Add get_time_position() to playback provider interface --- mopidy/backends/base/playback.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index ae5a4383..197ba90e 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -65,6 +65,16 @@ class BasePlaybackProvider(object): """ return self.backend.audio.stop_playback().get() + def get_time_position(self): + """ + Get the current time position in milliseconds. + + *MAY be reimplemented by subclass.* + + :rtype: int + """ + return self.backend.audio.get_position().get() + def get_volume(self): """ Get current volume From f0613753160ad985a37ec054e7dfaee9729070d6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Sep 2012 13:39:12 +0200 Subject: [PATCH 038/233] Override get_time_position() in the dummy backend --- mopidy/backends/dummy/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy/__init__.py index 3ada0052..9c4a0e69 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy/__init__.py @@ -56,6 +56,7 @@ class DummyLibraryProvider(base.BaseLibraryProvider): class DummyPlaybackProvider(base.BasePlaybackProvider): def __init__(self, *args, **kwargs): super(DummyPlaybackProvider, self).__init__(*args, **kwargs) + self._time_position = 0 self._volume = None def pause(self): @@ -63,17 +64,22 @@ class DummyPlaybackProvider(base.BasePlaybackProvider): def play(self, track): """Pass None as track to force failure""" + self._time_position = 0 return track is not None def resume(self): return True def seek(self, time_position): + self._time_position = time_position return True def stop(self): return True + def get_time_position(self): + return self._time_position + def get_volume(self): return self._volume From 81fca7d68674c4784c35cd770423549cd429934b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Sep 2012 19:33:34 +0200 Subject: [PATCH 039/233] Switch to time position from provider --- mopidy/core/playback.py | 3 +++ tests/frontends/mpd/status_test.py | 13 ++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 9f6030c1..1bebd270 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -307,6 +307,9 @@ class PlaybackController(object): @property def time_position(self): """Time position in milliseconds.""" + return self.provider.get_time_position() + + def _wall_clock_based_time_position(): if self.state == PlaybackState.PLAYING: time_since_started = (self._current_wall_time - self.play_time_started) diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 59418a3b..2397b96f 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -159,18 +159,21 @@ class StatusHandlerTest(unittest.TestCase): self.assertLessEqual(position, total) def test_status_method_when_playing_contains_elapsed(self): - self.backend.playback.state = PAUSED - self.backend.playback.play_time_accumulated = 59123 + self.backend.current_playlist.append([Track(length=60000)]) + self.backend.playback.play() + self.backend.playback.pause() + self.backend.playback.seek(59123) result = dict(status.status(self.context)) self.assertIn('elapsed', result) self.assertEqual(result['elapsed'], '59.123') def test_status_method_when_starting_playing_contains_elapsed_zero(self): - self.backend.playback.state = PAUSED - self.backend.playback.play_time_accumulated = 123 # Less than 1000ms + self.backend.current_playlist.append([Track(length=10000)]) + self.backend.playback.play() + self.backend.playback.pause() result = dict(status.status(self.context)) self.assertIn('elapsed', result) - self.assertEqual(result['elapsed'], '0.123') + self.assertEqual(result['elapsed'], '0.000') def test_status_method_when_playing_contains_bitrate(self): self.backend.current_playlist.append([Track(bitrate=320)]) From ef17e36a1a641edc5605ba8520a4fd5700776f0e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 25 Sep 2012 11:11:59 +0200 Subject: [PATCH 040/233] Remove LocalPlaybackController --- mopidy/backends/local/__init__.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index db86e56f..c321c6e9 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -41,7 +41,7 @@ class LocalBackend(ThreadingActor, base.Backend): provider=library_provider) playback_provider = base.BasePlaybackProvider(backend=self) - self.playback = LocalPlaybackController(backend=self, + self.playback = core.PlaybackController(backend=self, provider=playback_provider) stored_playlists_provider = LocalStoredPlaylistsProvider(backend=self) @@ -59,18 +59,6 @@ class LocalBackend(ThreadingActor, base.Backend): self.audio = audio_refs[0].proxy() -class LocalPlaybackController(core.PlaybackController): - def __init__(self, *args, **kwargs): - super(LocalPlaybackController, self).__init__(*args, **kwargs) - - # XXX Why do we call stop()? Is it to set GStreamer state to 'READY'? - self.stop() - - @property - def time_position(self): - return self.backend.audio.get_position().get() - - class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def __init__(self, *args, **kwargs): super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs) From 12d6ce53dd97593c4a5995783ea7bef8bc898b1e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 14 Sep 2012 23:32:19 +0200 Subject: [PATCH 041/233] Send new time position to 'seeked' listeners --- mopidy/core/playback.py | 6 +++--- mopidy/listeners.py | 5 ++++- tests/listeners_test.py | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 73866f16..9f6030c1 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -495,7 +495,7 @@ class PlaybackController(object): success = self.provider.seek(time_position) if success: - self._trigger_seeked() + self._trigger_seeked(time_position) return success def stop(self, clear_current_track=False): @@ -553,6 +553,6 @@ class PlaybackController(object): logger.debug(u'Triggering options changed event') BackendListener.send('options_changed') - def _trigger_seeked(self): + def _trigger_seeked(self, time_position): logger.debug(u'Triggering seeked event') - BackendListener.send('seeked') + BackendListener.send('seeked', time_position=time_position) diff --git a/mopidy/listeners.py b/mopidy/listeners.py index 8958ac2c..a8794232 100644 --- a/mopidy/listeners.py +++ b/mopidy/listeners.py @@ -111,11 +111,14 @@ class BackendListener(object): """ pass - def seeked(self): + def seeked(self, time_position): """ Called whenever the time position changes by an unexpected amount, e.g. at seek to a new time position. *MAY* be implemented by actor. + + :param time_position: the position that was seeked to in milliseconds + :type time_position: int """ pass diff --git a/tests/listeners_test.py b/tests/listeners_test.py index 7a1c6fb9..896fedf0 100644 --- a/tests/listeners_test.py +++ b/tests/listeners_test.py @@ -35,4 +35,4 @@ class BackendListenerTest(unittest.TestCase): self.listener.volume_changed() def test_listener_has_default_impl_for_seeked(self): - self.listener.seeked() + self.listener.seeked(0) From 2237e4f5a1e2e79d50485f0d1b97a5774af0ed40 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 25 Sep 2012 12:10:25 +0200 Subject: [PATCH 042/233] Move optional wall clock-based position tracking down to the playback provider --- mopidy/backends/base/playback.py | 45 +++++++++++++++++++++++++++ mopidy/backends/spotify/playback.py | 9 ++++++ mopidy/core/playback.py | 48 +++-------------------------- 3 files changed, 58 insertions(+), 44 deletions(-) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 197ba90e..b3b9959e 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -1,3 +1,8 @@ +import time + +from mopidy.core.playback import PlaybackState + + class BasePlaybackProvider(object): """ :param backend: the backend @@ -9,6 +14,9 @@ class BasePlaybackProvider(object): def __init__(self, backend): self.backend = backend + self._play_time_accumulated = 0 + self._play_time_started = 0 + def pause(self): """ Pause playback. @@ -95,3 +103,40 @@ class BasePlaybackProvider(object): :type volume: int [0..100] """ self.backend.audio.set_volume(volume) + + def wall_clock_based_time_position(self): + """ + Helper method that tracks track time position using the wall clock. + + To use this helper you must call the helper from your implementation of + :meth:`get_time_position` and return its return value. + + :rtype: int + """ + state = self.backend.playback.state + if state == PlaybackState.PLAYING: + time_since_started = (self._wall_time() - + self._play_time_started) + return self._play_time_accumulated + time_since_started + elif state == PlaybackState.PAUSED: + return self._play_time_accumulated + elif state == PlaybackState.STOPPED: + return 0 + + def update_play_time_on_play(self): + self._play_time_accumulated = 0 + self._play_time_started = self._wall_time() + + def update_play_time_on_pause(self): + time_since_started = self._wall_time() - self._play_time_started + self._play_time_accumulated += time_since_started + + def update_play_time_on_resume(self): + self._play_time_started = self._wall_time() + + def update_play_time_on_seek(self, time_position): + self._play_time_started = self._wall_time() + self._play_time_accumulated = time_position + + def _wall_time(self): + return int(time.time() * 1000) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 1c20da87..cd5b0689 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -5,8 +5,10 @@ from spotify import Link, SpotifyError from mopidy.backends.base import BasePlaybackProvider from mopidy.core import PlaybackState + logger = logging.getLogger('mopidy.backends.spotify.playback') + class SpotifyPlaybackProvider(BasePlaybackProvider): def play(self, track): if self.backend.playback.state == PlaybackState.PLAYING: @@ -38,3 +40,10 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): def stop(self): self.backend.spotify.session.play(0) return super(SpotifyPlaybackProvider, self).stop() + + def get_time_position(self): + # XXX: The default implementation of get_time_position hangs/times out + # when used with the Spotify backend and GStreamer appsrc. If this can + # be resolved, we no longer need to use a wall clock based time + # position for Spotify playback. + return self.wall_clock_based_time_position() diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 1bebd270..b32f5b62 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -1,6 +1,5 @@ import logging import random -import time from mopidy.listeners import BackendListener @@ -20,7 +19,6 @@ def option_wrapper(name, default): return property(get_option, set_option) - class PlaybackState(object): """ Enum of playback states. @@ -87,8 +85,6 @@ class PlaybackController(object): self._state = PlaybackState.STOPPED self._shuffled = [] self._first_shuffle = True - self.play_time_accumulated = 0 - self.play_time_started = 0 def _get_cpid(self, cp_track): if cp_track is None: @@ -292,48 +288,11 @@ class PlaybackController(object): self._trigger_playback_state_changed(old_state, new_state) - # FIXME play_time stuff assumes backend does not have a better way of - # handeling this stuff :/ - if (old_state in (PlaybackState.PLAYING, PlaybackState.STOPPED) - and new_state == PlaybackState.PLAYING): - self._play_time_start() - elif (old_state == PlaybackState.PLAYING - and new_state == PlaybackState.PAUSED): - self._play_time_pause() - elif (old_state == PlaybackState.PAUSED - and new_state == PlaybackState.PLAYING): - self._play_time_resume() - @property def time_position(self): """Time position in milliseconds.""" return self.provider.get_time_position() - def _wall_clock_based_time_position(): - if self.state == PlaybackState.PLAYING: - time_since_started = (self._current_wall_time - - self.play_time_started) - return self.play_time_accumulated + time_since_started - elif self.state == PlaybackState.PAUSED: - return self.play_time_accumulated - elif self.state == PlaybackState.STOPPED: - return 0 - - def _play_time_start(self): - self.play_time_accumulated = 0 - self.play_time_started = self._current_wall_time - - def _play_time_pause(self): - time_since_started = self._current_wall_time - self.play_time_started - self.play_time_accumulated += time_since_started - - def _play_time_resume(self): - self.play_time_started = self._current_wall_time - - @property - def _current_wall_time(self): - return int(time.time() * 1000) - @property def volume(self): return self.provider.get_volume() @@ -411,6 +370,7 @@ class PlaybackController(object): """Pause playback.""" if self.provider.pause(): self.state = PlaybackState.PAUSED + self.provider.update_play_time_on_pause() self._trigger_track_playback_paused() def play(self, cp_track=None, on_error_step=1): @@ -453,6 +413,7 @@ class PlaybackController(object): if self.random and self.current_cp_track in self._shuffled: self._shuffled.remove(self.current_cp_track) + self.provider.update_play_time_on_play() self._trigger_track_playback_started() def previous(self): @@ -469,6 +430,7 @@ class PlaybackController(object): """If paused, resume playing the current track.""" if self.state == PlaybackState.PAUSED and self.provider.resume(): self.state = PlaybackState.PLAYING + self.provider.update_play_time_on_resume() self._trigger_track_playback_resumed() def seek(self, time_position): @@ -493,11 +455,9 @@ class PlaybackController(object): self.next() return True - self.play_time_started = self._current_wall_time - self.play_time_accumulated = time_position - success = self.provider.seek(time_position) if success: + self.provider.update_play_time_on_seek(time_position) self._trigger_seeked(time_position) return success From 90a538c5954e6845080d93a6aeb970a5d8078e89 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 25 Sep 2012 15:43:08 +0200 Subject: [PATCH 043/233] Move wall clock-based time position into Spotify backend --- mopidy/backends/base/playback.py | 45 ----------------------------- mopidy/backends/spotify/playback.py | 37 ++++++++++++++++++++++-- mopidy/core/playback.py | 4 --- 3 files changed, 34 insertions(+), 52 deletions(-) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index b3b9959e..197ba90e 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -1,8 +1,3 @@ -import time - -from mopidy.core.playback import PlaybackState - - class BasePlaybackProvider(object): """ :param backend: the backend @@ -14,9 +9,6 @@ class BasePlaybackProvider(object): def __init__(self, backend): self.backend = backend - self._play_time_accumulated = 0 - self._play_time_started = 0 - def pause(self): """ Pause playback. @@ -103,40 +95,3 @@ class BasePlaybackProvider(object): :type volume: int [0..100] """ self.backend.audio.set_volume(volume) - - def wall_clock_based_time_position(self): - """ - Helper method that tracks track time position using the wall clock. - - To use this helper you must call the helper from your implementation of - :meth:`get_time_position` and return its return value. - - :rtype: int - """ - state = self.backend.playback.state - if state == PlaybackState.PLAYING: - time_since_started = (self._wall_time() - - self._play_time_started) - return self._play_time_accumulated + time_since_started - elif state == PlaybackState.PAUSED: - return self._play_time_accumulated - elif state == PlaybackState.STOPPED: - return 0 - - def update_play_time_on_play(self): - self._play_time_accumulated = 0 - self._play_time_started = self._wall_time() - - def update_play_time_on_pause(self): - time_since_started = self._wall_time() - self._play_time_started - self._play_time_accumulated += time_since_started - - def update_play_time_on_resume(self): - self._play_time_started = self._wall_time() - - def update_play_time_on_seek(self, time_position): - self._play_time_started = self._wall_time() - self._play_time_accumulated = time_position - - def _wall_time(self): - return int(time.time() * 1000) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index cd5b0689..61696bd8 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -1,4 +1,5 @@ import logging +import time from spotify import Link, SpotifyError @@ -10,11 +11,25 @@ logger = logging.getLogger('mopidy.backends.spotify.playback') class SpotifyPlaybackProvider(BasePlaybackProvider): + def __init__(self, *args, **kwargs): + super(SpotifyPlaybackProvider, self).__init__(*args, **kwargs) + + self._play_time_accumulated = 0 + self._play_time_started = 0 + + def pause(self): + time_since_started = self._wall_time() - self._play_time_started + self._play_time_accumulated += time_since_started + + return super(SpotifyPlaybackProvider, self).pause() + def play(self, track): - if self.backend.playback.state == PlaybackState.PLAYING: - self.backend.spotify.session.play(0) if track.uri is None: return False + + self._play_time_accumulated = 0 + self._play_time_started = self._wall_time() + try: self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) @@ -29,12 +44,17 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): return False def resume(self): + self._play_time_started = self._wall_time() return self.seek(self.backend.playback.time_position) def seek(self, time_position): + self._play_time_started = self._wall_time() + self._play_time_accumulated = time_position + self.backend.audio.prepare_change() self.backend.spotify.session.seek(time_position) self.backend.audio.start_playback() + return True def stop(self): @@ -46,4 +66,15 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): # when used with the Spotify backend and GStreamer appsrc. If this can # be resolved, we no longer need to use a wall clock based time # position for Spotify playback. - return self.wall_clock_based_time_position() + state = self.backend.playback.state + if state == PlaybackState.PLAYING: + time_since_started = (self._wall_time() - + self._play_time_started) + return self._play_time_accumulated + time_since_started + elif state == PlaybackState.PAUSED: + return self._play_time_accumulated + elif state == PlaybackState.STOPPED: + return 0 + + def _wall_time(self): + return int(time.time() * 1000) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index b32f5b62..82a11064 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -370,7 +370,6 @@ class PlaybackController(object): """Pause playback.""" if self.provider.pause(): self.state = PlaybackState.PAUSED - self.provider.update_play_time_on_pause() self._trigger_track_playback_paused() def play(self, cp_track=None, on_error_step=1): @@ -413,7 +412,6 @@ class PlaybackController(object): if self.random and self.current_cp_track in self._shuffled: self._shuffled.remove(self.current_cp_track) - self.provider.update_play_time_on_play() self._trigger_track_playback_started() def previous(self): @@ -430,7 +428,6 @@ class PlaybackController(object): """If paused, resume playing the current track.""" if self.state == PlaybackState.PAUSED and self.provider.resume(): self.state = PlaybackState.PLAYING - self.provider.update_play_time_on_resume() self._trigger_track_playback_resumed() def seek(self, time_position): @@ -457,7 +454,6 @@ class PlaybackController(object): success = self.provider.seek(time_position) if success: - self.provider.update_play_time_on_seek(time_position) self._trigger_seeked(time_position) return success From b913dc48737b19a0b3d757bffe2a36e1b05f9bc1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 25 Sep 2012 15:49:00 +0200 Subject: [PATCH 044/233] Turn on IRC notification when Travis build status changes --- .travis.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.travis.yml b/.travis.yml index a57f7474..6120e2de 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,3 +10,10 @@ before_script: - "rm $VIRTUAL_ENV/lib/python$TRAVIS_PYTHON_VERSION/no-global-site-packages.txt" script: nosetests + +notifications: + irc: + channels: + - "irc.freenode.org#mopidy" + on_success: change + on_failure: change From 66f476e85ae667d1f401593c33b7b542ec63ddb7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 26 Sep 2012 10:08:59 +0200 Subject: [PATCH 045/233] Fix typo --- mopidy/frontends/mpd/__init__.py | 2 +- mopidy/utils/network.py | 10 ++++---- tests/utils/network/lineprotocol_test.py | 32 ++++++++++++------------ 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 3d739c51..5d287d03 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -72,7 +72,7 @@ class MpdSession(network.LineProtocol): terminator = protocol.LINE_TERMINATOR encoding = protocol.ENCODING - delimeter = r'\r?\n' + delimiter = r'\r?\n' def __init__(self, connection): super(MpdSession, self).__init__(connection) diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 7d97daf8..9cb8d74c 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -293,7 +293,7 @@ class LineProtocol(ThreadingActor): #: Regex to use for spliting lines, will be set compiled version of its #: own value, or to ``terminator``s value if it is not set itself. - delimeter = None + delimiter = None #: What encoding to expect incomming data to be in, can be :class:`None`. encoding = 'utf-8' @@ -304,10 +304,10 @@ class LineProtocol(ThreadingActor): self.prevent_timeout = False self.recv_buffer = '' - if self.delimeter: - self.delimeter = re.compile(self.delimeter) + if self.delimiter: + self.delimiter = re.compile(self.delimiter) else: - self.delimeter = re.compile(self.terminator) + self.delimiter = re.compile(self.terminator) @property def host(self): @@ -348,7 +348,7 @@ class LineProtocol(ThreadingActor): def parse_lines(self): """Consume new data and yield any lines found.""" while re.search(self.terminator, self.recv_buffer): - line, self.recv_buffer = self.delimeter.split( + line, self.recv_buffer = self.delimiter.split( self.recv_buffer, 1) yield line diff --git a/tests/utils/network/lineprotocol_test.py b/tests/utils/network/lineprotocol_test.py index b323de09..4ba62b8f 100644 --- a/tests/utils/network/lineprotocol_test.py +++ b/tests/utils/network/lineprotocol_test.py @@ -14,23 +14,23 @@ class LineProtocolTest(unittest.TestCase): self.mock.terminator = network.LineProtocol.terminator self.mock.encoding = network.LineProtocol.encoding - self.mock.delimeter = network.LineProtocol.delimeter + self.mock.delimiter = network.LineProtocol.delimiter self.mock.prevent_timeout = False def test_init_stores_values_in_attributes(self): - delimeter = re.compile(network.LineProtocol.terminator) + delimiter = re.compile(network.LineProtocol.terminator) network.LineProtocol.__init__(self.mock, sentinel.connection) self.assertEqual(sentinel.connection, self.mock.connection) self.assertEqual('', self.mock.recv_buffer) - self.assertEqual(delimeter, self.mock.delimeter) + self.assertEqual(delimiter, self.mock.delimiter) self.assertFalse(self.mock.prevent_timeout) - def test_init_compiles_delimeter(self): - self.mock.delimeter = '\r?\n' - delimeter = re.compile('\r?\n') + def test_init_compiles_delimiter(self): + self.mock.delimiter = '\r?\n' + delimiter = re.compile('\r?\n') network.LineProtocol.__init__(self.mock, sentinel.connection) - self.assertEqual(delimeter, self.mock.delimeter) + self.assertEqual(delimiter, self.mock.delimiter) def test_on_receive_no_new_lines_adds_to_recv_buffer(self): self.mock.connection = Mock(spec=network.Connection) @@ -108,21 +108,21 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual(2, self.mock.on_line_received.call_count) def test_parse_lines_emtpy_buffer(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = '' lines = network.LineProtocol.parse_lines(self.mock) self.assertRaises(StopIteration, lines.next) def test_parse_lines_no_terminator(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'data' lines = network.LineProtocol.parse_lines(self.mock) self.assertRaises(StopIteration, lines.next) def test_parse_lines_termintor(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'data\n' lines = network.LineProtocol.parse_lines(self.mock) @@ -131,7 +131,7 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_termintor_with_carriage_return(self): - self.mock.delimeter = re.compile(r'\r?\n') + self.mock.delimiter = re.compile(r'\r?\n') self.mock.recv_buffer = 'data\r\n' lines = network.LineProtocol.parse_lines(self.mock) @@ -140,7 +140,7 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_no_data_before_terminator(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = '\n' lines = network.LineProtocol.parse_lines(self.mock) @@ -149,7 +149,7 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_extra_data_after_terminator(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'data1\ndata2' lines = network.LineProtocol.parse_lines(self.mock) @@ -158,7 +158,7 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual('data2', self.mock.recv_buffer) def test_parse_lines_unicode(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = u'æøå\n'.encode('utf-8') lines = network.LineProtocol.parse_lines(self.mock) @@ -167,7 +167,7 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_multiple_lines(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'abc\ndef\nghi\njkl' lines = network.LineProtocol.parse_lines(self.mock) @@ -178,7 +178,7 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual('jkl', self.mock.recv_buffer) def test_parse_lines_multiple_calls(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'data1' lines = network.LineProtocol.parse_lines(self.mock) From f88b7115d9aac100a4681d865c947fd0a27771af Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 26 Sep 2012 10:11:33 +0200 Subject: [PATCH 046/233] Give the backends an audio proxy on construction --- mopidy/__main__.py | 11 ++++++----- mopidy/backends/base/__init__.py | 9 +++++++++ mopidy/backends/local/__init__.py | 10 +--------- mopidy/backends/spotify/__init__.py | 8 +------- 4 files changed, 17 insertions(+), 21 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 35518874..c82510d9 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -51,8 +51,8 @@ def main(): setup_logging(options.verbosity_level, options.save_debug_log) check_old_folders() setup_settings(options.interactive) - setup_audio() - setup_backend() + audio = setup_audio() + setup_backend(audio) setup_frontends() loop.run() except SettingsError as e: @@ -118,14 +118,15 @@ def setup_settings(interactive): def setup_audio(): - Audio.start() + return Audio.start().proxy() def stop_audio(): stop_actors_by_class(Audio) -def setup_backend(): - get_class(settings.BACKENDS[0]).start() + +def setup_backend(audio): + get_class(settings.BACKENDS[0]).start(audio=audio) def stop_backend(): diff --git a/mopidy/backends/base/__init__.py b/mopidy/backends/base/__init__.py index e6c8b70a..67a3c5ba 100644 --- a/mopidy/backends/base/__init__.py +++ b/mopidy/backends/base/__init__.py @@ -4,6 +4,12 @@ from .stored_playlists import BaseStoredPlaylistsProvider class Backend(object): + #: Actor proxy to an instance of :class:`mopidy.audio.Audio`. + #: + #: Should be passed to the backend constructor as the kwarg ``audio``, + #: which will then set this field. + audio = None + #: The current playlist controller. An instance of #: :class:`mopidy.backends.base.CurrentPlaylistController`. current_playlist = None @@ -22,3 +28,6 @@ class Backend(object): #: List of URI schemes this backend can handle. uri_schemes = [] + + def __init__(self, audio=None): + self.audio = audio diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index c321c6e9..5d6ab8e1 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -32,7 +32,7 @@ class LocalBackend(ThreadingActor, base.Backend): """ def __init__(self, *args, **kwargs): - super(LocalBackend, self).__init__(*args, **kwargs) + base.Backend.__init__(self, *args, **kwargs) self.current_playlist = core.CurrentPlaylistController(backend=self) @@ -50,14 +50,6 @@ class LocalBackend(ThreadingActor, base.Backend): self.uri_schemes = [u'file'] - self.audio = None - - def on_start(self): - audio_refs = ActorRegistry.get_by_class(audio.Audio) - assert len(audio_refs) == 1, \ - 'Expected exactly one running Audio instance.' - self.audio = audio_refs[0].proxy() - class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def __init__(self, *args, **kwargs): diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 1feb1c65..039295b6 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -47,7 +47,7 @@ class SpotifyBackend(ThreadingActor, base.Backend): from .playback import SpotifyPlaybackProvider from .stored_playlists import SpotifyStoredPlaylistsProvider - super(SpotifyBackend, self).__init__(*args, **kwargs) + base.Backend.__init__(self, *args, **kwargs) self.current_playlist = core.CurrentPlaylistController(backend=self) @@ -66,7 +66,6 @@ class SpotifyBackend(ThreadingActor, base.Backend): self.uri_schemes = [u'spotify'] - self.audio = None self.spotify = None # Fail early if settings are not present @@ -74,11 +73,6 @@ class SpotifyBackend(ThreadingActor, base.Backend): self.password = settings.SPOTIFY_PASSWORD def on_start(self): - audio_refs = ActorRegistry.get_by_class(audio.Audio) - assert len(audio_refs) == 1, \ - 'Expected exactly one running Audio instance.' - self.audio = audio_refs[0].proxy() - logger.info(u'Mopidy uses SPOTIFY(R) CORE') self.spotify = self._connect() From 53d615622744939459aaaa9fb52e47926781d036 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 26 Sep 2012 10:12:17 +0200 Subject: [PATCH 047/233] Give SpotifySessionManager audio and backend proxies on construction --- mopidy/backends/spotify/__init__.py | 3 ++- mopidy/backends/spotify/session_manager.py | 17 +++-------------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 039295b6..fccdd3f1 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -83,6 +83,7 @@ class SpotifyBackend(ThreadingActor, base.Backend): from .session_manager import SpotifySessionManager logger.debug(u'Connecting to Spotify') - spotify = SpotifySessionManager(self.username, self.password) + spotify = SpotifySessionManager(self.username, self.password, + audio=self.audio, backend=self.actor_ref.proxy()) spotify.start() return spotify diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index ce1226d8..382f65f6 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -27,13 +27,13 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key') user_agent = 'Mopidy %s' % get_version() - def __init__(self, username, password): + def __init__(self, username, password, audio, backend): PyspotifySessionManager.__init__(self, username, password) BaseThread.__init__(self) self.name = 'SpotifyThread' - self.audio = None - self.backend = None + self.audio = audio + self.backend = backend self.connected = threading.Event() self.session = None @@ -44,19 +44,8 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): self._initial_data_receive_completed = False def run_inside_try(self): - self.setup() self.connect() - def setup(self): - audio_refs = ActorRegistry.get_by_class(audio.Audio) - assert len(audio_refs) == 1, \ - 'Expected exactly one running Audio instance.' - self.audio = audio_refs[0].proxy() - - backend_refs = ActorRegistry.get_by_class(Backend) - assert len(backend_refs) == 1, 'Expected exactly one running backend.' - self.backend = backend_refs[0].proxy() - def logged_in(self, session, error): """Callback used by pyspotify""" if error: From 4ba5395cc01aa1cbd800fe8f083c35e1f1e6aff5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 26 Sep 2012 14:39:22 +0200 Subject: [PATCH 048/233] Remove unused imports --- mopidy/backends/local/__init__.py | 1 - mopidy/backends/spotify/__init__.py | 1 - mopidy/backends/spotify/session_manager.py | 2 -- 3 files changed, 4 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 5d6ab8e1..363c1b36 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -5,7 +5,6 @@ import os import shutil from pykka.actor import ThreadingActor -from pykka.registry import ActorRegistry from mopidy import audio, core, settings from mopidy.backends import base diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index fccdd3f1..d41c70b4 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -1,7 +1,6 @@ import logging from pykka.actor import ThreadingActor -from pykka.registry import ActorRegistry from mopidy import audio, core, settings from mopidy.backends import base diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 382f65f6..9fb6adcb 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -4,8 +4,6 @@ import threading from spotify.manager import SpotifySessionManager as PyspotifySessionManager -from pykka.registry import ActorRegistry - from mopidy import audio, get_version, settings from mopidy.backends.base import Backend from mopidy.backends.spotify import BITRATES From 52656096103822fa18009913a15703ad2916e0d3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 26 Sep 2012 21:51:29 +0200 Subject: [PATCH 049/233] MPRIS: New BackendListener.seeked() signature --- mopidy/frontends/mpris/__init__.py | 7 ++----- tests/frontends/mpris/events_test.py | 6 +----- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 0f5d35c5..4d4d5edb 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -123,9 +123,6 @@ class MprisFrontend(ThreadingActor, BackendListener): logger.debug(u'Received volume changed event') self._emit_properties_changed('Volume') - def seeked(self): + def seeked(self, time_position_in_ms): logger.debug(u'Received seeked event') - if self.mpris_object is None: - return - self.mpris_object.Seeked( - self.mpris_object.Get(objects.PLAYER_IFACE, 'Position')) + self.mpris_object.Seeked(time_position_in_ms * 1000) diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py index 49e56226..3db03ccf 100644 --- a/tests/frontends/mpris/events_test.py +++ b/tests/frontends/mpris/events_test.py @@ -70,9 +70,5 @@ class BackendEventsTest(unittest.TestCase): objects.PLAYER_IFACE, {'Volume': 1.0}, []) def test_seeked_event_causes_mpris_seeked_event(self): - self.mpris_object.Get.return_value = 31000000 - self.mpris_frontend.seeked() - self.assertListEqual(self.mpris_object.Get.call_args_list, [ - ((objects.PLAYER_IFACE, 'Position'), {}), - ]) + self.mpris_frontend.seeked(31000) self.mpris_object.Seeked.assert_called_with(31000000) From f80979517daaf841dd0dcd836f709d9a08b2a7b5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 26 Sep 2012 22:10:20 +0200 Subject: [PATCH 050/233] Refactor Spotify track position tracking - Moved to its own class, so it can easily be removed in the future if we get GStreamer based track position working for appsrc. - Now tracks playback state itself, to not depend on the playback controller. --- mopidy/backends/spotify/playback.py | 74 +++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 61696bd8..94d57f56 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -14,12 +14,10 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): def __init__(self, *args, **kwargs): super(SpotifyPlaybackProvider, self).__init__(*args, **kwargs) - self._play_time_accumulated = 0 - self._play_time_started = 0 + self._timer = TrackPositionTimer() def pause(self): - time_since_started = self._wall_time() - self._play_time_started - self._play_time_accumulated += time_since_started + self._timer.pause() return super(SpotifyPlaybackProvider, self).pause() @@ -27,38 +25,42 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): if track.uri is None: return False - self._play_time_accumulated = 0 - self._play_time_started = self._wall_time() - try: self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) + self.backend.audio.prepare_change() self.backend.audio.set_uri('appsrc://') self.backend.audio.start_playback() self.backend.audio.set_metadata(track) + + self._timer.play() + return True except SpotifyError as e: logger.info('Playback of %s failed: %s', track.uri, e) return False def resume(self): - self._play_time_started = self._wall_time() - return self.seek(self.backend.playback.time_position) + time_position = self.get_time_position() + + self._timer.resume() + + return self.seek(time_position) def seek(self, time_position): - self._play_time_started = self._wall_time() - self._play_time_accumulated = time_position - self.backend.audio.prepare_change() self.backend.spotify.session.seek(time_position) self.backend.audio.start_playback() + self._timer.seek(time_position) + return True def stop(self): self.backend.spotify.session.play(0) + return super(SpotifyPlaybackProvider, self).stop() def get_time_position(self): @@ -66,14 +68,46 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): # when used with the Spotify backend and GStreamer appsrc. If this can # be resolved, we no longer need to use a wall clock based time # position for Spotify playback. - state = self.backend.playback.state - if state == PlaybackState.PLAYING: - time_since_started = (self._wall_time() - - self._play_time_started) - return self._play_time_accumulated + time_since_started - elif state == PlaybackState.PAUSED: - return self._play_time_accumulated - elif state == PlaybackState.STOPPED: + return self._timer.get_time_position() + + +class TrackPositionTimer(object): + """ + Keeps track of time position in a track using the wall clock and playback + events. + + To not introduce a reverse dependency on the playback controller, this + class keeps track of playback state itself. + """ + + def __init__(self): + self._state = PlaybackState.STOPPED + self._accumulated = 0 + self._started = 0 + + def play(self): + self._state = PlaybackState.PLAYING + self._accumulated = 0 + self._started = self._wall_time() + + def pause(self): + self._state = PlaybackState.PAUSED + self._accumulated += self._wall_time() - self._started + + def resume(self): + self._state = PlaybackState.PLAYING + + def seek(self, time_position): + self._started = self._wall_time() + self._accumulated = time_position + + def get_time_position(self): + if self._state == PlaybackState.PLAYING: + time_since_started = self._wall_time() - self._started + return self._accumulated + time_since_started + elif self._state == PlaybackState.PAUSED: + return self._accumulated + elif self._state == PlaybackState.STOPPED: return 0 def _wall_time(self): From 061c155f1e4e17621d454754e001755469ed46ad Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Sep 2012 02:03:48 +0200 Subject: [PATCH 051/233] Remove reverse dependency on the library controller --- mopidy/backends/local/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 363c1b36..73e10918 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -35,9 +35,9 @@ class LocalBackend(ThreadingActor, base.Backend): self.current_playlist = core.CurrentPlaylistController(backend=self) - library_provider = LocalLibraryProvider(backend=self) + self.library_provider = LocalLibraryProvider(backend=self) self.library = core.LibraryController(backend=self, - provider=library_provider) + provider=self.library_provider) playback_provider = base.BasePlaybackProvider(backend=self) self.playback = core.PlaybackController(backend=self, @@ -69,7 +69,7 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): tracks = [] for uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): try: - tracks.append(self.backend.library.lookup(uri)) + tracks.append(self.backend.library_provider.lookup(uri)) except LookupError, e: logger.error('Playlist item could not be added: %s', e) playlist = Playlist(tracks=tracks, name=name) From 5dd67fa7a762e7be2ab351d4185578336d94e44c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Sep 2012 02:10:03 +0200 Subject: [PATCH 052/233] Remove reverse dependency on the stored playlists controller --- mopidy/backends/spotify/__init__.py | 4 ++-- mopidy/backends/spotify/library.py | 2 +- mopidy/backends/spotify/session_manager.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index d41c70b4..4320d723 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -58,10 +58,10 @@ class SpotifyBackend(ThreadingActor, base.Backend): self.playback = core.PlaybackController(backend=self, provider=playback_provider) - stored_playlists_provider = SpotifyStoredPlaylistsProvider( + self.stored_playlists_provider = SpotifyStoredPlaylistsProvider( backend=self) self.stored_playlists = core.StoredPlaylistsController(backend=self, - provider=stored_playlists_provider) + provider=self.stored_playlists_provider) self.uri_schemes = [u'spotify'] diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 18276ecd..3931aece 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -66,7 +66,7 @@ class SpotifyLibraryProvider(BaseLibraryProvider): # Since we can't search for the entire Spotify library, we return # all tracks in the stored playlists when the query is empty. tracks = [] - for playlist in self.backend.stored_playlists.playlists: + for playlist in self.backend.stored_playlists_provider.playlists: tracks += playlist.tracks return Playlist(tracks=tracks) spotify_query = [] diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 9fb6adcb..577d48c9 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -139,7 +139,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): playlists = map(SpotifyTranslator.to_mopidy_playlist, self.session.playlist_container()) playlists = filter(None, playlists) - self.backend.stored_playlists.playlists = playlists + self.backend.stored_playlists_provider.playlists = playlists logger.info(u'Loaded %d Spotify playlist(s)', len(playlists)) def search(self, query, queue): From c5ef8431c3590cdb296160c6f2a44c22705a77de Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Sep 2012 20:09:31 +0200 Subject: [PATCH 053/233] Remove unused imports --- mopidy/backends/spotify/session_manager.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 577d48c9..a6389048 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -4,8 +4,7 @@ import threading from spotify.manager import SpotifySessionManager as PyspotifySessionManager -from mopidy import audio, get_version, settings -from mopidy.backends.base import Backend +from mopidy import get_version, settings from mopidy.backends.spotify import BITRATES from mopidy.backends.spotify.container_manager import SpotifyContainerManager from mopidy.backends.spotify.playlist_manager import SpotifyPlaylistManager From 2fdeec9f5af23315a9695568abce6ec24756c09c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 26 Sep 2012 14:54:44 +0200 Subject: [PATCH 054/233] Move controllers to a new core actor The frontends use the new core actor, while the core actor uses the backend. This is a step towards supporting multiple backends, where the core actor will coordinate the backends. --- mopidy/__main__.py | 15 +- mopidy/audio/__init__.py | 19 +- mopidy/backends/base/__init__.py | 18 +- mopidy/backends/dummy/__init__.py | 19 +- mopidy/backends/local/__init__.py | 20 +- mopidy/backends/spotify/__init__.py | 19 +- mopidy/backends/spotify/library.py | 2 +- mopidy/backends/spotify/session_manager.py | 2 +- mopidy/core/__init__.py | 1 + mopidy/core/actor.py | 42 ++ mopidy/core/current_playlist.py | 6 +- mopidy/core/library.py | 12 +- mopidy/core/playback.py | 43 +- mopidy/core/stored_playlists.py | 20 +- mopidy/frontends/mpd/dispatcher.py | 20 +- mopidy/frontends/mpris/objects.py | 18 +- tests/backends/base/__init__.py | 2 +- tests/backends/base/current_playlist.py | 16 +- tests/backends/base/library.py | 13 +- tests/backends/base/playback.py | 55 +- tests/backends/base/stored_playlists.py | 10 +- tests/backends/events_test.py | 37 +- tests/backends/local/playback_test.py | 7 +- tests/backends/local/stored_playlists_test.py | 3 +- tests/frontends/mpd/dispatcher_test.py | 10 +- tests/frontends/mpd/protocol/__init__.py | 11 +- .../mpd/protocol/current_playlist_test.py | 166 ++--- tests/frontends/mpd/protocol/playback_test.py | 230 +++---- .../frontends/mpd/protocol/regression_test.py | 28 +- tests/frontends/mpd/protocol/status_test.py | 4 +- .../mpd/protocol/stored_playlists_test.py | 16 +- tests/frontends/mpd/status_test.py | 58 +- .../frontends/mpris/player_interface_test.py | 621 +++++++++--------- tests/frontends/mpris/root_interface_test.py | 11 +- 34 files changed, 815 insertions(+), 759 deletions(-) create mode 100644 mopidy/core/actor.py diff --git a/mopidy/__main__.py b/mopidy/__main__.py index c82510d9..ee2e21b6 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -31,6 +31,7 @@ sys.path.insert(0, from mopidy import (get_version, settings, OptionalDependencyError, SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE) from mopidy.audio import Audio +from mopidy.core import Core from mopidy.utils import get_class from mopidy.utils.deps import list_deps_optparse_callback from mopidy.utils.log import setup_logging @@ -52,7 +53,8 @@ def main(): check_old_folders() setup_settings(options.interactive) audio = setup_audio() - setup_backend(audio) + backend = setup_backend(audio) + setup_core(audio, backend) setup_frontends() loop.run() except SettingsError as e: @@ -64,6 +66,7 @@ def main(): finally: loop.quit() stop_frontends() + stop_core() stop_backend() stop_audio() stop_remaining_actors() @@ -126,13 +129,21 @@ def stop_audio(): def setup_backend(audio): - get_class(settings.BACKENDS[0]).start(audio=audio) + return get_class(settings.BACKENDS[0]).start(audio=audio).proxy() def stop_backend(): stop_actors_by_class(get_class(settings.BACKENDS[0])) +def setup_core(audio, backend): + return Core.start(audio, backend).proxy() + + +def stop_core(): + stop_actors_by_class(Core) + + def setup_frontends(): for frontend_class_name in settings.FRONTENDS: try: diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index df5efb92..3ce459dd 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -8,8 +8,7 @@ import logging from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry -from mopidy import settings, utils -from mopidy.backends.base import Backend +from mopidy import core, settings, utils from mopidy.utils import process # Trigger install of gst mixer plugins @@ -150,7 +149,7 @@ class Audio(ThreadingActor): def _on_message(self, bus, message): if message.type == gst.MESSAGE_EOS: - self._notify_backend_of_eos() + self._notify_core_of_eos() elif message.type == gst.MESSAGE_ERROR: error, debug = message.parse_error() logger.error(u'%s %s', error, debug) @@ -159,14 +158,14 @@ class Audio(ThreadingActor): error, debug = message.parse_warning() logger.warning(u'%s %s', error, debug) - def _notify_backend_of_eos(self): - backend_refs = ActorRegistry.get_by_class(Backend) - assert len(backend_refs) <= 1, 'Expected at most one running backend.' - if backend_refs: - logger.debug(u'Notifying backend of end-of-stream.') - backend_refs[0].proxy().playback.on_end_of_track() + def _notify_core_of_eos(self): + core_refs = ActorRegistry.get_by_class(core.Core) + assert len(core_refs) <= 1, 'Expected at most one running core instance' + if core_refs: + logger.debug(u'Notifying core of end-of-stream') + core_refs[0].proxy().playback.on_end_of_track() else: - logger.debug(u'No backend to notify of end-of-stream found.') + logger.debug(u'No core instance to notify of end-of-stream found') def set_uri(self, uri): """ diff --git a/mopidy/backends/base/__init__.py b/mopidy/backends/base/__init__.py index 67a3c5ba..4e0f0b08 100644 --- a/mopidy/backends/base/__init__.py +++ b/mopidy/backends/base/__init__.py @@ -10,24 +10,20 @@ class Backend(object): #: which will then set this field. audio = None - #: The current playlist controller. An instance of - #: :class:`mopidy.backends.base.CurrentPlaylistController`. - current_playlist = None - - #: The library controller. An instance of - # :class:`mopidy.backends.base.LibraryController`. + #: The library provider. An instance of + # :class:`mopidy.backends.base.BaseLibraryProvider`. library = None - #: The playback controller. An instance of - #: :class:`mopidy.backends.base.PlaybackController`. + #: The playback provider. An instance of + #: :class:`mopidy.backends.base.BasePlaybackProvider`. playback = None - #: The stored playlists controller. An instance of - #: :class:`mopidy.backends.base.StoredPlaylistsController`. + #: The stored playlists provider. An instance of + #: :class:`mopidy.backends.base.BaseStoredPlaylistsProvider`. stored_playlists = None #: List of URI schemes this backend can handle. uri_schemes = [] - def __init__(self, audio=None): + def __init__(self, audio): self.audio = audio diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy/__init__.py index 9c4a0e69..1d69ed7c 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy/__init__.py @@ -1,6 +1,5 @@ from pykka.actor import ThreadingActor -from mopidy import core from mopidy.backends import base from mopidy.models import Playlist @@ -14,21 +13,11 @@ class DummyBackend(ThreadingActor, base.Backend): """ def __init__(self, *args, **kwargs): - super(DummyBackend, self).__init__(*args, **kwargs) + base.Backend.__init__(self, *args, **kwargs) - self.current_playlist = core.CurrentPlaylistController(backend=self) - - library_provider = DummyLibraryProvider(backend=self) - self.library = core.LibraryController(backend=self, - provider=library_provider) - - playback_provider = DummyPlaybackProvider(backend=self) - self.playback = core.PlaybackController(backend=self, - provider=playback_provider) - - stored_playlists_provider = DummyStoredPlaylistsProvider(backend=self) - self.stored_playlists = core.StoredPlaylistsController(backend=self, - provider=stored_playlists_provider) + self.library = DummyLibraryProvider(backend=self) + self.playback = DummyPlaybackProvider(backend=self) + self.stored_playlists = DummyStoredPlaylistsProvider(backend=self) self.uri_schemes = [u'dummy'] diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 73e10918..f3e86679 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -6,7 +6,7 @@ import shutil from pykka.actor import ThreadingActor -from mopidy import audio, core, settings +from mopidy import settings from mopidy.backends import base from mopidy.models import Playlist, Track, Album @@ -33,19 +33,9 @@ class LocalBackend(ThreadingActor, base.Backend): def __init__(self, *args, **kwargs): base.Backend.__init__(self, *args, **kwargs) - self.current_playlist = core.CurrentPlaylistController(backend=self) - - self.library_provider = LocalLibraryProvider(backend=self) - self.library = core.LibraryController(backend=self, - provider=self.library_provider) - - playback_provider = base.BasePlaybackProvider(backend=self) - self.playback = core.PlaybackController(backend=self, - provider=playback_provider) - - stored_playlists_provider = LocalStoredPlaylistsProvider(backend=self) - self.stored_playlists = core.StoredPlaylistsController(backend=self, - provider=stored_playlists_provider) + self.library = LocalLibraryProvider(backend=self) + self.playback = base.BasePlaybackProvider(backend=self) + self.stored_playlists = LocalStoredPlaylistsProvider(backend=self) self.uri_schemes = [u'file'] @@ -69,7 +59,7 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): tracks = [] for uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): try: - tracks.append(self.backend.library_provider.lookup(uri)) + tracks.append(self.backend.library.lookup(uri)) except LookupError, e: logger.error('Playlist item could not be added: %s', e) playlist = Playlist(tracks=tracks, name=name) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 4320d723..a79168f5 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -2,7 +2,7 @@ import logging from pykka.actor import ThreadingActor -from mopidy import audio, core, settings +from mopidy import settings from mopidy.backends import base logger = logging.getLogger('mopidy.backends.spotify') @@ -48,20 +48,9 @@ class SpotifyBackend(ThreadingActor, base.Backend): base.Backend.__init__(self, *args, **kwargs) - self.current_playlist = core.CurrentPlaylistController(backend=self) - - library_provider = SpotifyLibraryProvider(backend=self) - self.library = core.LibraryController(backend=self, - provider=library_provider) - - playback_provider = SpotifyPlaybackProvider(backend=self) - self.playback = core.PlaybackController(backend=self, - provider=playback_provider) - - self.stored_playlists_provider = SpotifyStoredPlaylistsProvider( - backend=self) - self.stored_playlists = core.StoredPlaylistsController(backend=self, - provider=self.stored_playlists_provider) + self.library = SpotifyLibraryProvider(backend=self) + self.playback = SpotifyPlaybackProvider(backend=self) + self.stored_playlists = SpotifyStoredPlaylistsProvider(backend=self) self.uri_schemes = [u'spotify'] diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 3931aece..18276ecd 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -66,7 +66,7 @@ class SpotifyLibraryProvider(BaseLibraryProvider): # Since we can't search for the entire Spotify library, we return # all tracks in the stored playlists when the query is empty. tracks = [] - for playlist in self.backend.stored_playlists_provider.playlists: + for playlist in self.backend.stored_playlists.playlists: tracks += playlist.tracks return Playlist(tracks=tracks) spotify_query = [] diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index a6389048..52769d84 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -138,7 +138,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): playlists = map(SpotifyTranslator.to_mopidy_playlist, self.session.playlist_container()) playlists = filter(None, playlists) - self.backend.stored_playlists_provider.playlists = playlists + self.backend.stored_playlists.playlists = playlists logger.info(u'Loaded %d Spotify playlist(s)', len(playlists)) def search(self, query, queue): diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py index 87df96c9..6070dcc8 100644 --- a/mopidy/core/__init__.py +++ b/mopidy/core/__init__.py @@ -1,3 +1,4 @@ +from .actor import Core from .current_playlist import CurrentPlaylistController from .library import LibraryController from .playback import PlaybackController, PlaybackState diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py new file mode 100644 index 00000000..4ff378c4 --- /dev/null +++ b/mopidy/core/actor.py @@ -0,0 +1,42 @@ +from pykka.actor import ThreadingActor + +from .current_playlist import CurrentPlaylistController +from .library import LibraryController +from .playback import PlaybackController +from .stored_playlists import StoredPlaylistsController + + +class Core(ThreadingActor): + #: The current playlist controller. An instance of + #: :class:`mopidy.core.CurrentPlaylistController`. + current_playlist = None + + #: The library controller. An instance of + # :class:`mopidy.core.LibraryController`. + library = None + + #: The playback controller. An instance of + #: :class:`mopidy.core.PlaybackController`. + playback = None + + #: The stored playlists controller. An instance of + #: :class:`mopidy.core.StoredPlaylistsController`. + stored_playlists = None + + def __init__(self, audio=None, backend=None): + self._backend = backend + + self.current_playlist = CurrentPlaylistController(core=self) + + self.library = LibraryController(backend=backend, core=self) + + self.playback = PlaybackController( + audio=audio, backend=backend, core=self) + + self.stored_playlists = StoredPlaylistsController( + backend=backend, core=self) + + @property + def uri_schemes(self): + """List of URI schemes we can handle""" + return self._backend.uri_schemes.get() diff --git a/mopidy/core/current_playlist.py b/mopidy/core/current_playlist.py index af06e05e..a39b4c39 100644 --- a/mopidy/core/current_playlist.py +++ b/mopidy/core/current_playlist.py @@ -17,8 +17,8 @@ class CurrentPlaylistController(object): pykka_traversable = True - def __init__(self, backend): - self.backend = backend + def __init__(self, core): + self.core = core self.cp_id = 0 self._cp_tracks = [] self._version = 0 @@ -59,7 +59,7 @@ class CurrentPlaylistController(object): @version.setter def version(self, version): self._version = version - self.backend.playback.on_current_playlist_change() + self.core.playback.on_current_playlist_change() self._trigger_playlist_changed() def add(self, track, at_position=None, increase_version=True): diff --git a/mopidy/core/library.py b/mopidy/core/library.py index fc55aaeb..52f85b55 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -8,9 +8,9 @@ class LibraryController(object): pykka_traversable = True - def __init__(self, backend, provider): + def __init__(self, backend, core): self.backend = backend - self.provider = provider + self.core = core def find_exact(self, **query): """ @@ -29,7 +29,7 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - return self.provider.find_exact(**query) + return self.backend.library.find_exact(**query).get() def lookup(self, uri): """ @@ -39,7 +39,7 @@ class LibraryController(object): :type uri: string :rtype: :class:`mopidy.models.Track` or :class:`None` """ - return self.provider.lookup(uri) + return self.backend.library.lookup(uri).get() def refresh(self, uri=None): """ @@ -48,7 +48,7 @@ class LibraryController(object): :param uri: directory or track URI :type uri: string """ - return self.provider.refresh(uri) + return self.backend.library.refresh(uri).get() def search(self, **query): """ @@ -67,4 +67,4 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - return self.provider.search(**query) + return self.backend.library.search(**query).get() diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 82a11064..efba03dd 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -79,9 +79,10 @@ class PlaybackController(object): #: Playback continues after current song. single = option_wrapper('_single', False) - def __init__(self, backend, provider): + def __init__(self, audio, backend, core): + self.audio = audio self.backend = backend - self.provider = provider + self.core = core self._state = PlaybackState.STOPPED self._shuffled = [] self._first_shuffle = True @@ -125,7 +126,7 @@ class PlaybackController(object): if self.current_cp_track is None: return None try: - return self.backend.current_playlist.cp_tracks.index( + return self.core.current_playlist.cp_tracks.index( self.current_cp_track) except ValueError: return None @@ -152,7 +153,7 @@ class PlaybackController(object): # pylint: disable = R0911 # Too many return statements - cp_tracks = self.backend.current_playlist.cp_tracks + cp_tracks = self.core.current_playlist.cp_tracks if not cp_tracks: return None @@ -204,7 +205,7 @@ class PlaybackController(object): enabled this should be a random track, all tracks should be played once before the list repeats. """ - cp_tracks = self.backend.current_playlist.cp_tracks + cp_tracks = self.core.current_playlist.cp_tracks if not cp_tracks: return None @@ -258,7 +259,7 @@ class PlaybackController(object): if self.current_playlist_position in (None, 0): return None - return self.backend.current_playlist.cp_tracks[ + return self.core.current_playlist.cp_tracks[ self.current_playlist_position - 1] @property @@ -291,15 +292,16 @@ class PlaybackController(object): @property def time_position(self): """Time position in milliseconds.""" - return self.provider.get_time_position() + return self.backend.playback.get_time_position().get() @property def volume(self): - return self.provider.get_volume() + """Volume as int in range [0..100].""" + return self.backend.playback.get_volume().get() @volume.setter def volume(self, volume): - self.provider.set_volume(volume) + self.backend.playback.set_volume(volume).get() def change_track(self, cp_track, on_error_step=1): """ @@ -337,20 +339,20 @@ class PlaybackController(object): self.stop(clear_current_track=True) if self.consume: - self.backend.current_playlist.remove(cpid=original_cp_track.cpid) + self.core.current_playlist.remove(cpid=original_cp_track.cpid) def on_current_playlist_change(self): """ Tell the playback controller that the current playlist has changed. - Used by :class:`mopidy.backends.base.CurrentPlaylistController`. + Used by :class:`mopidy.core.CurrentPlaylistController`. """ self._first_shuffle = True self._shuffled = [] - if (not self.backend.current_playlist.cp_tracks or + if (not self.core.current_playlist.cp_tracks or self.current_cp_track not in - self.backend.current_playlist.cp_tracks): + self.core.current_playlist.cp_tracks): self.stop(clear_current_track=True) def next(self): @@ -368,7 +370,7 @@ class PlaybackController(object): def pause(self): """Pause playback.""" - if self.provider.pause(): + if self.backend.playback.pause().get(): self.state = PlaybackState.PAUSED self._trigger_track_playback_paused() @@ -386,7 +388,7 @@ class PlaybackController(object): """ if cp_track is not None: - assert cp_track in self.backend.current_playlist.cp_tracks + assert cp_track in self.core.current_playlist.cp_tracks elif cp_track is None: if self.state == PlaybackState.PAUSED: return self.resume() @@ -400,7 +402,7 @@ class PlaybackController(object): if cp_track is not None: self.current_cp_track = cp_track self.state = PlaybackState.PLAYING - if not self.provider.play(cp_track.track): + if not self.backend.playback.play(cp_track.track).get(): # Track is not playable if self.random and self._shuffled: self._shuffled.remove(cp_track) @@ -426,7 +428,8 @@ class PlaybackController(object): def resume(self): """If paused, resume playing the current track.""" - if self.state == PlaybackState.PAUSED and self.provider.resume(): + if (self.state == PlaybackState.PAUSED and + self.backend.playback.resume().get()): self.state = PlaybackState.PLAYING self._trigger_track_playback_resumed() @@ -438,7 +441,7 @@ class PlaybackController(object): :type time_position: int :rtype: :class:`True` if successful, else :class:`False` """ - if not self.backend.current_playlist.tracks: + if not self.core.current_playlist.tracks: return False if self.state == PlaybackState.STOPPED: @@ -452,7 +455,7 @@ class PlaybackController(object): self.next() return True - success = self.provider.seek(time_position) + success = self.backend.playback.seek(time_position).get() if success: self._trigger_seeked(time_position) return success @@ -466,7 +469,7 @@ class PlaybackController(object): :type clear_current_track: boolean """ if self.state != PlaybackState.STOPPED: - if self.provider.stop(): + if self.backend.playback.stop().get(): self._trigger_track_playback_ended() self.state = PlaybackState.STOPPED if clear_current_track: diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index a29e34fc..6ea9b1d3 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -8,9 +8,9 @@ class StoredPlaylistsController(object): pykka_traversable = True - def __init__(self, backend, provider): + def __init__(self, backend, core): self.backend = backend - self.provider = provider + self.core = core @property def playlists(self): @@ -19,11 +19,11 @@ class StoredPlaylistsController(object): Read/write. List of :class:`mopidy.models.Playlist`. """ - return self.provider.playlists + return self.backend.stored_playlists.playlists.get() @playlists.setter def playlists(self, playlists): - self.provider.playlists = playlists + self.backend.stored_playlists.playlists = playlists def create(self, name): """ @@ -33,7 +33,7 @@ class StoredPlaylistsController(object): :type name: string :rtype: :class:`mopidy.models.Playlist` """ - return self.provider.create(name) + return self.backend.stored_playlists.create(name).get() def delete(self, playlist): """ @@ -42,7 +42,7 @@ class StoredPlaylistsController(object): :param playlist: the playlist to delete :type playlist: :class:`mopidy.models.Playlist` """ - return self.provider.delete(playlist) + return self.backend.stored_playlists.delete(playlist).get() def get(self, **criteria): """ @@ -83,14 +83,14 @@ class StoredPlaylistsController(object): :type uri: string :rtype: :class:`mopidy.models.Playlist` """ - return self.provider.lookup(uri) + return self.backend.stored_playlists.lookup(uri).get() def refresh(self): """ Refresh the stored playlists in :attr:`mopidy.backends.base.StoredPlaylistsController.playlists`. """ - return self.provider.refresh() + return self.backend.stored_playlists.refresh().get() def rename(self, playlist, new_name): """ @@ -101,7 +101,7 @@ class StoredPlaylistsController(object): :param new_name: the new name :type new_name: string """ - return self.provider.rename(playlist, new_name) + return self.backend.stored_playlists.rename(playlist, new_name).get() def save(self, playlist): """ @@ -110,4 +110,4 @@ class StoredPlaylistsController(object): :param playlist: the playlist :type playlist: :class:`mopidy.models.Playlist` """ - return self.provider.save(playlist) + return self.backend.stored_playlists.save(playlist).get() diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 94ac6bf9..c9dee576 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -4,8 +4,7 @@ import re from pykka import ActorDeadError from pykka.registry import ActorRegistry -from mopidy import settings -from mopidy.backends.base import Backend +from mopidy import core, settings from mopidy.frontends.mpd import exceptions from mopidy.frontends.mpd.protocol import mpd_commands, request_handlers # Do not remove the following import. The protocol modules must be imported to @@ -233,16 +232,17 @@ class MpdContext(object): self.session = session self.events = set() self.subscriptions = set() - self._backend = None + self._core = None @property def backend(self): """ - The backend. An instance of :class:`mopidy.backends.base.Backend`. + The Mopidy core. An instance of :class:`mopidy.core.Core`. """ - if self._backend is None: - backend_refs = ActorRegistry.get_by_class(Backend) - assert len(backend_refs) == 1, \ - 'Expected exactly one running backend.' - self._backend = backend_refs[0].proxy() - return self._backend + # TODO: Rename property to 'core' + if self._core is None: + core_refs = ActorRegistry.get_by_class(core.Core) + assert len(core_refs) == 1, \ + 'Expected exactly one running core instance.' + self._core = core_refs[0].proxy() + return self._core diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 93669977..c2c9f527 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -14,8 +14,7 @@ except ImportError as import_error: from pykka.registry import ActorRegistry -from mopidy import settings -from mopidy.backends.base import Backend +from mopidy import core, settings from mopidy.core import PlaybackState from mopidy.utils.process import exit_process @@ -35,7 +34,7 @@ class MprisObject(dbus.service.Object): properties = None def __init__(self): - self._backend = None + self._core = None self.properties = { ROOT_IFACE: self._get_root_iface_properties(), PLAYER_IFACE: self._get_player_iface_properties(), @@ -86,12 +85,13 @@ class MprisObject(dbus.service.Object): @property def backend(self): - if self._backend is None: - backend_refs = ActorRegistry.get_by_class(Backend) - assert len(backend_refs) == 1, \ - 'Expected exactly one running backend.' - self._backend = backend_refs[0].proxy() - return self._backend + # TODO: Rename property to 'core' + if self._core is None: + core_refs = ActorRegistry.get_by_class(core.Core) + assert len(core_refs) == 1, \ + 'Expected exactly one running core instance.' + self._core = core_refs[0].proxy() + return self._core def _get_track_id(self, cp_track): return '/com/mopidy/track/%d' % cp_track.cpid diff --git a/tests/backends/base/__init__.py b/tests/backends/base/__init__.py index 29f010e1..84eee193 100644 --- a/tests/backends/base/__init__.py +++ b/tests/backends/base/__init__.py @@ -1,7 +1,7 @@ def populate_playlist(func): def wrapper(self): for track in self.tracks: - self.backend.current_playlist.add(track) + self.core.current_playlist.add(track) return func(self) wrapper.__name__ = func.__name__ diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index a42e7eac..db4473bb 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -1,7 +1,9 @@ import mock import random -from mopidy import audio +from pykka.registry import ActorRegistry + +from mopidy import audio, core from mopidy.core import PlaybackState from mopidy.models import CpTrack, Playlist, Track @@ -12,13 +14,17 @@ class CurrentPlaylistControllerTest(object): tracks = [] def setUp(self): - self.backend = self.backend_class() - self.backend.audio = mock.Mock(spec=audio.Audio) - self.controller = self.backend.current_playlist - self.playback = self.backend.playback + self.audio = mock.Mock(spec=audio.Audio) + self.backend = self.backend_class.start(audio=self.audio).proxy() + self.core = core.Core(audio=audio, backend=self.backend) + self.controller = self.core.current_playlist + self.playback = self.core.playback assert len(self.tracks) == 3, 'Need three tracks to run tests.' + def tearDown(self): + ActorRegistry.stop_all() + def test_length(self): self.assertEqual(0, len(self.controller.cp_tracks)) self.assertEqual(0, self.controller.length) diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index f76d9d75..99dce78e 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -1,3 +1,8 @@ +import mock + +from pykka.registry import ActorRegistry + +from mopidy import core from mopidy.models import Playlist, Track, Album, Artist from tests import unittest, path_to_data_dir @@ -15,8 +20,12 @@ class LibraryControllerTest(object): Track()] def setUp(self): - self.backend = self.backend_class() - self.library = self.backend.library + self.backend = self.backend_class.start(audio=None).proxy() + self.core = core.Core(backend=self.backend) + self.library = self.core.library + + def tearDown(self): + ActorRegistry.stop_all() def test_refresh(self): self.library.refresh() diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index e052a907..46863f03 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -2,7 +2,7 @@ import mock import random import time -from mopidy import audio +from mopidy import audio, core from mopidy.core import PlaybackState from mopidy.models import Track @@ -16,10 +16,11 @@ class PlaybackControllerTest(object): tracks = [] def setUp(self): - self.backend = self.backend_class() - self.backend.audio = mock.Mock(spec=audio.Audio) - self.playback = self.backend.playback - self.current_playlist = self.backend.current_playlist + self.audio = mock.Mock(spec=audio.Audio) + self.backend = self.backend_class.start(audio=self.audio).proxy() + self.core = core.Core(backend=self.backend) + self.playback = self.core.playback + self.current_playlist = self.core.current_playlist assert len(self.tracks) >= 3, \ 'Need at least three tracks to run tests.' @@ -97,8 +98,8 @@ class PlaybackControllerTest(object): @populate_playlist def test_play_skips_to_next_track_on_failure(self): - # If provider.play() returns False, it is a failure. - self.playback.provider.play = lambda track: track != self.tracks[0] + # If backend's play() returns False, it is a failure. + self.backend.playback.play = lambda track: track != self.tracks[0] self.playback.play() self.assertNotEqual(self.playback.current_track, self.tracks[0]) self.assertEqual(self.playback.current_track, self.tracks[1]) @@ -157,8 +158,8 @@ class PlaybackControllerTest(object): @populate_playlist def test_previous_skips_to_previous_track_on_failure(self): - # If provider.play() returns False, it is a failure. - self.playback.provider.play = lambda track: track != self.tracks[1] + # If backend's play() returns False, it is a failure. + self.backend.playback.play = lambda track: track != self.tracks[1] self.playback.play(self.current_playlist.cp_tracks[2]) self.assertEqual(self.playback.current_track, self.tracks[2]) self.playback.previous() @@ -221,8 +222,8 @@ class PlaybackControllerTest(object): @populate_playlist def test_next_skips_to_next_track_on_failure(self): - # If provider.play() returns False, it is a failure. - self.playback.provider.play = lambda track: track != self.tracks[1] + # If backend's play() returns False, it is a failure. + self.backend.playback.play = lambda track: track != self.tracks[1] self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) self.playback.next() @@ -274,7 +275,7 @@ class PlaybackControllerTest(object): self.playback.consume = True self.playback.play() self.playback.next() - self.assertIn(self.tracks[0], self.backend.current_playlist.tracks) + self.assertIn(self.tracks[0], self.current_playlist.tracks) @populate_playlist def test_next_with_single_and_repeat(self): @@ -298,7 +299,7 @@ class PlaybackControllerTest(object): random.seed(1) self.playback.random = True self.assertEqual(self.playback.track_at_next, self.tracks[2]) - self.backend.current_playlist.append(self.tracks[:1]) + self.current_playlist.append(self.tracks[:1]) self.assertEqual(self.playback.track_at_next, self.tracks[1]) @populate_playlist @@ -357,8 +358,8 @@ class PlaybackControllerTest(object): @populate_playlist def test_end_of_track_skips_to_next_track_on_failure(self): - # If provider.play() returns False, it is a failure. - self.playback.provider.play = lambda track: track != self.tracks[1] + # If backend's play() returns False, it is a failure. + self.backend.playback.play = lambda track: track != self.tracks[1] self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) self.playback.on_end_of_track() @@ -411,7 +412,7 @@ class PlaybackControllerTest(object): self.playback.consume = True self.playback.play() self.playback.on_end_of_track() - self.assertNotIn(self.tracks[0], self.backend.current_playlist.tracks) + self.assertNotIn(self.tracks[0], self.current_playlist.tracks) @populate_playlist def test_end_of_track_with_random(self): @@ -427,7 +428,7 @@ class PlaybackControllerTest(object): random.seed(1) self.playback.random = True self.assertEqual(self.playback.track_at_next, self.tracks[2]) - self.backend.current_playlist.append(self.tracks[:1]) + self.current_playlist.append(self.tracks[:1]) self.assertEqual(self.playback.track_at_next, self.tracks[1]) @populate_playlist @@ -517,7 +518,7 @@ class PlaybackControllerTest(object): wrapper.called = False self.playback.on_current_playlist_change = wrapper - self.backend.current_playlist.append([Track()]) + self.current_playlist.append([Track()]) self.assert_(wrapper.called) @@ -534,13 +535,13 @@ class PlaybackControllerTest(object): def test_on_current_playlist_change_when_playing(self): self.playback.play() current_track = self.playback.current_track - self.backend.current_playlist.append([self.tracks[2]]) + self.current_playlist.append([self.tracks[2]]) self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, current_track) @populate_playlist def test_on_current_playlist_change_when_stopped(self): - self.backend.current_playlist.append([self.tracks[2]]) + self.current_playlist.append([self.tracks[2]]) self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) @@ -549,7 +550,7 @@ class PlaybackControllerTest(object): self.playback.play() self.playback.pause() current_track = self.playback.current_track - self.backend.current_playlist.append([self.tracks[2]]) + self.current_playlist.append([self.tracks[2]]) self.assertEqual(self.playback.state, PlaybackState.PAUSED) self.assertEqual(self.playback.current_track, current_track) @@ -640,7 +641,7 @@ class PlaybackControllerTest(object): @populate_playlist def test_seek_when_playing_updates_position(self): - length = self.backend.current_playlist.tracks[0].length + length = self.current_playlist.tracks[0].length self.playback.play() self.playback.seek(length - 1000) position = self.playback.time_position @@ -655,7 +656,7 @@ class PlaybackControllerTest(object): @populate_playlist def test_seek_when_paused_updates_position(self): - length = self.backend.current_playlist.tracks[0].length + length = self.current_playlist.tracks[0].length self.playback.play() self.playback.pause() self.playback.seek(length - 1000) @@ -730,7 +731,7 @@ class PlaybackControllerTest(object): def test_time_position_when_stopped(self): future = mock.Mock() future.get = mock.Mock(return_value=0) - self.backend.audio.get_position = mock.Mock(return_value=future) + self.audio.get_position = mock.Mock(return_value=future) self.assertEqual(self.playback.time_position, 0) @@ -738,7 +739,7 @@ class PlaybackControllerTest(object): def test_time_position_when_stopped_with_playlist(self): future = mock.Mock() future.get = mock.Mock(return_value=0) - self.backend.audio.get_position = mock.Mock(return_value=future) + self.audio.get_position = mock.Mock(return_value=future) self.assertEqual(self.playback.time_position, 0) @@ -772,9 +773,9 @@ class PlaybackControllerTest(object): def test_playlist_is_empty_after_all_tracks_are_played_with_consume(self): self.playback.consume = True self.playback.play() - for _ in range(len(self.backend.current_playlist.tracks)): + for _ in range(len(self.current_playlist.tracks)): self.playback.on_end_of_track() - self.assertEqual(len(self.backend.current_playlist.tracks), 0) + self.assertEqual(len(self.current_playlist.tracks), 0) @populate_playlist def test_play_with_random(self): diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 1e575b9e..4e65c034 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -2,7 +2,9 @@ import os import shutil import tempfile -from mopidy import settings +import mock + +from mopidy import audio, core, settings from mopidy.models import Playlist from tests import unittest, path_to_data_dir @@ -14,8 +16,10 @@ class StoredPlaylistsControllerTest(object): settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('library_tag_cache') settings.LOCAL_MUSIC_PATH = path_to_data_dir('') - self.backend = self.backend_class() - self.stored = self.backend.stored_playlists + self.audio = mock.Mock(spec=audio.Audio) + self.backend = self.backend_class.start(audio=self.audio).proxy() + self.core = core.Core(backend=self.backend) + self.stored = self.core.stored_playlists def tearDown(self): if os.path.exists(settings.LOCAL_PLAYLIST_PATH): diff --git a/tests/backends/events_test.py b/tests/backends/events_test.py index d761676d..5408d71f 100644 --- a/tests/backends/events_test.py +++ b/tests/backends/events_test.py @@ -2,7 +2,8 @@ import mock from pykka.registry import ActorRegistry -from mopidy.backends.dummy import DummyBackend +from mopidy import audio, core +from mopidy.backends import dummy from mopidy.listeners import BackendListener from mopidy.models import Track @@ -12,42 +13,44 @@ from tests import unittest @mock.patch.object(BackendListener, 'send') class BackendEventsTest(unittest.TestCase): def setUp(self): - self.backend = DummyBackend.start().proxy() + self.audio = mock.Mock(spec=audio.Audio) + self.backend = dummy.DummyBackend.start(audio=audio).proxy() + self.core = core.Core.start(backend=self.backend).proxy() def tearDown(self): ActorRegistry.stop_all() def test_pause_sends_track_playback_paused_event(self, send): - self.backend.current_playlist.add(Track(uri='a')) - self.backend.playback.play().get() + self.core.current_playlist.add(Track(uri='a')) + self.core.playback.play().get() send.reset_mock() - self.backend.playback.pause().get() + self.core.playback.pause().get() self.assertEqual(send.call_args[0][0], 'track_playback_paused') def test_resume_sends_track_playback_resumed(self, send): - self.backend.current_playlist.add(Track(uri='a')) - self.backend.playback.play() - self.backend.playback.pause().get() + self.core.current_playlist.add(Track(uri='a')) + self.core.playback.play() + self.core.playback.pause().get() send.reset_mock() - self.backend.playback.resume().get() + self.core.playback.resume().get() self.assertEqual(send.call_args[0][0], 'track_playback_resumed') def test_play_sends_track_playback_started_event(self, send): - self.backend.current_playlist.add(Track(uri='a')) + self.core.current_playlist.add(Track(uri='a')) send.reset_mock() - self.backend.playback.play().get() + self.core.playback.play().get() self.assertEqual(send.call_args[0][0], 'track_playback_started') def test_stop_sends_track_playback_ended_event(self, send): - self.backend.current_playlist.add(Track(uri='a')) - self.backend.playback.play().get() + self.core.current_playlist.add(Track(uri='a')) + self.core.playback.play().get() send.reset_mock() - self.backend.playback.stop().get() + self.core.playback.stop().get() self.assertEqual(send.call_args_list[0][0][0], 'track_playback_ended') def test_seek_sends_seeked_event(self, send): - self.backend.current_playlist.add(Track(uri='a', length=40000)) - self.backend.playback.play().get() + self.core.current_playlist.add(Track(uri='a', length=40000)) + self.core.playback.play().get() send.reset_mock() - self.backend.playback.seek(1000).get() + self.core.playback.seek(1000).get() self.assertEqual(send.call_args[0][0], 'seeked') diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index c167fbcc..fe5fee32 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -20,10 +20,7 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): def setUp(self): settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) - super(LocalPlaybackControllerTest, self).setUp() - # Two tests does not work at all when using the fake sink - #self.backend.playback.use_fake_sink() def tearDown(self): super(LocalPlaybackControllerTest, self).tearDown() @@ -32,10 +29,10 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): def add_track(self, path): uri = path_to_uri(path_to_data_dir(path)) track = Track(uri=uri, length=4464) - self.backend.current_playlist.add(track) + self.current_playlist.add(track) def test_uri_scheme(self): - self.assertIn('file', self.backend.uri_schemes) + self.assertIn('file', self.core.uri_schemes) def test_play_mp3(self): self.add_track('blank.mp3') diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index 56be92c4..3f3d9c58 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -65,8 +65,7 @@ class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest, self.stored.save(playlist) - self.backend = self.backend_class() - self.stored = self.backend.stored_playlists + self.backend = self.backend_class.start(audio=self.audio).proxy() self.assert_(self.stored.playlists) self.assertEqual('test', self.stored.playlists[0].name) diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/frontends/mpd/dispatcher_test.py index 9f05d7dd..0bff04e7 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -1,4 +1,7 @@ -from mopidy.backends.dummy import DummyBackend +from pykka.registry import ActorRegistry + +from mopidy import core +from mopidy.backends import dummy from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.frontends.mpd.exceptions import MpdAckError from mopidy.frontends.mpd.protocol import request_handlers, handle_request @@ -8,11 +11,12 @@ from tests import unittest class MpdDispatcherTest(unittest.TestCase): def setUp(self): - self.backend = DummyBackend.start().proxy() + self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.core = core.Core.start(backend=self.backend).proxy() self.dispatcher = MpdDispatcher() def tearDown(self): - self.backend.stop().get() + ActorRegistry.stop_all() def test_register_same_pattern_twice_fails(self): func = lambda: None diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index 3b8fbe33..a2dafb9b 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -1,7 +1,9 @@ import mock -from mopidy import settings -from mopidy.backends import dummy as backend +from pykka.registry import ActorRegistry + +from mopidy import core, settings +from mopidy.backends import dummy from mopidy.frontends import mpd from tests import unittest @@ -21,7 +23,8 @@ class MockConnection(mock.Mock): class BaseTestCase(unittest.TestCase): def setUp(self): - self.backend = backend.DummyBackend.start().proxy() + self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.core = core.Core.start(backend=self.backend).proxy() self.connection = MockConnection() self.session = mpd.MpdSession(self.connection) @@ -29,7 +32,7 @@ class BaseTestCase(unittest.TestCase): self.context = self.dispatcher.context def tearDown(self): - self.backend.stop().get() + ActorRegistry.stop_all() settings.runtime.clear() def sendRequest(self, request): diff --git a/tests/frontends/mpd/protocol/current_playlist_test.py b/tests/frontends/mpd/protocol/current_playlist_test.py index 4aed5de1..63c4a42b 100644 --- a/tests/frontends/mpd/protocol/current_playlist_test.py +++ b/tests/frontends/mpd/protocol/current_playlist_test.py @@ -6,15 +6,15 @@ from tests.frontends.mpd import protocol class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_add(self): needle = Track(uri='dummy://foo') - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(), Track(), needle, Track()] - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'add "dummy://foo"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 6) - self.assertEqual(self.backend.current_playlist.tracks.get()[5], needle) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 6) + self.assertEqual(self.core.current_playlist.tracks.get()[5], needle) self.assertEqualResponse(u'OK') def test_add_with_uri_not_found_in_library_should_ack(self): @@ -29,17 +29,17 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_addid_without_songpos(self): needle = Track(uri='dummy://foo') - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(), Track(), needle, Track()] - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'addid "dummy://foo"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 6) - self.assertEqual(self.backend.current_playlist.tracks.get()[5], needle) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 6) + self.assertEqual(self.core.current_playlist.tracks.get()[5], needle) self.assertInResponse(u'Id: %d' % - self.backend.current_playlist.cp_tracks.get()[5][0]) + self.core.current_playlist.cp_tracks.get()[5][0]) self.assertInResponse(u'OK') def test_addid_with_empty_uri_acks(self): @@ -48,26 +48,26 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_addid_with_songpos(self): needle = Track(uri='dummy://foo') - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(), Track(), needle, Track()] - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'addid "dummy://foo" "3"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 6) - self.assertEqual(self.backend.current_playlist.tracks.get()[3], needle) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 6) + self.assertEqual(self.core.current_playlist.tracks.get()[3], needle) self.assertInResponse(u'Id: %d' % - self.backend.current_playlist.cp_tracks.get()[3][0]) + self.core.current_playlist.cp_tracks.get()[3][0]) self.assertInResponse(u'OK') def test_addid_with_songpos_out_of_bounds_should_ack(self): needle = Track(uri='dummy://foo') - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(), Track(), needle, Track()] - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'addid "dummy://foo" "6"') self.assertEqualResponse(u'ACK [2@0] {addid} Bad song index') @@ -77,85 +77,85 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse(u'ACK [50@0] {addid} No such song') def test_clear(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'clear') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 0) - self.assertEqual(self.backend.playback.current_track.get(), None) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 0) + self.assertEqual(self.core.playback.current_track.get(), None) self.assertInResponse(u'OK') def test_delete_songpos(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'delete "%d"' % - self.backend.current_playlist.cp_tracks.get()[2][0]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 4) + self.core.current_playlist.cp_tracks.get()[2][0]) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 4) self.assertInResponse(u'OK') def test_delete_songpos_out_of_bounds(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'delete "5"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.assertEqualResponse(u'ACK [2@0] {delete} Bad song index') def test_delete_open_range(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'delete "1:"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 1) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 1) self.assertInResponse(u'OK') def test_delete_closed_range(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'delete "1:3"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 3) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 3) self.assertInResponse(u'OK') def test_delete_range_out_of_bounds(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'delete "5:7"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.assertEqualResponse(u'ACK [2@0] {delete} Bad song index') def test_deleteid(self): - self.backend.current_playlist.append([Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) + self.core.current_playlist.append([Track(), Track()]) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) self.sendRequest(u'deleteid "1"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 1) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 1) self.assertInResponse(u'OK') def test_deleteid_does_not_exist(self): - self.backend.current_playlist.append([Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) + self.core.current_playlist.append([Track(), Track()]) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) self.sendRequest(u'deleteid "12345"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) self.assertEqualResponse(u'ACK [50@0] {deleteid} No such song') def test_move_songpos(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest(u'move "1" "0"') - tracks = self.backend.current_playlist.tracks.get() + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[1].name, 'a') self.assertEqual(tracks[2].name, 'c') @@ -165,13 +165,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_move_open_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest(u'move "2:" "0"') - tracks = self.backend.current_playlist.tracks.get() + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'c') self.assertEqual(tracks[1].name, 'd') self.assertEqual(tracks[2].name, 'e') @@ -181,13 +181,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_move_closed_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest(u'move "1:3" "0"') - tracks = self.backend.current_playlist.tracks.get() + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[1].name, 'c') self.assertEqual(tracks[2].name, 'a') @@ -197,13 +197,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_moveid(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest(u'moveid "4" "2"') - tracks = self.backend.current_playlist.tracks.get() + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'b') self.assertEqual(tracks[2].name, 'e') @@ -230,7 +230,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse(u'OK') def test_playlistfind_by_filename_in_current_playlist(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(uri='file:///exists')]) self.sendRequest( u'playlistfind filename "file:///exists"') @@ -240,7 +240,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playlistid_without_songid(self): - self.backend.current_playlist.append([Track(name='a'), Track(name='b')]) + self.core.current_playlist.append([Track(name='a'), Track(name='b')]) self.sendRequest(u'playlistid') self.assertInResponse(u'Title: a') @@ -248,7 +248,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playlistid_with_songid(self): - self.backend.current_playlist.append([Track(name='a'), Track(name='b')]) + self.core.current_playlist.append([Track(name='a'), Track(name='b')]) self.sendRequest(u'playlistid "1"') self.assertNotInResponse(u'Title: a') @@ -258,13 +258,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playlistid_with_not_existing_songid_fails(self): - self.backend.current_playlist.append([Track(name='a'), Track(name='b')]) + self.core.current_playlist.append([Track(name='a'), Track(name='b')]) self.sendRequest(u'playlistid "25"') self.assertEqualResponse(u'ACK [50@0] {playlistid} No such song') def test_playlistinfo_without_songpos_or_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -286,8 +286,8 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_playlistinfo_with_songpos(self): # Make the track's CPID not match the playlist position - self.backend.current_playlist.cp_id = 17 - self.backend.current_playlist.append([ + self.core.current_playlist.cp_id = 17 + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -313,7 +313,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqual(response1, response2) def test_playlistinfo_with_open_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -334,7 +334,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playlistinfo_with_closed_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -365,7 +365,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse(u'ACK [0@0] {} Not implemented') def test_plchanges(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) self.sendRequest(u'plchanges "0"') @@ -375,7 +375,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_plchanges_with_minus_one_returns_entire_playlist(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) self.sendRequest(u'plchanges "-1"') @@ -385,7 +385,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_plchanges_without_quotes_works(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) self.sendRequest(u'plchanges 0') @@ -395,10 +395,10 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_plchangesposid(self): - self.backend.current_playlist.append([Track(), Track(), Track()]) + self.core.current_playlist.append([Track(), Track(), Track()]) self.sendRequest(u'plchangesposid "0"') - cp_tracks = self.backend.current_playlist.cp_tracks.get() + cp_tracks = self.core.current_playlist.cp_tracks.get() self.assertInResponse(u'cpos: 0') self.assertInResponse(u'Id: %d' % cp_tracks[0][0]) self.assertInResponse(u'cpos: 2') @@ -408,26 +408,26 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_shuffle_without_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - version = self.backend.current_playlist.version.get() + version = self.core.current_playlist.version.get() self.sendRequest(u'shuffle') - self.assertLess(version, self.backend.current_playlist.version.get()) + self.assertLess(version, self.core.current_playlist.version.get()) self.assertInResponse(u'OK') def test_shuffle_with_open_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - version = self.backend.current_playlist.version.get() + version = self.core.current_playlist.version.get() self.sendRequest(u'shuffle "4:"') - self.assertLess(version, self.backend.current_playlist.version.get()) - tracks = self.backend.current_playlist.tracks.get() + self.assertLess(version, self.core.current_playlist.version.get()) + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'b') self.assertEqual(tracks[2].name, 'c') @@ -435,15 +435,15 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_shuffle_with_closed_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - version = self.backend.current_playlist.version.get() + version = self.core.current_playlist.version.get() self.sendRequest(u'shuffle "1:3"') - self.assertLess(version, self.backend.current_playlist.version.get()) - tracks = self.backend.current_playlist.tracks.get() + self.assertLess(version, self.core.current_playlist.version.get()) + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[4].name, 'e') @@ -451,13 +451,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_swap(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest(u'swap "1" "4"') - tracks = self.backend.current_playlist.tracks.get() + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'e') self.assertEqual(tracks[2].name, 'c') @@ -467,13 +467,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_swapid(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest(u'swapid "1" "4"') - tracks = self.backend.current_playlist.tracks.get() + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'e') self.assertEqual(tracks[2].name, 'c') diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 112a13ae..2380c7bc 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -13,22 +13,22 @@ STOPPED = PlaybackState.STOPPED class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_consume_off(self): self.sendRequest(u'consume "0"') - self.assertFalse(self.backend.playback.consume.get()) + self.assertFalse(self.core.playback.consume.get()) self.assertInResponse(u'OK') def test_consume_off_without_quotes(self): self.sendRequest(u'consume 0') - self.assertFalse(self.backend.playback.consume.get()) + self.assertFalse(self.core.playback.consume.get()) self.assertInResponse(u'OK') def test_consume_on(self): self.sendRequest(u'consume "1"') - self.assertTrue(self.backend.playback.consume.get()) + self.assertTrue(self.core.playback.consume.get()) self.assertInResponse(u'OK') def test_consume_on_without_quotes(self): self.sendRequest(u'consume 1') - self.assertTrue(self.backend.playback.consume.get()) + self.assertTrue(self.core.playback.consume.get()) self.assertInResponse(u'OK') def test_crossfade(self): @@ -37,97 +37,97 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_random_off(self): self.sendRequest(u'random "0"') - self.assertFalse(self.backend.playback.random.get()) + self.assertFalse(self.core.playback.random.get()) self.assertInResponse(u'OK') def test_random_off_without_quotes(self): self.sendRequest(u'random 0') - self.assertFalse(self.backend.playback.random.get()) + self.assertFalse(self.core.playback.random.get()) self.assertInResponse(u'OK') def test_random_on(self): self.sendRequest(u'random "1"') - self.assertTrue(self.backend.playback.random.get()) + self.assertTrue(self.core.playback.random.get()) self.assertInResponse(u'OK') def test_random_on_without_quotes(self): self.sendRequest(u'random 1') - self.assertTrue(self.backend.playback.random.get()) + self.assertTrue(self.core.playback.random.get()) self.assertInResponse(u'OK') def test_repeat_off(self): self.sendRequest(u'repeat "0"') - self.assertFalse(self.backend.playback.repeat.get()) + self.assertFalse(self.core.playback.repeat.get()) self.assertInResponse(u'OK') def test_repeat_off_without_quotes(self): self.sendRequest(u'repeat 0') - self.assertFalse(self.backend.playback.repeat.get()) + self.assertFalse(self.core.playback.repeat.get()) self.assertInResponse(u'OK') def test_repeat_on(self): self.sendRequest(u'repeat "1"') - self.assertTrue(self.backend.playback.repeat.get()) + self.assertTrue(self.core.playback.repeat.get()) self.assertInResponse(u'OK') def test_repeat_on_without_quotes(self): self.sendRequest(u'repeat 1') - self.assertTrue(self.backend.playback.repeat.get()) + self.assertTrue(self.core.playback.repeat.get()) self.assertInResponse(u'OK') def test_setvol_below_min(self): self.sendRequest(u'setvol "-10"') - self.assertEqual(0, self.backend.playback.volume.get()) + self.assertEqual(0, self.core.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_min(self): self.sendRequest(u'setvol "0"') - self.assertEqual(0, self.backend.playback.volume.get()) + self.assertEqual(0, self.core.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_middle(self): self.sendRequest(u'setvol "50"') - self.assertEqual(50, self.backend.playback.volume.get()) + self.assertEqual(50, self.core.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_max(self): self.sendRequest(u'setvol "100"') - self.assertEqual(100, self.backend.playback.volume.get()) + self.assertEqual(100, self.core.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_above_max(self): self.sendRequest(u'setvol "110"') - self.assertEqual(100, self.backend.playback.volume.get()) + self.assertEqual(100, self.core.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_plus_is_ignored(self): self.sendRequest(u'setvol "+10"') - self.assertEqual(10, self.backend.playback.volume.get()) + self.assertEqual(10, self.core.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_without_quotes(self): self.sendRequest(u'setvol 50') - self.assertEqual(50, self.backend.playback.volume.get()) + self.assertEqual(50, self.core.playback.volume.get()) self.assertInResponse(u'OK') def test_single_off(self): self.sendRequest(u'single "0"') - self.assertFalse(self.backend.playback.single.get()) + self.assertFalse(self.core.playback.single.get()) self.assertInResponse(u'OK') def test_single_off_without_quotes(self): self.sendRequest(u'single 0') - self.assertFalse(self.backend.playback.single.get()) + self.assertFalse(self.core.playback.single.get()) self.assertInResponse(u'OK') def test_single_on(self): self.sendRequest(u'single "1"') - self.assertTrue(self.backend.playback.single.get()) + self.assertTrue(self.core.playback.single.get()) self.assertInResponse(u'OK') def test_single_on_without_quotes(self): self.sendRequest(u'single 1') - self.assertTrue(self.backend.playback.single.get()) + self.assertTrue(self.core.playback.single.get()) self.assertInResponse(u'OK') def test_replay_gain_mode_off(self): @@ -166,198 +166,198 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_pause_off(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track()]) self.sendRequest(u'play "0"') self.sendRequest(u'pause "1"') self.sendRequest(u'pause "0"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_pause_on(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track()]) self.sendRequest(u'play "0"') self.sendRequest(u'pause "1"') - self.assertEqual(PAUSED, self.backend.playback.state.get()) + self.assertEqual(PAUSED, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_pause_toggle(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track()]) self.sendRequest(u'play "0"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') self.sendRequest(u'pause') - self.assertEqual(PAUSED, self.backend.playback.state.get()) + self.assertEqual(PAUSED, self.core.playback.state.get()) self.assertInResponse(u'OK') self.sendRequest(u'pause') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_play_without_pos(self): - self.backend.current_playlist.append([Track()]) - self.backend.playback.state = PAUSED + self.core.current_playlist.append([Track()]) + self.core.playback.state = PAUSED self.sendRequest(u'play') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_play_with_pos(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track()]) self.sendRequest(u'play "0"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_play_with_pos_without_quotes(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track()]) self.sendRequest(u'play 0') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_play_with_pos_out_of_bounds(self): - self.backend.current_playlist.append([]) + self.core.current_playlist.append([]) self.sendRequest(u'play "0"') - self.assertEqual(STOPPED, self.backend.playback.state.get()) + self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertInResponse(u'ACK [2@0] {play} Bad song index') def test_play_minus_one_plays_first_in_playlist_if_no_current_track(self): - self.assertEqual(self.backend.playback.current_track.get(), None) - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(self.core.playback.current_track.get(), None) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.sendRequest(u'play "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertEqual('a', self.backend.playback.current_track.get().uri) + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertEqual('a', self.core.playback.current_track.get().uri) self.assertInResponse(u'OK') def test_play_minus_one_plays_current_track_if_current_track_is_set(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEqual(self.backend.playback.current_track.get(), None) - self.backend.playback.play() - self.backend.playback.next() - self.backend.playback.stop() - self.assertNotEqual(self.backend.playback.current_track.get(), None) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(self.core.playback.current_track.get(), None) + self.core.playback.play() + self.core.playback.next() + self.core.playback.stop() + self.assertNotEqual(self.core.playback.current_track.get(), None) self.sendRequest(u'play "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertEqual('b', self.backend.playback.current_track.get().uri) + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertEqual('b', self.core.playback.current_track.get().uri) self.assertInResponse(u'OK') def test_play_minus_one_on_empty_playlist_does_not_ack(self): - self.backend.current_playlist.clear() + self.core.current_playlist.clear() self.sendRequest(u'play "-1"') - self.assertEqual(STOPPED, self.backend.playback.state.get()) - self.assertEqual(None, self.backend.playback.current_track.get()) + self.assertEqual(STOPPED, self.core.playback.state.get()) + self.assertEqual(None, self.core.playback.current_track.get()) self.assertInResponse(u'OK') def test_play_minus_is_ignored_if_playing(self): - self.backend.current_playlist.append([Track(length=40000)]) - self.backend.playback.seek(30000) - self.assertGreaterEqual(self.backend.playback.time_position.get(), + self.core.current_playlist.append([Track(length=40000)]) + self.core.playback.seek(30000) + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) - self.assertEquals(PLAYING, self.backend.playback.state.get()) + self.assertEquals(PLAYING, self.core.playback.state.get()) self.sendRequest(u'play "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertGreaterEqual(self.backend.playback.time_position.get(), + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_play_minus_one_resumes_if_paused(self): - self.backend.current_playlist.append([Track(length=40000)]) - self.backend.playback.seek(30000) - self.assertGreaterEqual(self.backend.playback.time_position.get(), + self.core.current_playlist.append([Track(length=40000)]) + self.core.playback.seek(30000) + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) - self.assertEquals(PLAYING, self.backend.playback.state.get()) - self.backend.playback.pause() - self.assertEquals(PAUSED, self.backend.playback.state.get()) + self.assertEquals(PLAYING, self.core.playback.state.get()) + self.core.playback.pause() + self.assertEquals(PAUSED, self.core.playback.state.get()) self.sendRequest(u'play "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertGreaterEqual(self.backend.playback.time_position.get(), + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_playid(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track()]) self.sendRequest(u'playid "0"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_playid_without_quotes(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track()]) self.sendRequest(u'playid 0') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_playid_minus_one_plays_first_in_playlist_if_no_current_track(self): - self.assertEqual(self.backend.playback.current_track.get(), None) - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(self.core.playback.current_track.get(), None) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.sendRequest(u'playid "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertEqual('a', self.backend.playback.current_track.get().uri) + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertEqual('a', self.core.playback.current_track.get().uri) self.assertInResponse(u'OK') def test_playid_minus_one_plays_current_track_if_current_track_is_set(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEqual(self.backend.playback.current_track.get(), None) - self.backend.playback.play() - self.backend.playback.next() - self.backend.playback.stop() - self.assertNotEqual(None, self.backend.playback.current_track.get()) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(self.core.playback.current_track.get(), None) + self.core.playback.play() + self.core.playback.next() + self.core.playback.stop() + self.assertNotEqual(None, self.core.playback.current_track.get()) self.sendRequest(u'playid "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertEqual('b', self.backend.playback.current_track.get().uri) + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertEqual('b', self.core.playback.current_track.get().uri) self.assertInResponse(u'OK') def test_playid_minus_one_on_empty_playlist_does_not_ack(self): - self.backend.current_playlist.clear() + self.core.current_playlist.clear() self.sendRequest(u'playid "-1"') - self.assertEqual(STOPPED, self.backend.playback.state.get()) - self.assertEqual(None, self.backend.playback.current_track.get()) + self.assertEqual(STOPPED, self.core.playback.state.get()) + self.assertEqual(None, self.core.playback.current_track.get()) self.assertInResponse(u'OK') def test_playid_minus_is_ignored_if_playing(self): - self.backend.current_playlist.append([Track(length=40000)]) - self.backend.playback.seek(30000) - self.assertGreaterEqual(self.backend.playback.time_position.get(), + self.core.current_playlist.append([Track(length=40000)]) + self.core.playback.seek(30000) + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) - self.assertEquals(PLAYING, self.backend.playback.state.get()) + self.assertEquals(PLAYING, self.core.playback.state.get()) self.sendRequest(u'playid "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertGreaterEqual(self.backend.playback.time_position.get(), + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_playid_minus_one_resumes_if_paused(self): - self.backend.current_playlist.append([Track(length=40000)]) - self.backend.playback.seek(30000) - self.assertGreaterEqual(self.backend.playback.time_position.get(), + self.core.current_playlist.append([Track(length=40000)]) + self.core.playback.seek(30000) + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) - self.assertEquals(PLAYING, self.backend.playback.state.get()) - self.backend.playback.pause() - self.assertEquals(PAUSED, self.backend.playback.state.get()) + self.assertEquals(PLAYING, self.core.playback.state.get()) + self.core.playback.pause() + self.assertEquals(PAUSED, self.core.playback.state.get()) self.sendRequest(u'playid "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertGreaterEqual(self.backend.playback.time_position.get(), + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_playid_which_does_not_exist(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track()]) self.sendRequest(u'playid "12345"') self.assertInResponse(u'ACK [50@0] {playid} No such song') @@ -367,49 +367,49 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_seek(self): - self.backend.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([Track(length=40000)]) self.sendRequest(u'seek "0"') self.sendRequest(u'seek "0" "30"') - self.assertGreaterEqual(self.backend.playback.time_position, 30000) + self.assertGreaterEqual(self.core.playback.time_position, 30000) self.assertInResponse(u'OK') def test_seek_with_songpos(self): seek_track = Track(uri='2', length=40000) - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(uri='1', length=40000), seek_track]) self.sendRequest(u'seek "1" "30"') - self.assertEqual(self.backend.playback.current_track.get(), seek_track) + self.assertEqual(self.core.playback.current_track.get(), seek_track) self.assertInResponse(u'OK') def test_seek_without_quotes(self): - self.backend.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([Track(length=40000)]) self.sendRequest(u'seek 0') self.sendRequest(u'seek 0 30') - self.assertGreaterEqual(self.backend.playback.time_position.get(), + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_seekid(self): - self.backend.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([Track(length=40000)]) self.sendRequest(u'seekid "0" "30"') - self.assertGreaterEqual(self.backend.playback.time_position.get(), + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_seekid_with_cpid(self): seek_track = Track(uri='2', length=40000) - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(length=40000), seek_track]) self.sendRequest(u'seekid "1" "30"') - self.assertEqual(1, self.backend.playback.current_cpid.get()) - self.assertEqual(seek_track, self.backend.playback.current_track.get()) + self.assertEqual(1, self.core.playback.current_cpid.get()) + self.assertEqual(seek_track, self.core.playback.current_track.get()) self.assertInResponse(u'OK') def test_stop(self): self.sendRequest(u'stop') - self.assertEqual(STOPPED, self.backend.playback.state.get()) + self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertInResponse(u'OK') diff --git a/tests/frontends/mpd/protocol/regression_test.py b/tests/frontends/mpd/protocol/regression_test.py index 7f214efa..90bcaf60 100644 --- a/tests/frontends/mpd/protocol/regression_test.py +++ b/tests/frontends/mpd/protocol/regression_test.py @@ -16,23 +16,23 @@ class IssueGH17RegressionTest(protocol.BaseTestCase): - Press next until you get to the unplayable track """ def test(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(uri='a'), Track(uri='b'), None, Track(uri='d'), Track(uri='e'), Track(uri='f')]) random.seed(1) # Playlist order: abcfde self.sendRequest(u'play') - self.assertEquals('a', self.backend.playback.current_track.get().uri) + self.assertEquals('a', self.core.playback.current_track.get().uri) self.sendRequest(u'random "1"') self.sendRequest(u'next') - self.assertEquals('b', self.backend.playback.current_track.get().uri) + self.assertEquals('b', self.core.playback.current_track.get().uri) self.sendRequest(u'next') # Should now be at track 'c', but playback fails and it skips ahead - self.assertEquals('f', self.backend.playback.current_track.get().uri) + self.assertEquals('f', self.core.playback.current_track.get().uri) self.sendRequest(u'next') - self.assertEquals('d', self.backend.playback.current_track.get().uri) + self.assertEquals('d', self.core.playback.current_track.get().uri) self.sendRequest(u'next') - self.assertEquals('e', self.backend.playback.current_track.get().uri) + self.assertEquals('e', self.core.playback.current_track.get().uri) class IssueGH18RegressionTest(protocol.BaseTestCase): @@ -47,7 +47,7 @@ class IssueGH18RegressionTest(protocol.BaseTestCase): """ def test(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(uri='a'), Track(uri='b'), Track(uri='c'), Track(uri='d'), Track(uri='e'), Track(uri='f')]) random.seed(1) @@ -59,11 +59,11 @@ class IssueGH18RegressionTest(protocol.BaseTestCase): self.sendRequest(u'next') self.sendRequest(u'next') - cp_track_1 = self.backend.playback.current_cp_track.get() + cp_track_1 = self.core.playback.current_cp_track.get() self.sendRequest(u'next') - cp_track_2 = self.backend.playback.current_cp_track.get() + cp_track_2 = self.core.playback.current_cp_track.get() self.sendRequest(u'next') - cp_track_3 = self.backend.playback.current_cp_track.get() + cp_track_3 = self.core.playback.current_cp_track.get() self.assertNotEqual(cp_track_1, cp_track_2) self.assertNotEqual(cp_track_2, cp_track_3) @@ -83,7 +83,7 @@ class IssueGH22RegressionTest(protocol.BaseTestCase): """ def test(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(uri='a'), Track(uri='b'), Track(uri='c'), Track(uri='d'), Track(uri='e'), Track(uri='f')]) random.seed(1) @@ -111,8 +111,8 @@ class IssueGH69RegressionTest(protocol.BaseTestCase): """ def test(self): - self.backend.stored_playlists.create('foo') - self.backend.current_playlist.append([ + self.core.stored_playlists.create('foo') + self.core.current_playlist.append([ Track(uri='a'), Track(uri='b'), Track(uri='c'), Track(uri='d'), Track(uri='e'), Track(uri='f')]) @@ -136,7 +136,7 @@ class IssueGH113RegressionTest(protocol.BaseTestCase): """ def test(self): - self.backend.stored_playlists.create( + self.core.stored_playlists.create( u'all lart spotify:track:\w\{22\} pastes') self.sendRequest(u'lsinfo "/"') diff --git a/tests/frontends/mpd/protocol/status_test.py b/tests/frontends/mpd/protocol/status_test.py index e6572eab..e2f0df9c 100644 --- a/tests/frontends/mpd/protocol/status_test.py +++ b/tests/frontends/mpd/protocol/status_test.py @@ -10,8 +10,8 @@ class StatusHandlerTest(protocol.BaseTestCase): def test_currentsong(self): track = Track() - self.backend.current_playlist.append([track]) - self.backend.playback.play() + self.core.current_playlist.append([track]) + self.core.playback.play() self.sendRequest(u'currentsong') self.assertInResponse(u'file: ') self.assertInResponse(u'Time: 0') diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index 45d6a09a..0bf9756f 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -7,7 +7,7 @@ from tests.frontends.mpd import protocol class StoredPlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist(self): - self.backend.stored_playlists.playlists = [ + self.core.stored_playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] self.sendRequest(u'listplaylist "name"') @@ -19,7 +19,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertEqualResponse(u'ACK [50@0] {listplaylist} No such playlist') def test_listplaylistinfo(self): - self.backend.stored_playlists.playlists = [ + self.core.stored_playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] self.sendRequest(u'listplaylistinfo "name"') @@ -35,7 +35,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists(self): last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) - self.backend.stored_playlists.playlists = [Playlist(name='a', + self.core.stored_playlists.playlists = [Playlist(name='a', last_modified=last_modified)] self.sendRequest(u'listplaylists') @@ -45,13 +45,13 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_load_known_playlist_appends_to_current_playlist(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) - self.backend.stored_playlists.playlists = [Playlist(name='A-list', + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) + self.core.stored_playlists.playlists = [Playlist(name='A-list', tracks=[Track(uri='c'), Track(uri='d'), Track(uri='e')])] self.sendRequest(u'load "A-list"') - tracks = self.backend.current_playlist.tracks.get() + tracks = self.core.current_playlist.tracks.get() self.assertEqual(5, len(tracks)) self.assertEqual('a', tracks[0].uri) self.assertEqual('b', tracks[1].uri) @@ -62,7 +62,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): def test_load_unknown_playlist_acks(self): self.sendRequest(u'load "unknown playlist"') - self.assertEqual(0, len(self.backend.current_playlist.tracks.get())) + self.assertEqual(0, len(self.core.current_playlist.tracks.get())) self.assertEqualResponse(u'ACK [50@0] {load} No such playlist') def test_playlistadd(self): diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 2397b96f..3a5bdcbe 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -1,3 +1,6 @@ +from pykka.registry import ActorRegistry + +from mopidy import audio, core from mopidy.backends import dummy from mopidy.core import PlaybackState from mopidy.frontends.mpd import dispatcher @@ -17,12 +20,13 @@ STOPPED = PlaybackState.STOPPED class StatusHandlerTest(unittest.TestCase): def setUp(self): - self.backend = dummy.DummyBackend.start().proxy() + self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.core = core.Core.start(backend=self.backend).proxy() self.dispatcher = dispatcher.MpdDispatcher() self.context = self.dispatcher.context def tearDown(self): - self.backend.stop().get() + ActorRegistry.stop_all() def test_stats_method(self): result = status.stats(self.context) @@ -47,7 +51,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['volume']), -1) def test_status_method_contains_volume(self): - self.backend.playback.volume = 17 + self.core.playback.volume = 17 result = dict(status.status(self.context)) self.assertIn('volume', result) self.assertEqual(int(result['volume']), 17) @@ -58,7 +62,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['repeat']), 0) def test_status_method_contains_repeat_is_1(self): - self.backend.playback.repeat = 1 + self.core.playback.repeat = 1 result = dict(status.status(self.context)) self.assertIn('repeat', result) self.assertEqual(int(result['repeat']), 1) @@ -69,7 +73,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['random']), 0) def test_status_method_contains_random_is_1(self): - self.backend.playback.random = 1 + self.core.playback.random = 1 result = dict(status.status(self.context)) self.assertIn('random', result) self.assertEqual(int(result['random']), 1) @@ -85,7 +89,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['consume']), 0) def test_status_method_contains_consume_is_1(self): - self.backend.playback.consume = 1 + self.core.playback.consume = 1 result = dict(status.status(self.context)) self.assertIn('consume', result) self.assertEqual(int(result['consume']), 1) @@ -106,41 +110,41 @@ class StatusHandlerTest(unittest.TestCase): self.assertGreaterEqual(int(result['xfade']), 0) def test_status_method_contains_state_is_play(self): - self.backend.playback.state = PLAYING + self.core.playback.state = PLAYING result = dict(status.status(self.context)) self.assertIn('state', result) self.assertEqual(result['state'], 'play') def test_status_method_contains_state_is_stop(self): - self.backend.playback.state = STOPPED + self.core.playback.state = STOPPED result = dict(status.status(self.context)) self.assertIn('state', result) self.assertEqual(result['state'], 'stop') def test_status_method_contains_state_is_pause(self): - self.backend.playback.state = PLAYING - self.backend.playback.state = PAUSED + self.core.playback.state = PLAYING + self.core.playback.state = PAUSED result = dict(status.status(self.context)) self.assertIn('state', result) self.assertEqual(result['state'], 'pause') def test_status_method_when_playlist_loaded_contains_song(self): - self.backend.current_playlist.append([Track()]) - self.backend.playback.play() + self.core.current_playlist.append([Track()]) + 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_cpid_as_songid(self): - self.backend.current_playlist.append([Track()]) - self.backend.playback.play() + self.core.current_playlist.append([Track()]) + 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.backend.current_playlist.append([Track(length=None)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(length=None)]) + self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('time', result) (position, total) = result['time'].split(':') @@ -149,8 +153,8 @@ class StatusHandlerTest(unittest.TestCase): self.assertLessEqual(position, total) def test_status_method_when_playing_contains_time_with_length(self): - self.backend.current_playlist.append([Track(length=10000)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(length=10000)]) + self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('time', result) (position, total) = result['time'].split(':') @@ -159,25 +163,25 @@ class StatusHandlerTest(unittest.TestCase): self.assertLessEqual(position, total) def test_status_method_when_playing_contains_elapsed(self): - self.backend.current_playlist.append([Track(length=60000)]) - self.backend.playback.play() - self.backend.playback.pause() - self.backend.playback.seek(59123) + self.core.current_playlist.append([Track(length=60000)]) + self.core.playback.play() + self.core.playback.pause() + self.core.playback.seek(59123) result = dict(status.status(self.context)) self.assertIn('elapsed', result) self.assertEqual(result['elapsed'], '59.123') def test_status_method_when_starting_playing_contains_elapsed_zero(self): - self.backend.current_playlist.append([Track(length=10000)]) - self.backend.playback.play() - self.backend.playback.pause() + self.core.current_playlist.append([Track(length=10000)]) + self.core.playback.play() + self.core.playback.pause() result = dict(status.status(self.context)) self.assertIn('elapsed', result) self.assertEqual(result['elapsed'], '0.000') def test_status_method_when_playing_contains_bitrate(self): - self.backend.current_playlist.append([Track(bitrate=320)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(bitrate=320)]) + self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('bitrate', result) self.assertEqual(int(result['bitrate']), 320) diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index 89f7f1d4..236ec645 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -2,8 +2,10 @@ import sys import mock -from mopidy import OptionalDependencyError -from mopidy.backends.dummy import DummyBackend +from pykka.registry import ActorRegistry + +from mopidy import core, OptionalDependencyError +from mopidy.backends import dummy from mopidy.core import PlaybackState from mopidy.models import Album, Artist, Track @@ -23,68 +25,69 @@ STOPPED = PlaybackState.STOPPED class PlayerInterfaceTest(unittest.TestCase): def setUp(self): objects.MprisObject._connect_to_dbus = mock.Mock() - self.backend = DummyBackend.start().proxy() + self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.core = core.Core.start(backend=self.backend).proxy() self.mpris = objects.MprisObject() - self.mpris._backend = self.backend + self.mpris._core = self.core def tearDown(self): - self.backend.stop() + ActorRegistry.stop_all() def test_get_playback_status_is_playing_when_playing(self): - self.backend.playback.state = PLAYING + self.core.playback.state = PLAYING result = self.mpris.Get(objects.PLAYER_IFACE, 'PlaybackStatus') self.assertEqual('Playing', result) def test_get_playback_status_is_paused_when_paused(self): - self.backend.playback.state = PAUSED + self.core.playback.state = PAUSED result = self.mpris.Get(objects.PLAYER_IFACE, 'PlaybackStatus') self.assertEqual('Paused', result) def test_get_playback_status_is_stopped_when_stopped(self): - self.backend.playback.state = STOPPED + self.core.playback.state = STOPPED result = self.mpris.Get(objects.PLAYER_IFACE, 'PlaybackStatus') self.assertEqual('Stopped', result) def test_get_loop_status_is_none_when_not_looping(self): - self.backend.playback.repeat = False - self.backend.playback.single = False + self.core.playback.repeat = False + self.core.playback.single = False result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') self.assertEqual('None', result) def test_get_loop_status_is_track_when_looping_a_single_track(self): - self.backend.playback.repeat = True - self.backend.playback.single = True + self.core.playback.repeat = True + self.core.playback.single = True result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') self.assertEqual('Track', result) def test_get_loop_status_is_playlist_when_looping_the_current_playlist(self): - self.backend.playback.repeat = True - self.backend.playback.single = False + self.core.playback.repeat = True + self.core.playback.single = False result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') self.assertEqual('Playlist', result) def test_set_loop_status_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.backend.playback.repeat = True - self.backend.playback.single = True + self.core.playback.repeat = True + self.core.playback.single = True self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'None') - self.assertEquals(self.backend.playback.repeat.get(), True) - self.assertEquals(self.backend.playback.single.get(), True) + self.assertEquals(self.core.playback.repeat.get(), True) + self.assertEquals(self.core.playback.single.get(), True) def test_set_loop_status_to_none_unsets_repeat_and_single(self): self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'None') - self.assertEquals(self.backend.playback.repeat.get(), False) - self.assertEquals(self.backend.playback.single.get(), False) + self.assertEquals(self.core.playback.repeat.get(), False) + self.assertEquals(self.core.playback.single.get(), False) def test_set_loop_status_to_track_sets_repeat_and_single(self): self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'Track') - self.assertEquals(self.backend.playback.repeat.get(), True) - self.assertEquals(self.backend.playback.single.get(), True) + self.assertEquals(self.core.playback.repeat.get(), True) + self.assertEquals(self.core.playback.single.get(), True) def test_set_loop_status_to_playlists_sets_repeat_and_not_single(self): self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'Playlist') - self.assertEquals(self.backend.playback.repeat.get(), True) - self.assertEquals(self.backend.playback.single.get(), False) + self.assertEquals(self.core.playback.repeat.get(), True) + self.assertEquals(self.core.playback.single.get(), False) def test_get_rate_is_greater_or_equal_than_minimum_rate(self): rate = self.mpris.Get(objects.PLAYER_IFACE, 'Rate') @@ -98,46 +101,46 @@ class PlayerInterfaceTest(unittest.TestCase): def test_set_rate_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.Set(objects.PLAYER_IFACE, 'Rate', 0) - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) def test_set_rate_to_zero_pauses_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.Set(objects.PLAYER_IFACE, 'Rate', 0) - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.assertEquals(self.core.playback.state.get(), PAUSED) def test_get_shuffle_returns_true_if_random_is_active(self): - self.backend.playback.random = True + self.core.playback.random = True result = self.mpris.Get(objects.PLAYER_IFACE, 'Shuffle') self.assertTrue(result) def test_get_shuffle_returns_false_if_random_is_inactive(self): - self.backend.playback.random = False + self.core.playback.random = False result = self.mpris.Get(objects.PLAYER_IFACE, 'Shuffle') self.assertFalse(result) def test_set_shuffle_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.backend.playback.random = False + self.core.playback.random = False result = self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) - self.assertFalse(self.backend.playback.random.get()) + self.assertFalse(self.core.playback.random.get()) def test_set_shuffle_to_true_activates_random_mode(self): - self.backend.playback.random = False - self.assertFalse(self.backend.playback.random.get()) + self.core.playback.random = False + self.assertFalse(self.core.playback.random.get()) result = self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) - self.assertTrue(self.backend.playback.random.get()) + self.assertTrue(self.core.playback.random.get()) def test_set_shuffle_to_false_deactivates_random_mode(self): - self.backend.playback.random = True - self.assertTrue(self.backend.playback.random.get()) + self.core.playback.random = True + self.assertTrue(self.core.playback.random.get()) result = self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', False) - self.assertFalse(self.backend.playback.random.get()) + self.assertFalse(self.core.playback.random.get()) def test_get_metadata_has_trackid_even_when_no_current_track(self): result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') @@ -145,105 +148,105 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEquals(result['mpris:trackid'], '') def test_get_metadata_has_trackid_based_on_cpid(self): - self.backend.current_playlist.append([Track(uri='a')]) - self.backend.playback.play() - (cpid, track) = self.backend.playback.current_cp_track.get() + self.core.current_playlist.append([Track(uri='a')]) + self.core.playback.play() + (cpid, track) = self.core.playback.current_cp_track.get() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('mpris:trackid', result.keys()) self.assertEquals(result['mpris:trackid'], '/com/mopidy/track/%d' % cpid) def test_get_metadata_has_track_length(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('mpris:length', result.keys()) self.assertEquals(result['mpris:length'], 40000000) def test_get_metadata_has_track_uri(self): - self.backend.current_playlist.append([Track(uri='a')]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a')]) + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:url', result.keys()) self.assertEquals(result['xesam:url'], 'a') def test_get_metadata_has_track_title(self): - self.backend.current_playlist.append([Track(name='a')]) - self.backend.playback.play() + self.core.current_playlist.append([Track(name='a')]) + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:title', result.keys()) self.assertEquals(result['xesam:title'], 'a') def test_get_metadata_has_track_artists(self): - self.backend.current_playlist.append([Track(artists=[ + self.core.current_playlist.append([Track(artists=[ Artist(name='a'), Artist(name='b'), Artist(name=None)])]) - self.backend.playback.play() + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:artist', result.keys()) self.assertEquals(result['xesam:artist'], ['a', 'b']) def test_get_metadata_has_track_album(self): - self.backend.current_playlist.append([Track(album=Album(name='a'))]) - self.backend.playback.play() + self.core.current_playlist.append([Track(album=Album(name='a'))]) + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:album', result.keys()) self.assertEquals(result['xesam:album'], 'a') def test_get_metadata_has_track_album_artists(self): - self.backend.current_playlist.append([Track(album=Album(artists=[ + self.core.current_playlist.append([Track(album=Album(artists=[ Artist(name='a'), Artist(name='b'), Artist(name=None)]))]) - self.backend.playback.play() + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:albumArtist', result.keys()) self.assertEquals(result['xesam:albumArtist'], ['a', 'b']) def test_get_metadata_has_track_number_in_album(self): - self.backend.current_playlist.append([Track(track_no=7)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(track_no=7)]) + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:trackNumber', result.keys()) self.assertEquals(result['xesam:trackNumber'], 7) def test_get_volume_should_return_volume_between_zero_and_one(self): - self.backend.playback.volume = None + self.core.playback.volume = None result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') self.assertEquals(result, 0) - self.backend.playback.volume = 0 + self.core.playback.volume = 0 result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') self.assertEquals(result, 0) - self.backend.playback.volume = 50 + self.core.playback.volume = 50 result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') self.assertEquals(result, 0.5) - self.backend.playback.volume = 100 + self.core.playback.volume = 100 result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') self.assertEquals(result, 1) def test_set_volume_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.backend.playback.volume = 0 + self.core.playback.volume = 0 self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) - self.assertEquals(self.backend.playback.volume.get(), 0) + self.assertEquals(self.core.playback.volume.get(), 0) def test_set_volume_to_one_should_set_mixer_volume_to_100(self): self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) - self.assertEquals(self.backend.playback.volume.get(), 100) + self.assertEquals(self.core.playback.volume.get(), 100) def test_set_volume_to_anything_above_one_should_set_mixer_volume_to_100(self): self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 2.0) - self.assertEquals(self.backend.playback.volume.get(), 100) + self.assertEquals(self.core.playback.volume.get(), 100) def test_set_volume_to_anything_not_a_number_does_not_change_volume(self): - self.backend.playback.volume = 10 + self.core.playback.volume = 10 self.mpris.Set(objects.PLAYER_IFACE, 'Volume', None) - self.assertEquals(self.backend.playback.volume.get(), 10) + self.assertEquals(self.core.playback.volume.get(), 10) def test_get_position_returns_time_position_in_microseconds(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() - self.backend.playback.seek(10000) + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(10000) result_in_microseconds = self.mpris.Get(objects.PLAYER_IFACE, 'Position') result_in_milliseconds = result_in_microseconds // 1000 self.assertGreaterEqual(result_in_milliseconds, 10000) @@ -263,61 +266,61 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_go_next_is_true_if_can_control_and_other_next_track(self): self.mpris.get_CanControl = lambda *_: True - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') self.assertTrue(result) def test_can_go_next_is_false_if_next_track_is_the_same(self): self.mpris.get_CanControl = lambda *_: True - self.backend.current_playlist.append([Track(uri='a')]) - self.backend.playback.repeat = True - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a')]) + self.core.playback.repeat = True + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') self.assertFalse(result) def test_can_go_next_is_false_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') self.assertFalse(result) def test_can_go_previous_is_true_if_can_control_and_other_previous_track(self): self.mpris.get_CanControl = lambda *_: True - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.next() + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.next() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') self.assertTrue(result) def test_can_go_previous_is_false_if_previous_track_is_the_same(self): self.mpris.get_CanControl = lambda *_: True - self.backend.current_playlist.append([Track(uri='a')]) - self.backend.playback.repeat = True - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a')]) + self.core.playback.repeat = True + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') self.assertFalse(result) def test_can_go_previous_is_false_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.next() + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.next() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') self.assertFalse(result) def test_can_play_is_true_if_can_control_and_current_track(self): self.mpris.get_CanControl = lambda *_: True - self.backend.current_playlist.append([Track(uri='a')]) - self.backend.playback.play() - self.assertTrue(self.backend.playback.current_track.get()) + self.core.current_playlist.append([Track(uri='a')]) + self.core.playback.play() + self.assertTrue(self.core.playback.current_track.get()) result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') self.assertTrue(result) def test_can_play_is_false_if_no_current_track(self): self.mpris.get_CanControl = lambda *_: True - self.assertFalse(self.backend.playback.current_track.get()) + self.assertFalse(self.core.playback.current_track.get()) result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') self.assertFalse(result) @@ -352,223 +355,223 @@ class PlayerInterfaceTest(unittest.TestCase): def test_next_is_ignored_if_can_go_next_is_false(self): self.mpris.get_CanGoNext = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.current_track.get().uri, 'a') self.mpris.Next() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.current_track.get().uri, 'a') def test_next_when_playing_should_skip_to_next_track_and_keep_playing(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.Next() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'b') + self.assertEquals(self.core.playback.state.get(), PLAYING) def test_next_when_at_end_of_list_should_stop_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.next() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.next() + self.assertEquals(self.core.playback.current_track.get().uri, 'b') + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.Next() - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEquals(self.core.playback.state.get(), STOPPED) def test_next_when_paused_should_skip_to_next_track_and_stay_paused(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.pause() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.pause() + self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PAUSED) self.mpris.Next() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.assertEquals(self.core.playback.current_track.get().uri, 'b') + self.assertEquals(self.core.playback.state.get(), PAUSED) def test_next_when_stopped_should_skip_to_next_track_and_stay_stopped(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.stop() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.stop() + self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), STOPPED) self.mpris.Next() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEquals(self.core.playback.current_track.get().uri, 'b') + self.assertEquals(self.core.playback.state.get(), STOPPED) def test_previous_is_ignored_if_can_go_previous_is_false(self): self.mpris.get_CanGoPrevious = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.next() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.next() + self.assertEquals(self.core.playback.current_track.get().uri, 'b') self.mpris.Previous() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.assertEquals(self.core.playback.current_track.get().uri, 'b') def test_previous_when_playing_should_skip_to_prev_track_and_keep_playing(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.next() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.next() + self.assertEquals(self.core.playback.current_track.get().uri, 'b') + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.Previous() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PLAYING) def test_previous_when_at_start_of_list_should_stop_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.Previous() - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEquals(self.core.playback.state.get(), STOPPED) def test_previous_when_paused_should_skip_to_previous_track_and_stay_paused(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.next() - self.backend.playback.pause() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.next() + self.core.playback.pause() + self.assertEquals(self.core.playback.current_track.get().uri, 'b') + self.assertEquals(self.core.playback.state.get(), PAUSED) self.mpris.Previous() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PAUSED) def test_previous_when_stopped_should_skip_to_previous_track_and_stay_stopped(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.next() - self.backend.playback.stop() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.next() + self.core.playback.stop() + self.assertEquals(self.core.playback.current_track.get().uri, 'b') + self.assertEquals(self.core.playback.state.get(), STOPPED) self.mpris.Previous() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), STOPPED) def test_pause_is_ignored_if_can_pause_is_false(self): self.mpris.get_CanPause = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.Pause() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) def test_pause_when_playing_should_pause_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.Pause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.assertEquals(self.core.playback.state.get(), PAUSED) def test_pause_when_paused_has_no_effect(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.pause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.pause() + self.assertEquals(self.core.playback.state.get(), PAUSED) self.mpris.Pause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.assertEquals(self.core.playback.state.get(), PAUSED) def test_playpause_is_ignored_if_can_pause_is_false(self): self.mpris.get_CanPause = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.PlayPause() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) def test_playpause_when_playing_should_pause_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.PlayPause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.assertEquals(self.core.playback.state.get(), PAUSED) def test_playpause_when_paused_should_resume_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.pause() + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.pause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) - at_pause = self.backend.playback.time_position.get() + self.assertEquals(self.core.playback.state.get(), PAUSED) + at_pause = self.core.playback.time_position.get() self.assertGreaterEqual(at_pause, 0) self.mpris.PlayPause() - self.assertEquals(self.backend.playback.state.get(), PLAYING) - after_pause = self.backend.playback.time_position.get() + self.assertEquals(self.core.playback.state.get(), PLAYING) + after_pause = self.core.playback.time_position.get() self.assertGreaterEqual(after_pause, at_pause) def test_playpause_when_stopped_should_start_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEquals(self.core.playback.state.get(), STOPPED) self.mpris.PlayPause() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) def test_stop_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.Stop() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) def test_stop_when_playing_should_stop_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.Stop() - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEquals(self.core.playback.state.get(), STOPPED) def test_stop_when_paused_should_stop_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.pause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.pause() + self.assertEquals(self.core.playback.state.get(), PAUSED) self.mpris.Stop() - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEquals(self.core.playback.state.get(), STOPPED) def test_play_is_ignored_if_can_play_is_false(self): self.mpris.get_CanPlay = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEquals(self.core.playback.state.get(), STOPPED) self.mpris.Play() - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEquals(self.core.playback.state.get(), STOPPED) def test_play_when_stopped_starts_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEquals(self.core.playback.state.get(), STOPPED) self.mpris.Play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) def test_play_after_pause_resumes_from_same_position(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() - before_pause = self.backend.playback.time_position.get() + before_pause = self.core.playback.time_position.get() self.assertGreaterEqual(before_pause, 0) self.mpris.Pause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) - at_pause = self.backend.playback.time_position.get() + self.assertEquals(self.core.playback.state.get(), PAUSED) + at_pause = self.core.playback.time_position.get() self.assertGreaterEqual(at_pause, before_pause) self.mpris.Play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) - after_pause = self.backend.playback.time_position.get() + self.assertEquals(self.core.playback.state.get(), PLAYING) + after_pause = self.core.playback.time_position.get() self.assertGreaterEqual(after_pause, at_pause) def test_play_when_there_is_no_track_has_no_effect(self): - self.backend.current_playlist.clear() - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.core.current_playlist.clear() + self.assertEquals(self.core.playback.state.get(), STOPPED) self.mpris.Play() - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEquals(self.core.playback.state.get(), STOPPED) def test_seek_is_ignored_if_can_seek_is_false(self): self.mpris.get_CanSeek = lambda *_: False - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() - before_seek = self.backend.playback.time_position.get() + before_seek = self.core.playback.time_position.get() self.assertGreaterEqual(before_seek, 0) milliseconds_to_seek = 10000 @@ -576,15 +579,15 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Seek(microseconds_to_seek) - after_seek = self.backend.playback.time_position.get() + after_seek = self.core.playback.time_position.get() self.assertLessEqual(before_seek, after_seek) self.assertLess(after_seek, before_seek + milliseconds_to_seek) def test_seek_seeks_given_microseconds_forward_in_the_current_track(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() - before_seek = self.backend.playback.time_position.get() + before_seek = self.core.playback.time_position.get() self.assertGreaterEqual(before_seek, 0) milliseconds_to_seek = 10000 @@ -592,17 +595,17 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Seek(microseconds_to_seek) - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) - after_seek = self.backend.playback.time_position.get() + after_seek = self.core.playback.time_position.get() self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) def test_seek_seeks_given_microseconds_backward_if_negative(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() - self.backend.playback.seek(20000) + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(20000) - before_seek = self.backend.playback.time_position.get() + before_seek = self.core.playback.time_position.get() self.assertGreaterEqual(before_seek, 20000) milliseconds_to_seek = -10000 @@ -610,18 +613,18 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Seek(microseconds_to_seek) - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) - after_seek = self.backend.playback.time_position.get() + after_seek = self.core.playback.time_position.get() self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) self.assertLess(after_seek, before_seek) def test_seek_seeks_to_start_of_track_if_new_position_is_negative(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() - self.backend.playback.seek(20000) + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(20000) - before_seek = self.backend.playback.time_position.get() + before_seek = self.core.playback.time_position.get() self.assertGreaterEqual(before_seek, 20000) milliseconds_to_seek = -30000 @@ -629,42 +632,42 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Seek(microseconds_to_seek) - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) - after_seek = self.backend.playback.time_position.get() + after_seek = self.core.playback.time_position.get() self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) self.assertLess(after_seek, before_seek) self.assertGreaterEqual(after_seek, 0) def test_seek_skips_to_next_track_if_new_position_larger_than_track_length(self): - self.backend.current_playlist.append([Track(uri='a', length=40000), + self.core.current_playlist.append([Track(uri='a', length=40000), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.seek(20000) + self.core.playback.play() + self.core.playback.seek(20000) - before_seek = self.backend.playback.time_position.get() + before_seek = self.core.playback.time_position.get() self.assertGreaterEqual(before_seek, 20000) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') milliseconds_to_seek = 50000 microseconds_to_seek = milliseconds_to_seek * 1000 self.mpris.Seek(microseconds_to_seek) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'b') - after_seek = self.backend.playback.time_position.get() + after_seek = self.core.playback.time_position.get() self.assertGreaterEqual(after_seek, 0) self.assertLess(after_seek, before_seek) def test_set_position_is_ignored_if_can_seek_is_false(self): self.mpris.get_CanSeek = lambda *_: False - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() - before_set_position = self.backend.playback.time_position.get() + before_set_position = self.core.playback.time_position.get() self.assertLessEqual(before_set_position, 5000) track_id = 'a' @@ -674,17 +677,17 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.SetPosition(track_id, position_to_set_in_microseconds) - after_set_position = self.backend.playback.time_position.get() + after_set_position = self.core.playback.time_position.get() self.assertLessEqual(before_set_position, after_set_position) self.assertLess(after_set_position, position_to_set_in_milliseconds) def test_set_position_sets_the_current_track_position_in_microsecs(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() - before_set_position = self.backend.playback.time_position.get() + before_set_position = self.core.playback.time_position.get() self.assertLessEqual(before_set_position, 5000) - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) track_id = '/com/mopidy/track/0' @@ -693,21 +696,21 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.SetPosition(track_id, position_to_set_in_microseconds) - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) - after_set_position = self.backend.playback.time_position.get() + after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(after_set_position, position_to_set_in_milliseconds) def test_set_position_does_nothing_if_the_position_is_negative(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() - self.backend.playback.seek(20000) + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(20000) - before_set_position = self.backend.playback.time_position.get() + before_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(before_set_position, 20000) self.assertLessEqual(before_set_position, 25000) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') track_id = '/com/mopidy/track/0' @@ -716,21 +719,21 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.SetPosition(track_id, position_to_set_in_microseconds) - after_set_position = self.backend.playback.time_position.get() + after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(after_set_position, before_set_position) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') def test_set_position_does_nothing_if_position_is_larger_than_track_length(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() - self.backend.playback.seek(20000) + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(20000) - before_set_position = self.backend.playback.time_position.get() + before_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(before_set_position, 20000) self.assertLessEqual(before_set_position, 25000) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') track_id = 'a' @@ -739,21 +742,21 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.SetPosition(track_id, position_to_set_in_microseconds) - after_set_position = self.backend.playback.time_position.get() + after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(after_set_position, before_set_position) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') def test_set_position_does_nothing_if_track_id_does_not_match_current_track(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() - self.backend.playback.seek(20000) + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(20000) - before_set_position = self.backend.playback.time_position.get() + before_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(before_set_position, 20000) self.assertLessEqual(before_set_position, 25000) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') track_id = 'b' @@ -762,74 +765,74 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.SetPosition(track_id, position_to_set_in_microseconds) - after_set_position = self.backend.playback.time_position.get() + after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(after_set_position, before_set_position) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') def test_open_uri_is_ignored_if_can_play_is_false(self): self.mpris.get_CanPlay = lambda *_: False - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(len(self.backend.current_playlist.tracks.get()), 0) + self.assertEquals(len(self.core.current_playlist.tracks.get()), 0) def test_open_uri_ignores_uris_with_unknown_uri_scheme(self): - self.assertListEqual(self.backend.uri_schemes.get(), ['dummy']) + self.assertListEqual(self.core.uri_schemes.get(), ['dummy']) self.mpris.get_CanPlay = lambda *_: True - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(uri='notdummy:/test/uri')] self.mpris.OpenUri('notdummy:/test/uri') - self.assertEquals(len(self.backend.current_playlist.tracks.get()), 0) + self.assertEquals(len(self.core.current_playlist.tracks.get()), 0) def test_open_uri_adds_uri_to_current_playlist(self): self.mpris.get_CanPlay = lambda *_: True - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(self.backend.current_playlist.tracks.get()[0].uri, + self.assertEquals(self.core.current_playlist.tracks.get()[0].uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_stopped(self): self.mpris.get_CanPlay = lambda *_: True - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEquals(self.core.playback.state.get(), STOPPED) self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_paused(self): self.mpris.get_CanPlay = lambda *_: True - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.pause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.pause() + self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_playing(self): self.mpris.get_CanPlay = lambda *_: True - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'dummy:/test/uri') diff --git a/tests/frontends/mpris/root_interface_test.py b/tests/frontends/mpris/root_interface_test.py index 1e54fc15..b84b70c3 100644 --- a/tests/frontends/mpris/root_interface_test.py +++ b/tests/frontends/mpris/root_interface_test.py @@ -2,8 +2,10 @@ import sys import mock -from mopidy import OptionalDependencyError, settings -from mopidy.backends.dummy import DummyBackend +from pykka.registry import ActorRegistry + +from mopidy import core, settings, OptionalDependencyError +from mopidy.backends import dummy try: from mopidy.frontends.mpris import objects @@ -18,11 +20,12 @@ class RootInterfaceTest(unittest.TestCase): def setUp(self): objects.exit_process = mock.Mock() objects.MprisObject._connect_to_dbus = mock.Mock() - self.backend = DummyBackend.start().proxy() + self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.core = core.Core.start(backend=self.backend).proxy() self.mpris = objects.MprisObject() def tearDown(self): - self.backend.stop() + ActorRegistry.stop_all() def test_constructor_connects_to_dbus(self): self.assert_(self.mpris._connect_to_dbus.called) From 2fb878df2e346996b6b5a8cf7646245d4dd6f0c2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Sep 2012 20:18:22 +0200 Subject: [PATCH 055/233] MPD: Rename context.backend to context.core --- mopidy/frontends/mpd/dispatcher.py | 3 +- .../mpd/protocol/current_playlist.py | 84 +++++++++---------- mopidy/frontends/mpd/protocol/music_db.py | 10 +-- mopidy/frontends/mpd/protocol/playback.py | 72 ++++++++-------- mopidy/frontends/mpd/protocol/reflection.py | 2 +- mopidy/frontends/mpd/protocol/status.py | 26 +++--- .../mpd/protocol/stored_playlists.py | 10 +-- 7 files changed, 103 insertions(+), 104 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index c9dee576..1f2af153 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -235,11 +235,10 @@ class MpdContext(object): self._core = None @property - def backend(self): + def core(self): """ The Mopidy core. An instance of :class:`mopidy.core.Core`. """ - # TODO: Rename property to 'core' if self._core is None: core_refs = ActorRegistry.get_by_class(core.Core) assert len(core_refs) == 1, \ diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index c60cbc4a..622f79c9 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -20,11 +20,11 @@ def add(context, uri): """ if not uri: return - for uri_scheme in context.backend.uri_schemes.get(): + for uri_scheme in context.core.uri_schemes.get(): if uri.startswith(uri_scheme): - track = context.backend.library.lookup(uri).get() + track = context.core.library.lookup(uri).get() if track is not None: - context.backend.current_playlist.add(track) + context.core.current_playlist.add(track) return raise MpdNoExistError( u'directory or file not found', command=u'add') @@ -52,12 +52,12 @@ def addid(context, uri, songpos=None): raise MpdNoExistError(u'No such song', command=u'addid') if songpos is not None: songpos = int(songpos) - track = context.backend.library.lookup(uri).get() + track = context.core.library.lookup(uri).get() if track is None: raise MpdNoExistError(u'No such song', command=u'addid') - if songpos and songpos > context.backend.current_playlist.length.get(): + if songpos and songpos > context.core.current_playlist.length.get(): raise MpdArgError(u'Bad song index', command=u'addid') - cp_track = context.backend.current_playlist.add(track, + cp_track = context.core.current_playlist.add(track, at_position=songpos).get() return ('Id', cp_track.cpid) @@ -74,21 +74,21 @@ def delete_range(context, start, end=None): if end is not None: end = int(end) else: - end = context.backend.current_playlist.length.get() - cp_tracks = context.backend.current_playlist.slice(start, end).get() + end = context.core.current_playlist.length.get() + cp_tracks = context.core.current_playlist.slice(start, end).get() if not cp_tracks: raise MpdArgError(u'Bad song index', command=u'delete') for (cpid, _) in cp_tracks: - context.backend.current_playlist.remove(cpid=cpid) + context.core.current_playlist.remove(cpid=cpid) @handle_request(r'^delete "(?P\d+)"$') def delete_songpos(context, songpos): """See :meth:`delete_range`""" try: songpos = int(songpos) - (cpid, _) = context.backend.current_playlist.slice( + (cpid, _) = context.core.current_playlist.slice( songpos, songpos + 1).get()[0] - context.backend.current_playlist.remove(cpid=cpid) + context.core.current_playlist.remove(cpid=cpid) except IndexError: raise MpdArgError(u'Bad song index', command=u'delete') @@ -103,9 +103,9 @@ def deleteid(context, cpid): """ try: cpid = int(cpid) - if context.backend.playback.current_cpid.get() == cpid: - context.backend.playback.next() - return context.backend.current_playlist.remove(cpid=cpid).get() + if context.core.playback.current_cpid.get() == cpid: + context.core.playback.next() + return context.core.current_playlist.remove(cpid=cpid).get() except LookupError: raise MpdNoExistError(u'No such song', command=u'deleteid') @@ -118,7 +118,7 @@ def clear(context): Clears the current playlist. """ - context.backend.current_playlist.clear() + context.core.current_playlist.clear() @handle_request(r'^move "(?P\d+):(?P\d+)*" "(?P\d+)"$') def move_range(context, start, to, end=None): @@ -131,18 +131,18 @@ def move_range(context, start, to, end=None): ``TO`` in the playlist. """ if end is None: - end = context.backend.current_playlist.length.get() + end = context.core.current_playlist.length.get() start = int(start) end = int(end) to = int(to) - context.backend.current_playlist.move(start, end, to) + context.core.current_playlist.move(start, end, to) @handle_request(r'^move "(?P\d+)" "(?P\d+)"$') def move_songpos(context, songpos, to): """See :meth:`move_range`.""" songpos = int(songpos) to = int(to) - context.backend.current_playlist.move(songpos, songpos + 1, to) + context.core.current_playlist.move(songpos, songpos + 1, to) @handle_request(r'^moveid "(?P\d+)" "(?P\d+)"$') def moveid(context, cpid, to): @@ -157,9 +157,9 @@ def moveid(context, cpid, to): """ cpid = int(cpid) to = int(to) - cp_track = context.backend.current_playlist.get(cpid=cpid).get() - position = context.backend.current_playlist.index(cp_track).get() - context.backend.current_playlist.move(position, position + 1, to) + cp_track = context.core.current_playlist.get(cpid=cpid).get() + position = context.core.current_playlist.index(cp_track).get() + context.core.current_playlist.move(position, position + 1, to) @handle_request(r'^playlist$') def playlist(context): @@ -192,8 +192,8 @@ def playlistfind(context, tag, needle): """ if tag == 'filename': try: - cp_track = context.backend.current_playlist.get(uri=needle).get() - position = context.backend.current_playlist.index(cp_track).get() + cp_track = context.core.current_playlist.get(uri=needle).get() + position = context.core.current_playlist.index(cp_track).get() return track_to_mpd_format(cp_track, position=position) except LookupError: return None @@ -212,14 +212,14 @@ def playlistid(context, cpid=None): if cpid is not None: try: cpid = int(cpid) - cp_track = context.backend.current_playlist.get(cpid=cpid).get() - position = context.backend.current_playlist.index(cp_track).get() + cp_track = context.core.current_playlist.get(cpid=cpid).get() + position = context.core.current_playlist.index(cp_track).get() return track_to_mpd_format(cp_track, position=position) except LookupError: raise MpdNoExistError(u'No such song', command=u'playlistid') else: return tracks_to_mpd_format( - context.backend.current_playlist.cp_tracks.get()) + context.core.current_playlist.cp_tracks.get()) @handle_request(r'^playlistinfo$') @handle_request(r'^playlistinfo "-1"$') @@ -243,19 +243,19 @@ def playlistinfo(context, songpos=None, """ if songpos is not None: songpos = int(songpos) - cp_track = context.backend.current_playlist.cp_tracks.get()[songpos] + cp_track = context.core.current_playlist.cp_tracks.get()[songpos] return track_to_mpd_format(cp_track, position=songpos) else: if start is None: start = 0 start = int(start) - if not (0 <= start <= context.backend.current_playlist.length.get()): + if not (0 <= start <= context.core.current_playlist.length.get()): raise MpdArgError(u'Bad song index', command=u'playlistinfo') if end is not None: end = int(end) - if end > context.backend.current_playlist.length.get(): + if end > context.core.current_playlist.length.get(): end = None - cp_tracks = context.backend.current_playlist.cp_tracks.get() + cp_tracks = context.core.current_playlist.cp_tracks.get() return tracks_to_mpd_format(cp_tracks, start, end) @handle_request(r'^playlistsearch "(?P[^"]+)" "(?P[^"]+)"$') @@ -294,9 +294,9 @@ 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.backend.current_playlist.version: + if int(version) < context.core.current_playlist.version: return tracks_to_mpd_format( - context.backend.current_playlist.cp_tracks.get()) + context.core.current_playlist.cp_tracks.get()) @handle_request(r'^plchangesposid "(?P\d+)"$') def plchangesposid(context, version): @@ -313,10 +313,10 @@ def plchangesposid(context, version): ``playlistlength`` returned by status command. """ # XXX Naive implementation that returns all tracks as changed - if int(version) != context.backend.current_playlist.version.get(): + if int(version) != context.core.current_playlist.version.get(): result = [] for (position, (cpid, _)) in enumerate( - context.backend.current_playlist.cp_tracks.get()): + context.core.current_playlist.cp_tracks.get()): result.append((u'cpos', position)) result.append((u'Id', cpid)) return result @@ -336,7 +336,7 @@ def shuffle(context, start=None, end=None): start = int(start) if end is not None: end = int(end) - context.backend.current_playlist.shuffle(start, end) + context.core.current_playlist.shuffle(start, end) @handle_request(r'^swap "(?P\d+)" "(?P\d+)"$') def swap(context, songpos1, songpos2): @@ -349,15 +349,15 @@ def swap(context, songpos1, songpos2): """ songpos1 = int(songpos1) songpos2 = int(songpos2) - tracks = context.backend.current_playlist.tracks.get() + tracks = context.core.current_playlist.tracks.get() song1 = tracks[songpos1] song2 = tracks[songpos2] del tracks[songpos1] tracks.insert(songpos1, song2) del tracks[songpos2] tracks.insert(songpos2, song1) - context.backend.current_playlist.clear() - context.backend.current_playlist.append(tracks) + context.core.current_playlist.clear() + context.core.current_playlist.append(tracks) @handle_request(r'^swapid "(?P\d+)" "(?P\d+)"$') def swapid(context, cpid1, cpid2): @@ -370,8 +370,8 @@ def swapid(context, cpid1, cpid2): """ cpid1 = int(cpid1) cpid2 = int(cpid2) - cp_track1 = context.backend.current_playlist.get(cpid=cpid1).get() - cp_track2 = context.backend.current_playlist.get(cpid=cpid2).get() - position1 = context.backend.current_playlist.index(cp_track1).get() - position2 = context.backend.current_playlist.index(cp_track2).get() + cp_track1 = context.core.current_playlist.get(cpid=cpid1).get() + cp_track2 = context.core.current_playlist.get(cpid=cpid2).get() + position1 = context.core.current_playlist.index(cp_track1).get() + position2 = context.core.current_playlist.index(cp_track2).get() swap(context, position1, position2) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index d0128a1e..2678714a 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -70,7 +70,7 @@ def find(context, mpd_query): """ query = _build_query(mpd_query) return playlist_to_mpd_format( - context.backend.library.find_exact(**query).get()) + context.core.library.find_exact(**query).get()) @handle_request(r'^findadd ' r'(?P("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? ' @@ -223,7 +223,7 @@ def _list_build_query(field, mpd_query): def _list_artist(context, query): artists = set() - playlist = context.backend.library.find_exact(**query).get() + playlist = context.core.library.find_exact(**query).get() for track in playlist.tracks: for artist in track.artists: artists.add((u'Artist', artist.name)) @@ -231,7 +231,7 @@ def _list_artist(context, query): def _list_album(context, query): albums = set() - playlist = context.backend.library.find_exact(**query).get() + playlist = context.core.library.find_exact(**query).get() for track in playlist.tracks: if track.album is not None: albums.add((u'Album', track.album.name)) @@ -239,7 +239,7 @@ def _list_album(context, query): def _list_date(context, query): dates = set() - playlist = context.backend.library.find_exact(**query).get() + playlist = context.core.library.find_exact(**query).get() for track in playlist.tracks: if track.date is not None: dates.add((u'Date', track.date)) @@ -333,7 +333,7 @@ def search(context, mpd_query): """ query = _build_query(mpd_query) return playlist_to_mpd_format( - context.backend.library.search(**query).get()) + context.core.library.search(**query).get()) @handle_request(r'^update( "(?P[^"]+)")*$') def update(context, uri=None, rescan_unmodified_files=False): diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 4152f11e..76cefdc3 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -16,9 +16,9 @@ def consume(context, state): playlist. """ if int(state): - context.backend.playback.consume = True + context.core.playback.consume = True else: - context.backend.playback.consume = False + context.core.playback.consume = False @handle_request(r'^crossfade "(?P\d+)"$') def crossfade(context, seconds): @@ -87,7 +87,7 @@ def next_(context): order as the first time. """ - return context.backend.playback.next().get() + return context.core.playback.next().get() @handle_request(r'^pause$') @handle_request(r'^pause "(?P[01])"$') @@ -104,14 +104,14 @@ def pause(context, state=None): - Calls ``pause`` without any arguments to toogle pause. """ if state is None: - if (context.backend.playback.state.get() == PlaybackState.PLAYING): - context.backend.playback.pause() - elif (context.backend.playback.state.get() == PlaybackState.PAUSED): - context.backend.playback.resume() + if (context.core.playback.state.get() == PlaybackState.PLAYING): + context.core.playback.pause() + elif (context.core.playback.state.get() == PlaybackState.PAUSED): + context.core.playback.resume() elif int(state): - context.backend.playback.pause() + context.core.playback.pause() else: - context.backend.playback.resume() + context.core.playback.resume() @handle_request(r'^play$') def play(context): @@ -119,7 +119,7 @@ def play(context): The original MPD server resumes from the paused state on ``play`` without arguments. """ - return context.backend.playback.play().get() + return context.core.playback.play().get() @handle_request(r'^playid (?P-?\d+)$') @handle_request(r'^playid "(?P-?\d+)"$') @@ -144,8 +144,8 @@ def playid(context, cpid): if cpid == -1: return _play_minus_one(context) try: - cp_track = context.backend.current_playlist.get(cpid=cpid).get() - return context.backend.playback.play(cp_track).get() + cp_track = context.core.current_playlist.get(cpid=cpid).get() + return context.core.playback.play(cp_track).get() except LookupError: raise MpdNoExistError(u'No such song', command=u'playid') @@ -176,23 +176,23 @@ def playpos(context, songpos): if songpos == -1: return _play_minus_one(context) try: - cp_track = context.backend.current_playlist.slice( + cp_track = context.core.current_playlist.slice( songpos, songpos + 1).get()[0] - return context.backend.playback.play(cp_track).get() + return context.core.playback.play(cp_track).get() except IndexError: raise MpdArgError(u'Bad song index', command=u'play') def _play_minus_one(context): - if (context.backend.playback.state.get() == PlaybackState.PLAYING): + if (context.core.playback.state.get() == PlaybackState.PLAYING): return # Nothing to do - elif (context.backend.playback.state.get() == PlaybackState.PAUSED): - return context.backend.playback.resume().get() - elif context.backend.playback.current_cp_track.get() is not None: - cp_track = context.backend.playback.current_cp_track.get() - return context.backend.playback.play(cp_track).get() - elif context.backend.current_playlist.slice(0, 1).get(): - cp_track = context.backend.current_playlist.slice(0, 1).get()[0] - return context.backend.playback.play(cp_track).get() + elif (context.core.playback.state.get() == PlaybackState.PAUSED): + return context.core.playback.resume().get() + elif context.core.playback.current_cp_track.get() is not None: + cp_track = context.core.playback.current_cp_track.get() + return context.core.playback.play(cp_track).get() + elif context.core.current_playlist.slice(0, 1).get(): + cp_track = context.core.current_playlist.slice(0, 1).get()[0] + return context.core.playback.play(cp_track).get() else: return # Fail silently @@ -240,7 +240,7 @@ def previous(context): ``previous`` should do a seek to time position 0. """ - return context.backend.playback.previous().get() + return context.core.playback.previous().get() @handle_request(r'^random (?P[01])$') @handle_request(r'^random "(?P[01])"$') @@ -253,9 +253,9 @@ def random(context, state): Sets random state to ``STATE``, ``STATE`` should be 0 or 1. """ if int(state): - context.backend.playback.random = True + context.core.playback.random = True else: - context.backend.playback.random = False + context.core.playback.random = False @handle_request(r'^repeat (?P[01])$') @handle_request(r'^repeat "(?P[01])"$') @@ -268,9 +268,9 @@ def repeat(context, state): Sets repeat state to ``STATE``, ``STATE`` should be 0 or 1. """ if int(state): - context.backend.playback.repeat = True + context.core.playback.repeat = True else: - context.backend.playback.repeat = False + context.core.playback.repeat = False @handle_request(r'^replay_gain_mode "(?P(off|track|album))"$') def replay_gain_mode(context, mode): @@ -315,9 +315,9 @@ def seek(context, songpos, seconds): - issues ``seek 1 120`` without quotes around the arguments. """ - if context.backend.playback.current_playlist_position != songpos: + if context.core.playback.current_playlist_position != songpos: playpos(context, songpos) - context.backend.playback.seek(int(seconds) * 1000) + context.core.playback.seek(int(seconds) * 1000) @handle_request(r'^seekid "(?P\d+)" "(?P\d+)"$') def seekid(context, cpid, seconds): @@ -328,9 +328,9 @@ def seekid(context, cpid, seconds): Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. """ - if context.backend.playback.current_cpid != cpid: + if context.core.playback.current_cpid != cpid: playid(context, cpid) - context.backend.playback.seek(int(seconds) * 1000) + context.core.playback.seek(int(seconds) * 1000) @handle_request(r'^setvol (?P[-+]*\d+)$') @handle_request(r'^setvol "(?P[-+]*\d+)"$') @@ -351,7 +351,7 @@ def setvol(context, volume): volume = 0 if volume > 100: volume = 100 - context.backend.playback.volume = volume + context.core.playback.volume = volume @handle_request(r'^single (?P[01])$') @handle_request(r'^single "(?P[01])"$') @@ -366,9 +366,9 @@ def single(context, state): song is repeated if the ``repeat`` mode is enabled. """ if int(state): - context.backend.playback.single = True + context.core.playback.single = True else: - context.backend.playback.single = False + context.core.playback.single = False @handle_request(r'^stop$') def stop(context): @@ -379,4 +379,4 @@ def stop(context): Stops playing. """ - context.backend.playback.stop() + context.core.playback.stop() diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index df13b4b4..8cd1337b 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -84,4 +84,4 @@ def urlhandlers(context): Gets a list of available URL handlers. """ return [(u'handler', uri_scheme) - for uri_scheme in context.backend.uri_schemes.get()] + for uri_scheme in context.core.uri_schemes.get()] diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index fc24e1e1..4f48265c 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -31,9 +31,9 @@ def currentsong(context): Displays the song info of the current song (same song that is identified in status). """ - current_cp_track = context.backend.playback.current_cp_track.get() + current_cp_track = context.core.playback.current_cp_track.get() if current_cp_track is not None: - position = context.backend.playback.current_playlist_position.get() + position = context.core.playback.current_playlist_position.get() return track_to_mpd_format(current_cp_track, position=position) @handle_request(r'^idle$') @@ -166,18 +166,18 @@ def status(context): decimal places for millisecond precision. """ futures = { - 'current_playlist.length': context.backend.current_playlist.length, - 'current_playlist.version': context.backend.current_playlist.version, - 'playback.volume': context.backend.playback.volume, - 'playback.consume': context.backend.playback.consume, - 'playback.random': context.backend.playback.random, - 'playback.repeat': context.backend.playback.repeat, - 'playback.single': context.backend.playback.single, - 'playback.state': context.backend.playback.state, - 'playback.current_cp_track': context.backend.playback.current_cp_track, + 'current_playlist.length': context.core.current_playlist.length, + 'current_playlist.version': context.core.current_playlist.version, + 'playback.volume': context.core.playback.volume, + 'playback.consume': context.core.playback.consume, + 'playback.random': context.core.playback.random, + 'playback.repeat': context.core.playback.repeat, + 'playback.single': context.core.playback.single, + 'playback.state': context.core.playback.state, + 'playback.current_cp_track': context.core.playback.current_cp_track, 'playback.current_playlist_position': - context.backend.playback.current_playlist_position, - 'playback.time_position': context.backend.playback.time_position, + context.core.playback.current_playlist_position, + 'playback.time_position': context.core.playback.time_position, } pykka.future.get_all(futures.values()) result = [ diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index bb39d328..c21f4714 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -20,7 +20,7 @@ def listplaylist(context, name): file: relative/path/to/file3.mp3 """ try: - playlist = context.backend.stored_playlists.get(name=name).get() + playlist = context.core.stored_playlists.get(name=name).get() return ['file: %s' % t.uri for t in playlist.tracks] except LookupError: raise MpdNoExistError(u'No such playlist', command=u'listplaylist') @@ -40,7 +40,7 @@ def listplaylistinfo(context, name): Album, Artist, Track """ try: - playlist = context.backend.stored_playlists.get(name=name).get() + playlist = context.core.stored_playlists.get(name=name).get() return playlist_to_mpd_format(playlist) except LookupError: raise MpdNoExistError( @@ -68,7 +68,7 @@ def listplaylists(context): Last-Modified: 2010-02-06T02:11:08Z """ result = [] - for playlist in context.backend.stored_playlists.playlists.get(): + for playlist in context.core.stored_playlists.playlists.get(): result.append((u'playlist', playlist.name)) last_modified = (playlist.last_modified or dt.datetime.now()).isoformat() @@ -94,8 +94,8 @@ def load(context, name): - ``load`` appends the given playlist to the current playlist. """ try: - playlist = context.backend.stored_playlists.get(name=name).get() - context.backend.current_playlist.append(playlist.tracks) + playlist = context.core.stored_playlists.get(name=name).get() + context.core.current_playlist.append(playlist.tracks) except LookupError: raise MpdNoExistError(u'No such playlist', command=u'load') From 5a628a4150cf43fe4bfab5a55f2c67b2ee25a7ce Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Sep 2012 20:18:35 +0200 Subject: [PATCH 056/233] MPRIS: Rename self.backend to self.core --- mopidy/frontends/mpris/objects.py | 91 +++++++++++++++---------------- 1 file changed, 45 insertions(+), 46 deletions(-) diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index c2c9f527..cb1e73eb 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -84,8 +84,7 @@ class MprisObject(dbus.service.Object): return bus_name @property - def backend(self): - # TODO: Rename property to 'core' + def core(self): if self._core is None: core_refs = ActorRegistry.get_by_class(core.Core) assert len(core_refs) == 1, \ @@ -162,7 +161,7 @@ class MprisObject(dbus.service.Object): return os.path.splitext(os.path.basename(settings.DESKTOP_FILE))[0] def get_SupportedUriSchemes(self): - return dbus.Array(self.backend.uri_schemes.get(), signature='s') + return dbus.Array(self.core.uri_schemes.get(), signature='s') ### Player interface methods @@ -173,7 +172,7 @@ class MprisObject(dbus.service.Object): if not self.get_CanGoNext(): logger.debug(u'%s.Next not allowed', PLAYER_IFACE) return - self.backend.playback.next().get() + self.core.playback.next().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def Previous(self): @@ -181,7 +180,7 @@ class MprisObject(dbus.service.Object): if not self.get_CanGoPrevious(): logger.debug(u'%s.Previous not allowed', PLAYER_IFACE) return - self.backend.playback.previous().get() + self.core.playback.previous().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def Pause(self): @@ -189,7 +188,7 @@ class MprisObject(dbus.service.Object): if not self.get_CanPause(): logger.debug(u'%s.Pause not allowed', PLAYER_IFACE) return - self.backend.playback.pause().get() + self.core.playback.pause().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def PlayPause(self): @@ -197,13 +196,13 @@ class MprisObject(dbus.service.Object): if not self.get_CanPause(): logger.debug(u'%s.PlayPause not allowed', PLAYER_IFACE) return - state = self.backend.playback.state.get() + state = self.core.playback.state.get() if state == PlaybackState.PLAYING: - self.backend.playback.pause().get() + self.core.playback.pause().get() elif state == PlaybackState.PAUSED: - self.backend.playback.resume().get() + self.core.playback.resume().get() elif state == PlaybackState.STOPPED: - self.backend.playback.play().get() + self.core.playback.play().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def Stop(self): @@ -211,7 +210,7 @@ class MprisObject(dbus.service.Object): if not self.get_CanControl(): logger.debug(u'%s.Stop not allowed', PLAYER_IFACE) return - self.backend.playback.stop().get() + self.core.playback.stop().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def Play(self): @@ -219,11 +218,11 @@ class MprisObject(dbus.service.Object): if not self.get_CanPlay(): logger.debug(u'%s.Play not allowed', PLAYER_IFACE) return - state = self.backend.playback.state.get() + state = self.core.playback.state.get() if state == PlaybackState.PAUSED: - self.backend.playback.resume().get() + self.core.playback.resume().get() else: - self.backend.playback.play().get() + self.core.playback.play().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def Seek(self, offset): @@ -232,9 +231,9 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.Seek not allowed', PLAYER_IFACE) return offset_in_milliseconds = offset // 1000 - current_position = self.backend.playback.time_position.get() + current_position = self.core.playback.time_position.get() new_position = current_position + offset_in_milliseconds - self.backend.playback.seek(new_position) + self.core.playback.seek(new_position) @dbus.service.method(dbus_interface=PLAYER_IFACE) def SetPosition(self, track_id, position): @@ -243,7 +242,7 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.SetPosition not allowed', PLAYER_IFACE) return position = position // 1000 - current_cp_track = self.backend.playback.current_cp_track.get() + current_cp_track = self.core.playback.current_cp_track.get() if current_cp_track is None: return if track_id != self._get_track_id(current_cp_track): @@ -252,7 +251,7 @@ class MprisObject(dbus.service.Object): return if current_cp_track.track.length < position: return - self.backend.playback.seek(position) + self.core.playback.seek(position) @dbus.service.method(dbus_interface=PLAYER_IFACE) def OpenUri(self, uri): @@ -264,13 +263,13 @@ class MprisObject(dbus.service.Object): return # NOTE Check if URI has MIME type known to the backend, if MIME support # is added to the backend. - uri_schemes = self.backend.uri_schemes.get() + uri_schemes = self.core.uri_schemes.get() if not any([uri.startswith(uri_scheme) for uri_scheme in uri_schemes]): return - track = self.backend.library.lookup(uri).get() + track = self.core.library.lookup(uri).get() if track is not None: - cp_track = self.backend.current_playlist.add(track).get() - self.backend.playback.play(cp_track) + cp_track = self.core.current_playlist.add(track).get() + self.core.playback.play(cp_track) else: logger.debug(u'Track with URI "%s" not found in library.', uri) @@ -286,7 +285,7 @@ class MprisObject(dbus.service.Object): ### Player interface properties def get_PlaybackStatus(self): - state = self.backend.playback.state.get() + state = self.core.playback.state.get() if state == PlaybackState.PLAYING: return 'Playing' elif state == PlaybackState.PAUSED: @@ -295,8 +294,8 @@ class MprisObject(dbus.service.Object): return 'Stopped' def get_LoopStatus(self): - repeat = self.backend.playback.repeat.get() - single = self.backend.playback.single.get() + repeat = self.core.playback.repeat.get() + single = self.core.playback.single.get() if not repeat: return 'None' else: @@ -310,14 +309,14 @@ class MprisObject(dbus.service.Object): logger.debug(u'Setting %s.LoopStatus not allowed', PLAYER_IFACE) return if value == 'None': - self.backend.playback.repeat = False - self.backend.playback.single = False + self.core.playback.repeat = False + self.core.playback.single = False elif value == 'Track': - self.backend.playback.repeat = True - self.backend.playback.single = True + self.core.playback.repeat = True + self.core.playback.single = True elif value == 'Playlist': - self.backend.playback.repeat = True - self.backend.playback.single = False + self.core.playback.repeat = True + self.core.playback.single = False def set_Rate(self, value): if not self.get_CanControl(): @@ -329,19 +328,19 @@ class MprisObject(dbus.service.Object): self.Pause() def get_Shuffle(self): - return self.backend.playback.random.get() + return self.core.playback.random.get() def set_Shuffle(self, value): if not self.get_CanControl(): logger.debug(u'Setting %s.Shuffle not allowed', PLAYER_IFACE) return if value: - self.backend.playback.random = True + self.core.playback.random = True else: - self.backend.playback.random = False + self.core.playback.random = False def get_Metadata(self): - current_cp_track = self.backend.playback.current_cp_track.get() + current_cp_track = self.core.playback.current_cp_track.get() if current_cp_track is None: return {'mpris:trackid': ''} else: @@ -370,7 +369,7 @@ class MprisObject(dbus.service.Object): return dbus.Dictionary(metadata, signature='sv') def get_Volume(self): - volume = self.backend.playback.volume.get() + volume = self.core.playback.volume.get() if volume is None: return 0 return volume / 100.0 @@ -382,32 +381,32 @@ class MprisObject(dbus.service.Object): if value is None: return elif value < 0: - self.backend.playback.volume = 0 + self.core.playback.volume = 0 elif value > 1: - self.backend.playback.volume = 100 + self.core.playback.volume = 100 elif 0 <= value <= 1: - self.backend.playback.volume = int(value * 100) + self.core.playback.volume = int(value * 100) def get_Position(self): - return self.backend.playback.time_position.get() * 1000 + return self.core.playback.time_position.get() * 1000 def get_CanGoNext(self): if not self.get_CanControl(): return False - return (self.backend.playback.cp_track_at_next.get() != - self.backend.playback.current_cp_track.get()) + return (self.core.playback.cp_track_at_next.get() != + self.core.playback.current_cp_track.get()) def get_CanGoPrevious(self): if not self.get_CanControl(): return False - return (self.backend.playback.cp_track_at_previous.get() != - self.backend.playback.current_cp_track.get()) + return (self.core.playback.cp_track_at_previous.get() != + self.core.playback.current_cp_track.get()) def get_CanPlay(self): if not self.get_CanControl(): return False - return (self.backend.playback.current_track.get() is not None - or self.backend.playback.track_at_next.get() is not None) + return (self.core.playback.current_track.get() is not None + or self.core.playback.track_at_next.get() is not None) def get_CanPause(self): if not self.get_CanControl(): From e7f08a7a20e3493e1c35561d293423659f0c3977 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Sep 2012 20:31:41 +0200 Subject: [PATCH 057/233] Rename mopidy.{listeners.BackendListener => core.CoreListener} --- docs/api/core.rst | 7 +++++++ docs/api/index.rst | 1 - docs/api/listeners.rst | 7 ------- mopidy/core/__init__.py | 1 + mopidy/core/current_playlist.py | 5 +++-- mopidy/{listeners.py => core/listener.py} | 11 ++++++----- mopidy/core/playback.py | 16 ++++++++-------- mopidy/frontends/lastfm.py | 7 ++++--- mopidy/frontends/mpd/__init__.py | 6 ++++-- mopidy/frontends/mpris/__init__.py | 5 ++--- tests/backends/events_test.py | 3 +-- .../{listeners_test.py => core/listener_test.py} | 7 +++---- 12 files changed, 39 insertions(+), 37 deletions(-) delete mode 100644 docs/api/listeners.rst rename mopidy/{listeners.py => core/listener.py} (92%) rename tests/{listeners_test.py => core/listener_test.py} (87%) diff --git a/docs/api/core.rst b/docs/api/core.rst index e74d9f45..1563b61b 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -48,3 +48,10 @@ Manages the music library, e.g. searching for tracks to be added to a playlist. .. autoclass:: mopidy.core.LibraryController :members: + + +Core listener +============= + +.. autoclass:: mopidy.core.CoreListener + :members: diff --git a/docs/api/index.rst b/docs/api/index.rst index 618096ee..5a210812 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -11,4 +11,3 @@ API reference core audio frontends - listeners diff --git a/docs/api/listeners.rst b/docs/api/listeners.rst deleted file mode 100644 index 609dc3c7..00000000 --- a/docs/api/listeners.rst +++ /dev/null @@ -1,7 +0,0 @@ -************ -Listener API -************ - -.. automodule:: mopidy.listeners - :synopsis: Listener API - :members: diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py index 6070dcc8..28274fe3 100644 --- a/mopidy/core/__init__.py +++ b/mopidy/core/__init__.py @@ -1,5 +1,6 @@ from .actor import Core from .current_playlist import CurrentPlaylistController from .library import LibraryController +from .listener import CoreListener from .playback import PlaybackController, PlaybackState from .stored_playlists import StoredPlaylistsController diff --git a/mopidy/core/current_playlist.py b/mopidy/core/current_playlist.py index a39b4c39..973fe71f 100644 --- a/mopidy/core/current_playlist.py +++ b/mopidy/core/current_playlist.py @@ -2,9 +2,10 @@ from copy import copy import logging import random -from mopidy.listeners import BackendListener from mopidy.models import CpTrack +from .listener import CoreListener + logger = logging.getLogger('mopidy.core') @@ -240,4 +241,4 @@ class CurrentPlaylistController(object): def _trigger_playlist_changed(self): logger.debug(u'Triggering playlist changed event') - BackendListener.send('playlist_changed') + CoreListener.send('playlist_changed') diff --git a/mopidy/listeners.py b/mopidy/core/listener.py similarity index 92% rename from mopidy/listeners.py rename to mopidy/core/listener.py index a8794232..a77b29a8 100644 --- a/mopidy/listeners.py +++ b/mopidy/core/listener.py @@ -1,11 +1,12 @@ from pykka import registry -class BackendListener(object): + +class CoreListener(object): """ - Marker interface for recipients of events sent by the backend. + Marker interface for recipients of events sent by the core actor. Any Pykka actor that mixes in this class will receive calls to the methods - defined here when the corresponding events happen in the backend. This + defined here when the corresponding events happen in the core actor. This interface is used both for looking up what actors to notify of the events, and for providing default implementations for those listeners that are not interested in all events. @@ -13,7 +14,7 @@ class BackendListener(object): @staticmethod def send(event, **kwargs): - """Helper to allow calling of backend listener events""" + """Helper to allow calling of core listener events""" # FIXME this should be updated once Pykka supports non-blocking calls # on proxies or some similar solution. registry.ActorRegistry.broadcast({ @@ -21,7 +22,7 @@ class BackendListener(object): 'attr_path': (event,), 'args': [], 'kwargs': kwargs, - }, target_class=BackendListener) + }, target_class=CoreListener) def track_playback_paused(self, track, time_position): """ diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index efba03dd..603b40a4 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -1,7 +1,7 @@ import logging import random -from mopidy.listeners import BackendListener +from .listener import CoreListener logger = logging.getLogger('mopidy.backends.base') @@ -479,7 +479,7 @@ class PlaybackController(object): logger.debug(u'Triggering track playback paused event') if self.current_track is None: return - BackendListener.send('track_playback_paused', + CoreListener.send('track_playback_paused', track=self.current_track, time_position=self.time_position) @@ -487,7 +487,7 @@ class PlaybackController(object): logger.debug(u'Triggering track playback resumed event') if self.current_track is None: return - BackendListener.send('track_playback_resumed', + CoreListener.send('track_playback_resumed', track=self.current_track, time_position=self.time_position) @@ -495,26 +495,26 @@ class PlaybackController(object): logger.debug(u'Triggering track playback started event') if self.current_track is None: return - BackendListener.send('track_playback_started', + CoreListener.send('track_playback_started', track=self.current_track) def _trigger_track_playback_ended(self): logger.debug(u'Triggering track playback ended event') if self.current_track is None: return - BackendListener.send('track_playback_ended', + CoreListener.send('track_playback_ended', track=self.current_track, time_position=self.time_position) def _trigger_playback_state_changed(self, old_state, new_state): logger.debug(u'Triggering playback state change event') - BackendListener.send('playback_state_changed', + CoreListener.send('playback_state_changed', old_state=old_state, new_state=new_state) def _trigger_options_changed(self): logger.debug(u'Triggering options changed event') - BackendListener.send('options_changed') + CoreListener.send('options_changed') def _trigger_seeked(self, time_position): logger.debug(u'Triggering seeked event') - BackendListener.send('seeked', time_position=time_position) + CoreListener.send('seeked', time_position=time_position) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 0e79024b..f2bc44d2 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -9,15 +9,16 @@ except ImportError as import_error: from pykka.actor import ThreadingActor -from mopidy import settings, SettingsError -from mopidy.listeners import BackendListener +from mopidy import core, settings, SettingsError + logger = logging.getLogger('mopidy.frontends.lastfm') API_KEY = '2236babefa8ebb3d93ea467560d00d04' API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd' -class LastfmFrontend(ThreadingActor, BackendListener): + +class LastfmFrontend(ThreadingActor, core.CoreListener): """ Frontend which scrobbles the music you play to your `Last.fm `_ profile. diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 5d287d03..9dcf4c34 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -3,13 +3,15 @@ import sys from pykka import registry, actor -from mopidy import listeners, settings +from mopidy import core, settings from mopidy.frontends.mpd import dispatcher, protocol from mopidy.utils import locale_decode, log, network, process + logger = logging.getLogger('mopidy.frontends.mpd') -class MpdFrontend(actor.ThreadingActor, listeners.BackendListener): + +class MpdFrontend(actor.ThreadingActor, core.CoreListener): """ The MPD frontend. diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 4d4d5edb..2815c551 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -10,12 +10,11 @@ except ImportError as import_error: from pykka.actor import ThreadingActor -from mopidy import settings +from mopidy import core, settings from mopidy.frontends.mpris import objects -from mopidy.listeners import BackendListener -class MprisFrontend(ThreadingActor, BackendListener): +class MprisFrontend(ThreadingActor, core.CoreListener): """ Frontend which lets you control Mopidy through the Media Player Remote Interfacing Specification (`MPRIS `_) D-Bus diff --git a/tests/backends/events_test.py b/tests/backends/events_test.py index 5408d71f..200e0ca2 100644 --- a/tests/backends/events_test.py +++ b/tests/backends/events_test.py @@ -4,13 +4,12 @@ from pykka.registry import ActorRegistry from mopidy import audio, core from mopidy.backends import dummy -from mopidy.listeners import BackendListener from mopidy.models import Track from tests import unittest -@mock.patch.object(BackendListener, 'send') +@mock.patch.object(core.CoreListener, 'send') class BackendEventsTest(unittest.TestCase): def setUp(self): self.audio = mock.Mock(spec=audio.Audio) diff --git a/tests/listeners_test.py b/tests/core/listener_test.py similarity index 87% rename from tests/listeners_test.py rename to tests/core/listener_test.py index 896fedf0..2abd9479 100644 --- a/tests/listeners_test.py +++ b/tests/core/listener_test.py @@ -1,13 +1,12 @@ -from mopidy.core import PlaybackState -from mopidy.listeners import BackendListener +from mopidy.core import CoreListener, PlaybackState from mopidy.models import Track from tests import unittest -class BackendListenerTest(unittest.TestCase): +class CoreListenerTest(unittest.TestCase): def setUp(self): - self.listener = BackendListener() + self.listener = CoreListener() def test_listener_has_default_impl_for_track_playback_paused(self): self.listener.track_playback_paused(Track(), 0) From 8c78d469e29b54d6ab5c085e6246348050f46009 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Sep 2012 22:18:39 +0200 Subject: [PATCH 058/233] Use Pykka proxies to send events With Pykka >= 0.16, sending events can be done using proxies instead of manually crafting Pykka's internal function call messages. --- docs/changes.rst | 4 +++- docs/installation/index.rst | 2 +- mopidy/core/listener.py | 14 ++++---------- requirements/core.txt | 2 +- 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index caec53ba..17d50072 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -8,7 +8,9 @@ This change log is used to track all major changes to Mopidy. v0.9.0 (in development) ======================= -- Nothing so far. +**Dependencies** + +- Pykka >= 0.16 is now required. v0.8.0 (2012-09-20) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 66b920f8..d5728c00 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -26,7 +26,7 @@ dependencies installed. - Python >= 2.6, < 3 - - Pykka >= 0.12.3:: + - Pykka >= 0.16:: sudo pip install -U pykka diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index a77b29a8..9476ac4f 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -1,4 +1,4 @@ -from pykka import registry +from pykka.registry import ActorRegistry class CoreListener(object): @@ -15,14 +15,9 @@ class CoreListener(object): @staticmethod def send(event, **kwargs): """Helper to allow calling of core listener events""" - # FIXME this should be updated once Pykka supports non-blocking calls - # on proxies or some similar solution. - registry.ActorRegistry.broadcast({ - 'command': 'pykka_call', - 'attr_path': (event,), - 'args': [], - 'kwargs': kwargs, - }, target_class=CoreListener) + listeners = ActorRegistry.get_by_class(CoreListener) + for listener in listeners: + getattr(listener.proxy(), event)(**kwargs) def track_playback_paused(self, track, time_position): """ @@ -50,7 +45,6 @@ class CoreListener(object): """ pass - def track_playback_started(self, track): """ Called whenever a new track starts playing. diff --git a/requirements/core.txt b/requirements/core.txt index 8f9da622..1c2371f3 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -1 +1 @@ -Pykka >= 0.12.3 +Pykka >= 0.16 From 4b13f46e2e9c1f8fd85a03cb23f27ddb4edc74ae Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Sep 2012 23:17:57 +0200 Subject: [PATCH 059/233] Add AudioListener for events from the audio actor This is analogous to how the core actor sends events to the frontends. This removes the audio actor's direct dependency on the core actor, which conceptually is on a higher layer. --- docs/api/audio.rst | 13 ++++++++++--- mopidy/audio/__init__.py | 19 +++++++------------ mopidy/audio/listener.py | 28 ++++++++++++++++++++++++++++ mopidy/core/actor.py | 7 ++++++- 4 files changed, 51 insertions(+), 16 deletions(-) create mode 100644 mopidy/audio/listener.py diff --git a/docs/api/audio.rst b/docs/api/audio.rst index d5fb5dd9..e00772fd 100644 --- a/docs/api/audio.rst +++ b/docs/api/audio.rst @@ -10,10 +10,17 @@ the URI of the resource they want to play, for these cases the default playback provider should be used. For more advanced cases such as when the raw audio data is delivered outside of -GStreamer or the backend needs to add metadata to the currently playing resource, -developers should sub-class the base playback provider and implement the extra -behaviour that is needed through the following API: +GStreamer or the backend needs to add metadata to the currently playing +resource, developers should sub-class the base playback provider and implement +the extra behaviour that is needed through the following API: .. autoclass:: mopidy.audio.Audio :members: + + +Audio listener +============== + +.. autoclass:: mopidy.audio.AudioListener + :members: diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 3ce459dd..4abd5774 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -6,13 +6,13 @@ import gobject import logging from pykka.actor import ThreadingActor -from pykka.registry import ActorRegistry -from mopidy import core, settings, utils +from mopidy import settings, utils from mopidy.utils import process # Trigger install of gst mixer plugins -from mopidy.audio import mixers +from . import mixers +from .listener import AudioListener logger = logging.getLogger('mopidy.audio') @@ -149,7 +149,7 @@ class Audio(ThreadingActor): def _on_message(self, bus, message): if message.type == gst.MESSAGE_EOS: - self._notify_core_of_eos() + self._trigger_reached_end_of_stream_event() elif message.type == gst.MESSAGE_ERROR: error, debug = message.parse_error() logger.error(u'%s %s', error, debug) @@ -158,14 +158,9 @@ class Audio(ThreadingActor): error, debug = message.parse_warning() logger.warning(u'%s %s', error, debug) - def _notify_core_of_eos(self): - core_refs = ActorRegistry.get_by_class(core.Core) - assert len(core_refs) <= 1, 'Expected at most one running core instance' - if core_refs: - logger.debug(u'Notifying core of end-of-stream') - core_refs[0].proxy().playback.on_end_of_track() - else: - logger.debug(u'No core instance to notify of end-of-stream found') + def _trigger_reached_end_of_stream_event(self): + logger.debug(u'Triggering reached end of stream event') + AudioListener.send('reached_end_of_stream') def set_uri(self, uri): """ diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py new file mode 100644 index 00000000..757cd5f4 --- /dev/null +++ b/mopidy/audio/listener.py @@ -0,0 +1,28 @@ +from pykka.registry import ActorRegistry + + +class AudioListener(object): + """ + Marker interface for recipients of events sent by the audio actor. + + Any Pykka actor that mixes in this class will receive calls to the methods + defined here when the corresponding events happen in the core actor. This + interface is used both for looking up what actors to notify of the events, + and for providing default implementations for those listeners that are not + interested in all events. + """ + + @staticmethod + def send(event, **kwargs): + """Helper to allow calling of audio listener events""" + listeners = ActorRegistry.get_by_class(AudioListener) + for listener in listeners: + getattr(listener.proxy(), event)(**kwargs) + + def reached_end_of_stream(self): + """ + Called whenever the end of the audio stream is reached. + + *MAY* be implemented by actor. + """ + pass diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 4ff378c4..4ec86e8b 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -1,12 +1,14 @@ from pykka.actor import ThreadingActor +from mopidy import audio + from .current_playlist import CurrentPlaylistController from .library import LibraryController from .playback import PlaybackController from .stored_playlists import StoredPlaylistsController -class Core(ThreadingActor): +class Core(ThreadingActor, audio.AudioListener): #: The current playlist controller. An instance of #: :class:`mopidy.core.CurrentPlaylistController`. current_playlist = None @@ -40,3 +42,6 @@ class Core(ThreadingActor): def uri_schemes(self): """List of URI schemes we can handle""" return self._backend.uri_schemes.get() + + def reached_end_of_stream(self): + self.playback.on_end_of_track() From 9798c34e79eccf45c5ffbadc25fd631c30a7d7bc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 00:14:40 +0200 Subject: [PATCH 060/233] Remove unused variable --- mopidy/audio/__init__.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 4abd5774..10a74959 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -32,15 +32,6 @@ class Audio(ThreadingActor): def __init__(self): super(Audio, self).__init__() - self._default_caps = gst.Caps(""" - audio/x-raw-int, - endianness=(int)1234, - channels=(int)2, - width=(int)16, - depth=(int)16, - signed=(boolean)true, - rate=(int)44100""") - self._playbin = None self._mixer = None self._mixer_track = None From 63cd153b1b2b3a58a4b5741c935c86735928f56f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 00:45:09 +0200 Subject: [PATCH 061/233] Let NetworkServer pass protocol_kwargs on --- mopidy/utils/network.py | 19 +++++++++++++++---- tests/utils/network/connection_test.py | 13 ++++++++----- tests/utils/network/server_test.py | 3 ++- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 9cb8d74c..d2e0690b 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -11,11 +11,14 @@ from pykka.registry import ActorRegistry from mopidy.utils import locale_decode + logger = logging.getLogger('mopidy.utils.server') + class ShouldRetrySocketCall(Exception): """Indicate that attempted socket call should be retried""" + def try_ipv6_socket(): """Determine if system really supports IPv6""" if not socket.has_ipv6: @@ -28,9 +31,11 @@ def try_ipv6_socket(): 'creation failed, disabling: %s', locale_decode(error)) return False + #: Boolean value that indicates if creating an IPv6 socket will succeed. has_ipv6 = try_ipv6_socket() + def create_socket(): """Create a TCP socket with or without IPv6 depending on system support""" if has_ipv6: @@ -42,17 +47,21 @@ def create_socket(): sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) return sock + def format_hostname(hostname): """Format hostname for display.""" if (has_ipv6 and re.match('\d+.\d+.\d+.\d+', hostname) is not None): hostname = '::ffff:%s' % hostname return hostname + class Server(object): """Setup listener and register it with gobject's event loop.""" - def __init__(self, host, port, protocol, max_connections=5, timeout=30): + def __init__(self, host, port, protocol, protocol_kwargs=None, + max_connections=5, timeout=30): self.protocol = protocol + self.protocol_kwargs = protocol_kwargs or {} self.max_connections = max_connections self.timeout = timeout self.server_socket = self.create_server_socket(host, port) @@ -105,7 +114,8 @@ class Server(object): pass def init_connection(self, sock, addr): - Connection(self.protocol, sock, addr, self.timeout) + Connection(self.protocol, self.protocol_kwargs, + sock, addr, self.timeout) class Connection(object): @@ -117,13 +127,14 @@ class Connection(object): # false return value would only tell us that what we thought was registered # is already gone, there is really nothing more we can do. - def __init__(self, protocol, sock, addr, timeout): + def __init__(self, protocol, protocol_kwargs, sock, addr, timeout): sock.setblocking(False) self.host, self.port = addr[:2] # IPv6 has larger addr self.sock = sock self.protocol = protocol + self.protocol_kwargs = protocol_kwargs self.timeout = timeout self.send_lock = threading.Lock() @@ -135,7 +146,7 @@ class Connection(object): self.send_id = None self.timeout_id = None - self.actor_ref = self.protocol.start(self) + self.actor_ref = self.protocol.start(self, **self.protocol_kwargs) self.enable_recv() self.enable_timeout() diff --git a/tests/utils/network/connection_test.py b/tests/utils/network/connection_test.py index 96ddb833..25ae1940 100644 --- a/tests/utils/network/connection_test.py +++ b/tests/utils/network/connection_test.py @@ -17,19 +17,19 @@ class ConnectionTest(unittest.TestCase): def test_init_ensure_nonblocking_io(self): sock = Mock(spec=socket.SocketType) - network.Connection.__init__(self.mock, Mock(), sock, + network.Connection.__init__(self.mock, Mock(), {}, sock, (sentinel.host, sentinel.port), sentinel.timeout) sock.setblocking.assert_called_once_with(False) def test_init_starts_actor(self): protocol = Mock(spec=network.LineProtocol) - network.Connection.__init__(self.mock, protocol, Mock(), + network.Connection.__init__(self.mock, protocol, {}, Mock(), (sentinel.host, sentinel.port), sentinel.timeout) protocol.start.assert_called_once_with(self.mock) def test_init_enables_recv_and_timeout(self): - network.Connection.__init__(self.mock, Mock(), Mock(), + network.Connection.__init__(self.mock, Mock(), {}, Mock(), (sentinel.host, sentinel.port), sentinel.timeout) self.mock.enable_recv.assert_called_once_with() self.mock.enable_timeout.assert_called_once_with() @@ -37,12 +37,14 @@ class ConnectionTest(unittest.TestCase): def test_init_stores_values_in_attributes(self): addr = (sentinel.host, sentinel.port) protocol = Mock(spec=network.LineProtocol) + protocol_kwargs = {} sock = Mock(spec=socket.SocketType) network.Connection.__init__( - self.mock, protocol, sock, addr, sentinel.timeout) + self.mock, protocol, protocol_kwargs, sock, addr, sentinel.timeout) self.assertEqual(sock, self.mock.sock) self.assertEqual(protocol, self.mock.protocol) + self.assertEqual(protocol_kwargs, self.mock.protocol_kwargs) self.assertEqual(sentinel.timeout, self.mock.timeout) self.assertEqual(sentinel.host, self.mock.host) self.assertEqual(sentinel.port, self.mock.port) @@ -51,10 +53,11 @@ class ConnectionTest(unittest.TestCase): addr = (sentinel.host, sentinel.port, sentinel.flowinfo, sentinel.scopeid) protocol = Mock(spec=network.LineProtocol) + protocol_kwargs = {} sock = Mock(spec=socket.SocketType) network.Connection.__init__( - self.mock, protocol, sock, addr, sentinel.timeout) + self.mock, protocol, protocol_kwargs, sock, addr, sentinel.timeout) self.assertEqual(sentinel.host, self.mock.host) self.assertEqual(sentinel.port, self.mock.port) diff --git a/tests/utils/network/server_test.py b/tests/utils/network/server_test.py index e0399525..268b5dbd 100644 --- a/tests/utils/network/server_test.py +++ b/tests/utils/network/server_test.py @@ -164,10 +164,11 @@ class ServerTest(unittest.TestCase): @patch.object(network, 'Connection', new=Mock()) def test_init_connection(self): self.mock.protocol = sentinel.protocol + self.mock.protocol_kwargs = {} self.mock.timeout = sentinel.timeout network.Server.init_connection(self.mock, sentinel.sock, sentinel.addr) - network.Connection.assert_called_once_with(sentinel.protocol, + network.Connection.assert_called_once_with(sentinel.protocol, {}, sentinel.sock, sentinel.addr, sentinel.timeout) def test_reject_connection(self): From 706b6c6d3f9f89a92e435ac3b7751b38fb58cc63 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 00:14:21 +0200 Subject: [PATCH 062/233] Pass core actor to frontends --- docs/api/frontends.rst | 18 ++++++++++++++++-- mopidy/__main__.py | 10 +++++----- mopidy/frontends/lastfm.py | 2 +- mopidy/frontends/mpd/__init__.py | 2 +- mopidy/frontends/mpris/__init__.py | 2 +- tests/frontends/mpris/events_test.py | 2 +- 6 files changed, 25 insertions(+), 11 deletions(-) diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index af0cc991..36626fa0 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -6,22 +6,36 @@ The following requirements applies to any frontend implementation: - A frontend MAY do mostly whatever it wants to, including creating threads, opening TCP ports and exposing Mopidy for a group of clients. + - A frontend MUST implement at least one `Pykka `_ actor, called the "main actor" from here on. + +- The main actor MUST accept a constructor argument ``core``, which will be an + :class:`ActorProxy ` for the core actor. This object + gives access to the full :ref:`core-api`. + - It MAY use additional actors to implement whatever it does, and using actors in frontend implementations is encouraged. + - The frontend is activated by including its main actor in the :attr:`mopidy.settings.FRONTENDS` setting. + - The main actor MUST be able to start and stop the frontend when the main actor is started and stopped. + - The frontend MAY require additional settings to be set for it to work. + - Such settings MUST be documented. + - The main actor MUST stop itself if the defined settings are not adequate for the frontend to work properly. -- Any actor which is part of the frontend MAY implement any listener interface - from :mod:`mopidy.listeners` to receive notification of the specified events. + +- Any actor which is part of the frontend MAY implement the + :class:`mopidy.core.CoreListener` interface to receive notification of the + specified events. + Frontend implementations ======================== diff --git a/mopidy/__main__.py b/mopidy/__main__.py index ee2e21b6..dbdb193b 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -54,8 +54,8 @@ def main(): setup_settings(options.interactive) audio = setup_audio() backend = setup_backend(audio) - setup_core(audio, backend) - setup_frontends() + core = setup_core(audio, backend) + setup_frontends(core) loop.run() except SettingsError as e: logger.error(e.message) @@ -137,17 +137,17 @@ def stop_backend(): def setup_core(audio, backend): - return Core.start(audio, backend).proxy() + return Core.start(audio=audio, backend=backend).proxy() def stop_core(): stop_actors_by_class(Core) -def setup_frontends(): +def setup_frontends(core): for frontend_class_name in settings.FRONTENDS: try: - get_class(frontend_class_name).start() + get_class(frontend_class_name).start(core=core) except OptionalDependencyError as e: logger.info(u'Disabled: %s (%s)', frontend_class_name, e) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index f2bc44d2..37fbafe2 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -37,7 +37,7 @@ class LastfmFrontend(ThreadingActor, core.CoreListener): - :attr:`mopidy.settings.LASTFM_PASSWORD` """ - def __init__(self): + def __init__(self, core): super(LastfmFrontend, self).__init__() self.lastfm = None self.last_start_time = None diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 9dcf4c34..f8c7a9ef 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -26,7 +26,7 @@ class MpdFrontend(actor.ThreadingActor, core.CoreListener): - :attr:`mopidy.settings.MPD_SERVER_PASSWORD` """ - def __init__(self): + def __init__(self, core): super(MpdFrontend, self).__init__() hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME) port = settings.MPD_SERVER_PORT diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 2815c551..769f2e84 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -55,7 +55,7 @@ class MprisFrontend(ThreadingActor, core.CoreListener): player.Quit(dbus_interface='org.mpris.MediaPlayer2') """ - def __init__(self): + def __init__(self, core): super(MprisFrontend, self).__init__() self.indicate_server = None self.mpris_object = None diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py index 3db03ccf..f466e207 100644 --- a/tests/frontends/mpris/events_test.py +++ b/tests/frontends/mpris/events_test.py @@ -16,7 +16,7 @@ from tests import unittest @unittest.skipUnless(sys.platform.startswith('linux'), 'requires Linux') class BackendEventsTest(unittest.TestCase): def setUp(self): - self.mpris_frontend = MprisFrontend() # As a plain class, not an actor + self.mpris_frontend = MprisFrontend(core=None) # As a plain class, not an actor self.mpris_object = mock.Mock(spec=objects.MprisObject) self.mpris_frontend.mpris_object = self.mpris_object From 9fd3e93cb6fa8438cc54c0115bd8c393e8c353b0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 00:15:47 +0200 Subject: [PATCH 063/233] MPRIS: Use core actor passed to frontend --- mopidy/frontends/mpris/__init__.py | 3 ++- mopidy/frontends/mpris/objects.py | 22 +++++-------------- .../frontends/mpris/player_interface_test.py | 3 +-- tests/frontends/mpris/root_interface_test.py | 2 +- 4 files changed, 10 insertions(+), 20 deletions(-) diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 769f2e84..1a8797f2 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -57,12 +57,13 @@ class MprisFrontend(ThreadingActor, core.CoreListener): def __init__(self, core): super(MprisFrontend, self).__init__() + self.core = core self.indicate_server = None self.mpris_object = None def on_start(self): try: - self.mpris_object = objects.MprisObject() + self.mpris_object = objects.MprisObject(self.core) self._send_startup_notification() except Exception as e: logger.error(u'MPRIS frontend setup failed (%s)', e) diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index cb1e73eb..7c8b6f5a 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -1,8 +1,6 @@ import logging import os -logger = logging.getLogger('mopidy.frontends.mpris') - try: import dbus import dbus.mainloop.glib @@ -12,12 +10,13 @@ except ImportError as import_error: from mopidy import OptionalDependencyError raise OptionalDependencyError(import_error) -from pykka.registry import ActorRegistry - -from mopidy import core, settings +from mopidy import settings from mopidy.core import PlaybackState from mopidy.utils.process import exit_process + +logger = logging.getLogger('mopidy.frontends.mpris') + # Must be done before dbus.SessionBus() is called gobject.threads_init() dbus.mainloop.glib.threads_init() @@ -33,8 +32,8 @@ class MprisObject(dbus.service.Object): properties = None - def __init__(self): - self._core = None + def __init__(self, core): + self.core = core self.properties = { ROOT_IFACE: self._get_root_iface_properties(), PLAYER_IFACE: self._get_player_iface_properties(), @@ -83,15 +82,6 @@ class MprisObject(dbus.service.Object): logger.info(u'Connected to D-Bus') return bus_name - @property - def core(self): - if self._core is None: - core_refs = ActorRegistry.get_by_class(core.Core) - assert len(core_refs) == 1, \ - 'Expected exactly one running core instance.' - self._core = core_refs[0].proxy() - return self._core - def _get_track_id(self, cp_track): return '/com/mopidy/track/%d' % cp_track.cpid diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index 236ec645..403d05c7 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -27,8 +27,7 @@ class PlayerInterfaceTest(unittest.TestCase): objects.MprisObject._connect_to_dbus = mock.Mock() self.backend = dummy.DummyBackend.start(audio=None).proxy() self.core = core.Core.start(backend=self.backend).proxy() - self.mpris = objects.MprisObject() - self.mpris._core = self.core + self.mpris = objects.MprisObject(core=self.core) def tearDown(self): ActorRegistry.stop_all() diff --git a/tests/frontends/mpris/root_interface_test.py b/tests/frontends/mpris/root_interface_test.py index b84b70c3..847ed2de 100644 --- a/tests/frontends/mpris/root_interface_test.py +++ b/tests/frontends/mpris/root_interface_test.py @@ -22,7 +22,7 @@ class RootInterfaceTest(unittest.TestCase): objects.MprisObject._connect_to_dbus = mock.Mock() self.backend = dummy.DummyBackend.start(audio=None).proxy() self.core = core.Core.start(backend=self.backend).proxy() - self.mpris = objects.MprisObject() + self.mpris = objects.MprisObject(core=self.core) def tearDown(self): ActorRegistry.stop_all() From c115cf123f8be2725885ec885417c076f990aaeb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 00:46:44 +0200 Subject: [PATCH 064/233] MPD: Use core actor passed to frontend --- mopidy/frontends/mpd/__init__.py | 7 ++++--- mopidy/frontends/mpd/dispatcher.py | 23 +++++++---------------- tests/frontends/mpd/protocol/__init__.py | 2 +- tests/frontends/mpd/status_test.py | 2 +- 4 files changed, 13 insertions(+), 21 deletions(-) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index f8c7a9ef..d7eeaaa3 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -32,7 +32,8 @@ class MpdFrontend(actor.ThreadingActor, core.CoreListener): port = settings.MPD_SERVER_PORT try: - network.Server(hostname, port, protocol=MpdSession, + network.Server(hostname, port, + protocol=MpdSession, protocol_kwargs={'core': core}, max_connections=settings.MPD_SERVER_MAX_CONNECTIONS) except IOError as error: logger.error(u'MPD server startup failed: %s', locale_decode(error)) @@ -76,9 +77,9 @@ class MpdSession(network.LineProtocol): encoding = protocol.ENCODING delimiter = r'\r?\n' - def __init__(self, connection): + def __init__(self, connection, core=None): super(MpdSession, self).__init__(connection) - self.dispatcher = dispatcher.MpdDispatcher(self) + self.dispatcher = dispatcher.MpdDispatcher(session=self, core=core) def on_start(self): logger.info(u'New MPD connection from [%s]:%s', self.host, self.port) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 1f2af153..c29cdf4d 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -27,12 +27,12 @@ class MpdDispatcher(object): _noidle = re.compile(r'^noidle$') - def __init__(self, session=None): + def __init__(self, session=None, core=None): self.authenticated = False self.command_list = False self.command_list_ok = False self.command_list_index = None - self.context = MpdContext(self, session=session) + self.context = MpdContext(self, session=session, core=core) def handle_request(self, request, current_command_list_index=None): """Dispatch incoming requests to the correct handler.""" @@ -221,27 +221,18 @@ class MpdContext(object): #: The current :class:`mopidy.frontends.mpd.MpdSession`. session = None + #: The Mopidy core API. An instance of :class:`mopidy.core.Core`. + core = None + #: The active subsystems that have pending events. events = None #: The subsytems that we want to be notified about in idle mode. subscriptions = None - def __init__(self, dispatcher, session=None): + def __init__(self, dispatcher, session=None, core=None): self.dispatcher = dispatcher self.session = session + self.core = core self.events = set() self.subscriptions = set() - self._core = None - - @property - def core(self): - """ - The Mopidy core. An instance of :class:`mopidy.core.Core`. - """ - if self._core is None: - core_refs = ActorRegistry.get_by_class(core.Core) - assert len(core_refs) == 1, \ - 'Expected exactly one running core instance.' - self._core = core_refs[0].proxy() - return self._core diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index a2dafb9b..041b6532 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -27,7 +27,7 @@ class BaseTestCase(unittest.TestCase): self.core = core.Core.start(backend=self.backend).proxy() self.connection = MockConnection() - self.session = mpd.MpdSession(self.connection) + self.session = mpd.MpdSession(self.connection, core=self.core) self.dispatcher = self.session.dispatcher self.context = self.dispatcher.context diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 3a5bdcbe..6322ec36 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -22,7 +22,7 @@ class StatusHandlerTest(unittest.TestCase): def setUp(self): self.backend = dummy.DummyBackend.start(audio=None).proxy() self.core = core.Core.start(backend=self.backend).proxy() - self.dispatcher = dispatcher.MpdDispatcher() + self.dispatcher = dispatcher.MpdDispatcher(core=self.core) self.context = self.dispatcher.context def tearDown(self): From 609bd6a5b5d12215d7ffb3385d177381966d5993 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 01:38:39 +0200 Subject: [PATCH 065/233] Limit audio access to the playback provider --- mopidy/backends/base/__init__.py | 3 --- mopidy/backends/base/playback.py | 23 +++++++++--------- mopidy/backends/dummy/__init__.py | 6 ++--- mopidy/backends/local/__init__.py | 6 ++--- mopidy/backends/spotify/__init__.py | 28 ++++++++-------------- mopidy/backends/spotify/playback.py | 12 +++++----- mopidy/backends/spotify/session_manager.py | 5 ++-- 7 files changed, 35 insertions(+), 48 deletions(-) diff --git a/mopidy/backends/base/__init__.py b/mopidy/backends/base/__init__.py index 4e0f0b08..c27acae2 100644 --- a/mopidy/backends/base/__init__.py +++ b/mopidy/backends/base/__init__.py @@ -24,6 +24,3 @@ class Backend(object): #: List of URI schemes this backend can handle. uri_schemes = [] - - def __init__(self, audio): - self.audio = audio diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 197ba90e..635146ff 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -6,7 +6,8 @@ class BasePlaybackProvider(object): pykka_traversable = True - def __init__(self, backend): + def __init__(self, audio, backend): + self.audio = audio self.backend = backend def pause(self): @@ -17,7 +18,7 @@ class BasePlaybackProvider(object): :rtype: :class:`True` if successful, else :class:`False` """ - return self.backend.audio.pause_playback().get() + return self.audio.pause_playback().get() def play(self, track): """ @@ -29,9 +30,9 @@ class BasePlaybackProvider(object): :type track: :class:`mopidy.models.Track` :rtype: :class:`True` if successful, else :class:`False` """ - self.backend.audio.prepare_change() - self.backend.audio.set_uri(track.uri).get() - return self.backend.audio.start_playback().get() + self.audio.prepare_change() + self.audio.set_uri(track.uri).get() + return self.audio.start_playback().get() def resume(self): """ @@ -41,7 +42,7 @@ class BasePlaybackProvider(object): :rtype: :class:`True` if successful, else :class:`False` """ - return self.backend.audio.start_playback().get() + return self.audio.start_playback().get() def seek(self, time_position): """ @@ -53,7 +54,7 @@ class BasePlaybackProvider(object): :type time_position: int :rtype: :class:`True` if successful, else :class:`False` """ - return self.backend.audio.set_position(time_position).get() + return self.audio.set_position(time_position).get() def stop(self): """ @@ -63,7 +64,7 @@ class BasePlaybackProvider(object): :rtype: :class:`True` if successful, else :class:`False` """ - return self.backend.audio.stop_playback().get() + return self.audio.stop_playback().get() def get_time_position(self): """ @@ -73,7 +74,7 @@ class BasePlaybackProvider(object): :rtype: int """ - return self.backend.audio.get_position().get() + return self.audio.get_position().get() def get_volume(self): """ @@ -83,7 +84,7 @@ class BasePlaybackProvider(object): :rtype: int [0..100] or :class:`None` """ - return self.backend.audio.get_volume().get() + return self.audio.get_volume().get() def set_volume(self, volume): """ @@ -94,4 +95,4 @@ class BasePlaybackProvider(object): :param: volume :type volume: int [0..100] """ - self.backend.audio.set_volume(volume) + self.audio.set_volume(volume) diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy/__init__.py index 1d69ed7c..5e028ea3 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy/__init__.py @@ -12,11 +12,9 @@ class DummyBackend(ThreadingActor, base.Backend): Handles URIs starting with ``dummy:``. """ - def __init__(self, *args, **kwargs): - base.Backend.__init__(self, *args, **kwargs) - + def __init__(self, audio): self.library = DummyLibraryProvider(backend=self) - self.playback = DummyPlaybackProvider(backend=self) + self.playback = DummyPlaybackProvider(audio=audio, backend=self) self.stored_playlists = DummyStoredPlaylistsProvider(backend=self) self.uri_schemes = [u'dummy'] diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index f3e86679..ee8448b3 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -30,11 +30,9 @@ class LocalBackend(ThreadingActor, base.Backend): - :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` """ - def __init__(self, *args, **kwargs): - base.Backend.__init__(self, *args, **kwargs) - + def __init__(self, audio): self.library = LocalLibraryProvider(backend=self) - self.playback = base.BasePlaybackProvider(backend=self) + self.playback = base.BasePlaybackProvider(audio=audio, backend=self) self.stored_playlists = LocalStoredPlaylistsProvider(backend=self) self.uri_schemes = [u'file'] diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index a79168f5..0e2a2bfa 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -41,37 +41,29 @@ class SpotifyBackend(ThreadingActor, base.Backend): # Imports inside methods are to prevent loading of __init__.py to fail on # missing spotify dependencies. - def __init__(self, *args, **kwargs): + def __init__(self, audio): from .library import SpotifyLibraryProvider from .playback import SpotifyPlaybackProvider + from .session_manager import SpotifySessionManager from .stored_playlists import SpotifyStoredPlaylistsProvider - base.Backend.__init__(self, *args, **kwargs) - self.library = SpotifyLibraryProvider(backend=self) - self.playback = SpotifyPlaybackProvider(backend=self) + self.playback = SpotifyPlaybackProvider(audio=audio, backend=self) self.stored_playlists = SpotifyStoredPlaylistsProvider(backend=self) self.uri_schemes = [u'spotify'] - self.spotify = None - # Fail early if settings are not present - self.username = settings.SPOTIFY_USERNAME - self.password = settings.SPOTIFY_PASSWORD + username = settings.SPOTIFY_USERNAME + password = settings.SPOTIFY_PASSWORD + + self.spotify = SpotifySessionManager(username, password, + audio=audio, backend_ref=self.actor_ref) def on_start(self): logger.info(u'Mopidy uses SPOTIFY(R) CORE') - self.spotify = self._connect() + logger.debug(u'Connecting to Spotify') + self.spotify.start() def on_stop(self): self.spotify.logout() - - def _connect(self): - from .session_manager import SpotifySessionManager - - logger.debug(u'Connecting to Spotify') - spotify = SpotifySessionManager(self.username, self.password, - audio=self.audio, backend=self.actor_ref.proxy()) - spotify.start() - return spotify diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 94d57f56..d3d0cfa9 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -30,10 +30,10 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) - self.backend.audio.prepare_change() - self.backend.audio.set_uri('appsrc://') - self.backend.audio.start_playback() - self.backend.audio.set_metadata(track) + self.audio.prepare_change() + self.audio.set_uri('appsrc://') + self.audio.start_playback() + self.audio.set_metadata(track) self._timer.play() @@ -50,9 +50,9 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): return self.seek(time_position) def seek(self, time_position): - self.backend.audio.prepare_change() + self.audio.prepare_change() self.backend.spotify.session.seek(time_position) - self.backend.audio.start_playback() + self.audio.start_playback() self._timer.seek(time_position) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 52769d84..dab759c9 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -24,13 +24,13 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key') user_agent = 'Mopidy %s' % get_version() - def __init__(self, username, password, audio, backend): + def __init__(self, username, password, audio, backend_ref): PyspotifySessionManager.__init__(self, username, password) BaseThread.__init__(self) self.name = 'SpotifyThread' self.audio = audio - self.backend = backend + self.backend_ref = backend_ref self.connected = threading.Event() self.session = None @@ -41,6 +41,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): self._initial_data_receive_completed = False def run_inside_try(self): + self.backend = self.backend_ref.proxy() self.connect() def logged_in(self, session, error): From c6b38820ce20026ed9a36ff28e303f39a0a41c2d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 01:58:53 +0200 Subject: [PATCH 066/233] Remove volume handling from backends --- mopidy/backends/base/playback.py | 21 --------------------- mopidy/backends/dummy/__init__.py | 7 ------- mopidy/core/playback.py | 15 ++++++++++++--- 3 files changed, 12 insertions(+), 31 deletions(-) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 635146ff..b21c30dc 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -75,24 +75,3 @@ class BasePlaybackProvider(object): :rtype: int """ return self.audio.get_position().get() - - def get_volume(self): - """ - Get current volume - - *MAY be reimplemented by subclass.* - - :rtype: int [0..100] or :class:`None` - """ - return self.audio.get_volume().get() - - def set_volume(self, volume): - """ - Get current volume - - *MAY be reimplemented by subclass.* - - :param: volume - :type volume: int [0..100] - """ - self.audio.set_volume(volume) diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy/__init__.py index 5e028ea3..6c3e1437 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy/__init__.py @@ -44,7 +44,6 @@ class DummyPlaybackProvider(base.BasePlaybackProvider): def __init__(self, *args, **kwargs): super(DummyPlaybackProvider, self).__init__(*args, **kwargs) self._time_position = 0 - self._volume = None def pause(self): return True @@ -67,12 +66,6 @@ class DummyPlaybackProvider(base.BasePlaybackProvider): def get_time_position(self): return self._time_position - def get_volume(self): - return self._volume - - def set_volume(self, volume): - self._volume = volume - class DummyStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def create(self, name): diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 603b40a4..a86c5650 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -86,6 +86,7 @@ class PlaybackController(object): self._state = PlaybackState.STOPPED self._shuffled = [] self._first_shuffle = True + self._volume = None def _get_cpid(self, cp_track): if cp_track is None: @@ -296,12 +297,20 @@ class PlaybackController(object): @property def volume(self): - """Volume as int in range [0..100].""" - return self.backend.playback.get_volume().get() + """Volume as int in range [0..100] or :class:`None`""" + if self.audio: + return self.audio.get_volume().get() + else: + # For testing + return self._volume @volume.setter def volume(self, volume): - self.backend.playback.set_volume(volume).get() + if self.audio: + self.audio.set_volume(volume) + else: + # For testing + self._volume = volume def change_track(self, cp_track, on_error_step=1): """ From fe80189accc89f65ebe94fda93366609e7ae26a4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 02:20:35 +0200 Subject: [PATCH 067/233] Simplify import --- mopidy/__main__.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index dbdb193b..a67c58b8 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -32,12 +32,10 @@ from mopidy import (get_version, settings, OptionalDependencyError, SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE) from mopidy.audio import Audio from mopidy.core import Core -from mopidy.utils import get_class +from mopidy.utils import get_class, process from mopidy.utils.deps import list_deps_optparse_callback from mopidy.utils.log import setup_logging from mopidy.utils.path import get_or_create_folder, get_or_create_file -from mopidy.utils.process import (exit_handler, stop_remaining_actors, - stop_actors_by_class) from mopidy.utils.settings import list_settings_optparse_callback @@ -45,7 +43,7 @@ logger = logging.getLogger('mopidy.main') def main(): - signal.signal(signal.SIGTERM, exit_handler) + signal.signal(signal.SIGTERM, process.exit_handler) loop = gobject.MainLoop() options = parse_options() try: @@ -69,7 +67,7 @@ def main(): stop_core() stop_backend() stop_audio() - stop_remaining_actors() + process.stop_remaining_actors() def parse_options(): @@ -125,7 +123,7 @@ def setup_audio(): def stop_audio(): - stop_actors_by_class(Audio) + process.stop_actors_by_class(Audio) def setup_backend(audio): @@ -133,7 +131,7 @@ def setup_backend(audio): def stop_backend(): - stop_actors_by_class(get_class(settings.BACKENDS[0])) + process.stop_actors_by_class(get_class(settings.BACKENDS[0])) def setup_core(audio, backend): @@ -141,7 +139,7 @@ def setup_core(audio, backend): def stop_core(): - stop_actors_by_class(Core) + process.stop_actors_by_class(Core) def setup_frontends(core): @@ -155,7 +153,7 @@ def setup_frontends(core): def stop_frontends(): for frontend_class_name in settings.FRONTENDS: try: - stop_actors_by_class(get_class(frontend_class_name)) + process.stop_actors_by_class(get_class(frontend_class_name)) except OptionalDependencyError: pass From 3c66b3a011ca8833b0e54ad01c69139f33b05e41 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 11:40:31 +0200 Subject: [PATCH 068/233] Use module imports --- mopidy/__main__.py | 56 +++++++++++++++------------------ mopidy/core/current_playlist.py | 4 +-- mopidy/core/playback.py | 16 +++++----- 3 files changed, 36 insertions(+), 40 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index a67c58b8..3e586044 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -28,14 +28,10 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) -from mopidy import (get_version, settings, OptionalDependencyError, - SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE) -from mopidy.audio import Audio -from mopidy.core import Core -from mopidy.utils import get_class, process +import mopidy +from mopidy import audio, core, settings, utils +from mopidy.utils import log, path, process from mopidy.utils.deps import list_deps_optparse_callback -from mopidy.utils.log import setup_logging -from mopidy.utils.path import get_or_create_folder, get_or_create_file from mopidy.utils.settings import list_settings_optparse_callback @@ -47,7 +43,7 @@ def main(): loop = gobject.MainLoop() options = parse_options() try: - setup_logging(options.verbosity_level, options.save_debug_log) + log.setup_logging(options.verbosity_level, options.save_debug_log) check_old_folders() setup_settings(options.interactive) audio = setup_audio() @@ -55,12 +51,12 @@ def main(): core = setup_core(audio, backend) setup_frontends(core) loop.run() - except SettingsError as e: - logger.error(e.message) + except mopidy.SettingsError as ex: + logger.error(ex.message) except KeyboardInterrupt: logger.info(u'Interrupted. Exiting...') - except Exception as e: - logger.exception(e) + except Exception as ex: + logger.exception(ex) finally: loop.quit() stop_frontends() @@ -71,7 +67,7 @@ def main(): def parse_options(): - parser = optparse.OptionParser(version=u'Mopidy %s' % get_version()) + parser = optparse.OptionParser(version=u'Mopidy %s' % mopidy.get_version()) parser.add_option('--help-gst', action='store_true', dest='help_gst', help='show GStreamer help options') @@ -104,57 +100,57 @@ def check_old_folders(): logger.warning(u'Old settings folder found at %s, settings.py should be ' 'moved to %s, any cache data should be deleted. See release notes ' - 'for further instructions.', old_settings_folder, SETTINGS_PATH) + 'for further instructions.', old_settings_folder, mopidy.SETTINGS_PATH) def setup_settings(interactive): - get_or_create_folder(SETTINGS_PATH) - get_or_create_folder(DATA_PATH) - get_or_create_file(SETTINGS_FILE) + path.get_or_create_folder(mopidy.SETTINGS_PATH) + path.get_or_create_folder(mopidy.DATA_PATH) + path.get_or_create_file(mopidy.SETTINGS_FILE) try: settings.validate(interactive) - except SettingsError, e: - logger.error(e.message) + except mopidy.SettingsError as ex: + logger.error(ex.message) sys.exit(1) def setup_audio(): - return Audio.start().proxy() + return audio.Audio.start().proxy() def stop_audio(): - process.stop_actors_by_class(Audio) + process.stop_actors_by_class(audio.Audio) def setup_backend(audio): - return get_class(settings.BACKENDS[0]).start(audio=audio).proxy() + return utils.get_class(settings.BACKENDS[0]).start(audio=audio).proxy() def stop_backend(): - process.stop_actors_by_class(get_class(settings.BACKENDS[0])) + process.stop_actors_by_class(utils.get_class(settings.BACKENDS[0])) def setup_core(audio, backend): - return Core.start(audio=audio, backend=backend).proxy() + return core.Core.start(audio=audio, backend=backend).proxy() def stop_core(): - process.stop_actors_by_class(Core) + process.stop_actors_by_class(core.Core) def setup_frontends(core): for frontend_class_name in settings.FRONTENDS: try: - get_class(frontend_class_name).start(core=core) - except OptionalDependencyError as e: - logger.info(u'Disabled: %s (%s)', frontend_class_name, e) + utils.get_class(frontend_class_name).start(core=core) + except mopidy.OptionalDependencyError as ex: + logger.info(u'Disabled: %s (%s)', frontend_class_name, ex) def stop_frontends(): for frontend_class_name in settings.FRONTENDS: try: - process.stop_actors_by_class(get_class(frontend_class_name)) - except OptionalDependencyError: + process.stop_actors_by_class(utils.get_class(frontend_class_name)) + except mopidy.OptionalDependencyError: pass diff --git a/mopidy/core/current_playlist.py b/mopidy/core/current_playlist.py index 973fe71f..17cd70ad 100644 --- a/mopidy/core/current_playlist.py +++ b/mopidy/core/current_playlist.py @@ -4,7 +4,7 @@ import random from mopidy.models import CpTrack -from .listener import CoreListener +from . import listener logger = logging.getLogger('mopidy.core') @@ -241,4 +241,4 @@ class CurrentPlaylistController(object): def _trigger_playlist_changed(self): logger.debug(u'Triggering playlist changed event') - CoreListener.send('playlist_changed') + listener.CoreListener.send('playlist_changed') diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index a86c5650..f3592831 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -1,7 +1,7 @@ import logging import random -from .listener import CoreListener +from . import listener logger = logging.getLogger('mopidy.backends.base') @@ -488,7 +488,7 @@ class PlaybackController(object): logger.debug(u'Triggering track playback paused event') if self.current_track is None: return - CoreListener.send('track_playback_paused', + listener.CoreListener.send('track_playback_paused', track=self.current_track, time_position=self.time_position) @@ -496,7 +496,7 @@ class PlaybackController(object): logger.debug(u'Triggering track playback resumed event') if self.current_track is None: return - CoreListener.send('track_playback_resumed', + listener.CoreListener.send('track_playback_resumed', track=self.current_track, time_position=self.time_position) @@ -504,26 +504,26 @@ class PlaybackController(object): logger.debug(u'Triggering track playback started event') if self.current_track is None: return - CoreListener.send('track_playback_started', + listener.CoreListener.send('track_playback_started', track=self.current_track) def _trigger_track_playback_ended(self): logger.debug(u'Triggering track playback ended event') if self.current_track is None: return - CoreListener.send('track_playback_ended', + listener.CoreListener.send('track_playback_ended', track=self.current_track, time_position=self.time_position) def _trigger_playback_state_changed(self, old_state, new_state): logger.debug(u'Triggering playback state change event') - CoreListener.send('playback_state_changed', + listener.CoreListener.send('playback_state_changed', old_state=old_state, new_state=new_state) def _trigger_options_changed(self): logger.debug(u'Triggering options changed event') - CoreListener.send('options_changed') + listener.CoreListener.send('options_changed') def _trigger_seeked(self, time_position): logger.debug(u'Triggering seeked event') - CoreListener.send('seeked', time_position=time_position) + listener.CoreListener.send('seeked', time_position=time_position) From cef3f73d9a7737afc51cd18060a9161586340916 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 30 Sep 2012 23:39:14 +0200 Subject: [PATCH 069/233] Check Pykka version on startup --- mopidy/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 26e5b904..3b0f76a5 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -2,12 +2,17 @@ import sys if not (2, 6) <= sys.version_info < (3,): sys.exit(u'Mopidy requires Python >= 2.6, < 3') +from distutils.version import StrictVersion import os import platform from subprocess import PIPE, Popen import glib +import pykka +if StrictVersion(pykka.__version__) < StrictVersion('0.16'): + sys.exit(u'Mopidy requires Pykka >= 0.16') + __version__ = '0.8.0' DATA_PATH = os.path.join(str(glib.get_user_data_dir()), 'mopidy') From 666800ec57ac3ffb75a680b31d37bed35ef2176a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 14:00:34 +0200 Subject: [PATCH 070/233] Fix most flake8 warnings (#211) --- mopidy/__init__.py | 9 ++- mopidy/__main__.py | 40 +++++++------ mopidy/audio/__init__.py | 39 +++++++------ mopidy/audio/mixers/__init__.py | 3 +- mopidy/audio/mixers/auto.py | 9 +-- mopidy/audio/mixers/fake.py | 14 ++--- mopidy/audio/mixers/nad.py | 25 ++++---- mopidy/backends/base/stored_playlists.py | 2 +- mopidy/backends/local/__init__.py | 19 ++++--- mopidy/backends/local/translator.py | 9 ++- mopidy/backends/spotify/__init__.py | 5 +- mopidy/backends/spotify/container_manager.py | 7 ++- mopidy/backends/spotify/library.py | 9 +-- mopidy/backends/spotify/playlist_manager.py | 33 +++++++---- mopidy/backends/spotify/session_manager.py | 12 ++-- mopidy/backends/spotify/stored_playlists.py | 13 +++-- mopidy/backends/spotify/translator.py | 3 +- mopidy/core/current_playlist.py | 7 +-- mopidy/core/playback.py | 29 +++++----- mopidy/core/stored_playlists.py | 6 +- mopidy/frontends/mpd/__init__.py | 23 +++++--- mopidy/frontends/mpd/dispatcher.py | 37 ++++++------ mopidy/frontends/mpd/exceptions.py | 8 +++ mopidy/frontends/mpd/protocol/__init__.py | 1 + mopidy/frontends/mpd/protocol/audio_output.py | 7 ++- mopidy/frontends/mpd/protocol/command_list.py | 3 + mopidy/frontends/mpd/protocol/connection.py | 8 ++- .../mpd/protocol/current_playlist.py | 49 ++++++++++------ mopidy/frontends/mpd/protocol/empty.py | 1 + mopidy/frontends/mpd/protocol/music_db.py | 55 +++++++++++------- mopidy/frontends/mpd/protocol/playback.py | 32 ++++++++--- mopidy/frontends/mpd/protocol/reflection.py | 32 +++++++---- mopidy/frontends/mpd/protocol/status.py | 57 +++++++++++++------ mopidy/frontends/mpd/protocol/stickers.py | 27 ++++++--- .../mpd/protocol/stored_playlists.py | 32 +++++++---- mopidy/frontends/mpd/translator.py | 20 +++++-- mopidy/frontends/mpris/__init__.py | 6 +- mopidy/frontends/mpris/objects.py | 47 +++++++-------- mopidy/models.py | 9 +-- mopidy/scanner.py | 10 ++-- mopidy/utils/__init__.py | 1 - mopidy/utils/deps.py | 10 ++-- mopidy/utils/log.py | 5 ++ mopidy/utils/network.py | 32 +++++++---- mopidy/utils/path.py | 7 ++- mopidy/utils/process.py | 10 +++- mopidy/utils/settings.py | 29 ++++++---- 47 files changed, 531 insertions(+), 320 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 3b0f76a5..2a88666c 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -20,12 +20,14 @@ CACHE_PATH = os.path.join(str(glib.get_user_cache_dir()), 'mopidy') SETTINGS_PATH = os.path.join(str(glib.get_user_config_dir()), 'mopidy') SETTINGS_FILE = os.path.join(SETTINGS_PATH, 'settings.py') + def get_version(): try: return get_git_version() except EnvironmentError: return __version__ + def get_git_version(): process = Popen(['git', 'describe'], stdout=PIPE, stderr=PIPE) if process.wait() != 0: @@ -35,14 +37,17 @@ def get_git_version(): version = version[1:] return version + def get_platform(): return platform.platform() + def get_python(): implementation = platform.python_implementation() version = platform.python_version() return u' '.join([implementation, version]) + class MopidyException(Exception): def __init__(self, message, *args, **kwargs): super(MopidyException, self).__init__(message, *args, **kwargs) @@ -53,13 +58,15 @@ class MopidyException(Exception): """Reimplement message field that was deprecated in Python 2.6""" return self._message - @message.setter + @message.setter # noqa def message(self, message): self._message = message + class SettingsError(MopidyException): pass + class OptionalDependencyError(MopidyException): pass diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 3e586044..bfc600f5 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -24,8 +24,8 @@ sys.argv[1:] = gstreamer_args # Add ../ to the path so we can run Mopidy from a Git checkout without # installing it on the system. -sys.path.insert(0, - os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) import mopidy @@ -46,10 +46,10 @@ def main(): log.setup_logging(options.verbosity_level, options.save_debug_log) check_old_folders() setup_settings(options.interactive) - audio = setup_audio() - backend = setup_backend(audio) - core = setup_core(audio, backend) - setup_frontends(core) + audio_ref = setup_audio() + backend_ref = setup_backend(audio_ref) + core_ref = setup_core(audio_ref, backend_ref) + setup_frontends(core_ref) loop.run() except mopidy.SettingsError as ex: logger.error(ex.message) @@ -68,25 +68,32 @@ def main(): def parse_options(): parser = optparse.OptionParser(version=u'Mopidy %s' % mopidy.get_version()) - parser.add_option('--help-gst', + parser.add_option( + '--help-gst', action='store_true', dest='help_gst', help='show GStreamer help options') - parser.add_option('-i', '--interactive', + parser.add_option( + '-i', '--interactive', action='store_true', dest='interactive', help='ask interactively for required settings which are missing') - parser.add_option('-q', '--quiet', + parser.add_option( + '-q', '--quiet', action='store_const', const=0, dest='verbosity_level', help='less output (warning level)') - parser.add_option('-v', '--verbose', + parser.add_option( + '-v', '--verbose', action='count', default=1, dest='verbosity_level', help='more output (debug level)') - parser.add_option('--save-debug-log', + parser.add_option( + '--save-debug-log', action='store_true', dest='save_debug_log', help='save debug log to "./mopidy.log"') - parser.add_option('--list-settings', + parser.add_option( + '--list-settings', action='callback', callback=list_settings_optparse_callback, help='list current settings') - parser.add_option('--list-deps', + parser.add_option( + '--list-deps', action='callback', callback=list_deps_optparse_callback, help='list dependencies and their versions') return parser.parse_args(args=mopidy_args)[0] @@ -98,9 +105,10 @@ def check_old_folders(): if not os.path.isdir(old_settings_folder): return - logger.warning(u'Old settings folder found at %s, settings.py should be ' - 'moved to %s, any cache data should be deleted. See release notes ' - 'for further instructions.', old_settings_folder, mopidy.SETTINGS_PATH) + logger.warning( + u'Old settings folder found at %s, settings.py should be moved ' + u'to %s, any cache data should be deleted. See release notes for ' + u'further instructions.', old_settings_folder, mopidy.SETTINGS_PATH) def setup_settings(interactive): diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 10a74959..a342799b 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -70,8 +70,8 @@ class Audio(ThreadingActor): self._playbin.set_property('audio-sink', output) logger.info('Output set to %s', settings.OUTPUT) except gobject.GError as ex: - logger.error('Failed to create output "%s": %s', - settings.OUTPUT, ex) + logger.error( + 'Failed to create output "%s": %s', settings.OUTPUT, ex) process.exit_process() def _setup_mixer(self): @@ -85,11 +85,11 @@ class Audio(ThreadingActor): return try: - mixerbin = gst.parse_bin_from_description(settings.MIXER, - ghost_unconnected_pads=False) + mixerbin = gst.parse_bin_from_description( + settings.MIXER, ghost_unconnected_pads=False) except gobject.GError as ex: - logger.warning('Failed to create mixer "%s": %s', - settings.MIXER, ex) + logger.warning( + 'Failed to create mixer "%s": %s', settings.MIXER, ex) return # We assume that the bin will contain a single mixer. @@ -215,10 +215,11 @@ class Audio(ThreadingActor): :type volume: int :rtype: :class:`True` if successful, else :class:`False` """ - self._playbin.get_state() # block until state changes are done - handeled = self._playbin.seek_simple(gst.Format(gst.FORMAT_TIME), - gst.SEEK_FLAG_FLUSH, position * gst.MSECOND) - self._playbin.get_state() # block until seek is done + self._playbin.get_state() # block until state changes are done + handeled = self._playbin.seek_simple( + gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, + position * gst.MSECOND) + self._playbin.get_state() # block until seek is done return handeled def start_playback(self): @@ -279,16 +280,16 @@ class Audio(ThreadingActor): """ result = self._playbin.set_state(state) if result == gst.STATE_CHANGE_FAILURE: - logger.warning('Setting GStreamer state to %s: failed', - state.value_name) + logger.warning( + 'Setting GStreamer state to %s: failed', state.value_name) return False elif result == gst.STATE_CHANGE_ASYNC: - logger.debug('Setting GStreamer state to %s: async', - state.value_name) + logger.debug( + 'Setting GStreamer state to %s: async', state.value_name) return True else: - logger.debug('Setting GStreamer state to %s: OK', - state.value_name) + logger.debug( + 'Setting GStreamer state to %s: OK', state.value_name) return True def get_volume(self): @@ -316,7 +317,8 @@ class Audio(ThreadingActor): avg_volume = float(sum(volumes)) / len(volumes) new_scale = (0, 100) - old_scale = (self._mixer_track.min_volume, self._mixer_track.max_volume) + old_scale = ( + self._mixer_track.min_volume, self._mixer_track.max_volume) return utils.rescale(avg_volume, old=old_scale, new=new_scale) def set_volume(self, volume): @@ -335,7 +337,8 @@ class Audio(ThreadingActor): return False old_scale = (0, 100) - new_scale = (self._mixer_track.min_volume, self._mixer_track.max_volume) + new_scale = ( + self._mixer_track.min_volume, self._mixer_track.max_volume) volume = utils.rescale(volume, old=old_scale, new=new_scale) diff --git a/mopidy/audio/mixers/__init__.py b/mopidy/audio/mixers/__init__.py index a0247519..08ecda0d 100644 --- a/mopidy/audio/mixers/__init__.py +++ b/mopidy/audio/mixers/__init__.py @@ -5,7 +5,8 @@ import gobject def create_track(label, initial_volume, min_volume, max_volume, - num_channels, flags): + num_channels, flags): + class Track(gst.interfaces.MixerTrack): def __init__(self): super(Track, self).__init__() diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py index 1233afa3..3dce11f7 100644 --- a/mopidy/audio/mixers/auto.py +++ b/mopidy/audio/mixers/auto.py @@ -10,10 +10,11 @@ logger = logging.getLogger('mopidy.audio.mixers.auto') # TODO: we might want to add some ranking to the mixers we know about? class AutoAudioMixer(gst.Bin): - __gstdetails__ = ('AutoAudioMixer', - 'Mixer', - 'Element automatically selects a mixer.', - 'Thomas Adamcik') + __gstdetails__ = ( + 'AutoAudioMixer', + 'Mixer', + 'Element automatically selects a mixer.', + 'Thomas Adamcik') def __init__(self): gst.Bin.__init__(self) diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py index c5faa03f..d44fbd71 100644 --- a/mopidy/audio/mixers/fake.py +++ b/mopidy/audio/mixers/fake.py @@ -7,19 +7,19 @@ from mopidy.audio.mixers import create_track class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): - __gstdetails__ = ('FakeMixer', - 'Mixer', - 'Fake mixer for use in tests.', - 'Thomas Adamcik') + __gstdetails__ = ( + 'FakeMixer', + 'Mixer', + 'Fake mixer for use in tests.', + 'Thomas Adamcik') track_label = gobject.property(type=str, default='Master') track_initial_volume = gobject.property(type=int, default=0) track_min_volume = gobject.property(type=int, default=0) track_max_volume = gobject.property(type=int, default=100) track_num_channels = gobject.property(type=int, default=2) - track_flags = gobject.property(type=int, - default=(gst.interfaces.MIXER_TRACK_MASTER | - gst.interfaces.MIXER_TRACK_OUTPUT)) + track_flags = gobject.property(type=int, default=( + gst.interfaces.MIXER_TRACK_MASTER | gst.interfaces.MIXER_TRACK_OUTPUT)) def __init__(self): gst.Element.__init__(self) diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index 667dee53..d50c1242 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -8,7 +8,7 @@ import gst try: import serial except ImportError: - serial = None + serial = None # noqa from pykka.actor import ThreadingActor @@ -19,10 +19,11 @@ logger = logging.getLogger('mopidy.audio.mixers.nad') class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): - __gstdetails__ = ('NadMixer', - 'Mixer', - 'Mixer to control NAD amplifiers using a serial link', - 'Stein Magnus Jodal') + __gstdetails__ = ( + 'NadMixer', + 'Mixer', + 'Mixer to control NAD amplifiers using a serial link', + 'Stein Magnus Jodal') port = gobject.property(type=str, default='/dev/ttyUSB0') source = gobject.property(type=str) @@ -41,8 +42,9 @@ class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): min_volume=0, max_volume=100, num_channels=1, - flags=(gst.interfaces.MIXER_TRACK_MASTER | - gst.interfaces.MIXER_TRACK_OUTPUT)) + flags=( + gst.interfaces.MIXER_TRACK_MASTER | + gst.interfaces.MIXER_TRACK_OUTPUT)) return [track] def get_volume(self, track): @@ -121,8 +123,7 @@ class NadTalker(ThreadingActor): self._set_device_to_known_state() def _open_connection(self): - logger.info(u'NAD amplifier: Connecting through "%s"', - self.port) + logger.info(u'NAD amplifier: Connecting through "%s"', self.port) self._device = serial.Serial( port=self.port, baudrate=self.BAUDRATE, @@ -200,11 +201,13 @@ class NadTalker(ThreadingActor): for attempt in range(1, 4): if self._ask_device(key) == value: return - logger.info(u'NAD amplifier: Setting "%s" to "%s" (attempt %d/3)', + logger.info( + u'NAD amplifier: Setting "%s" to "%s" (attempt %d/3)', key, value, attempt) self._command_device(key, value) if self._ask_device(key) != value: - logger.info(u'NAD amplifier: Gave up on setting "%s" to "%s"', + logger.info( + u'NAD amplifier: Gave up on setting "%s" to "%s"', key, value) def _ask_device(self, key): diff --git a/mopidy/backends/base/stored_playlists.py b/mopidy/backends/base/stored_playlists.py index d1d52c9a..d808798d 100644 --- a/mopidy/backends/base/stored_playlists.py +++ b/mopidy/backends/base/stored_playlists.py @@ -22,7 +22,7 @@ class BaseStoredPlaylistsProvider(object): """ return copy(self._playlists) - @playlists.setter + @playlists.setter # noqa def playlists(self, playlists): self._playlists = playlists diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index ee8448b3..b34c3da5 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -1,5 +1,4 @@ import glob -import glib import logging import os import shutil @@ -8,7 +7,7 @@ from pykka.actor import ThreadingActor from mopidy import settings from mopidy.backends import base -from mopidy.models import Playlist, Track, Album +from mopidy.models import Playlist, Album from .translator import parse_m3u, parse_mpd_tag_cache @@ -45,7 +44,7 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): self.refresh() def lookup(self, uri): - pass # TODO + pass # TODO def refresh(self): playlists = [] @@ -118,11 +117,12 @@ class LocalLibraryProvider(base.BaseLibraryProvider): self.refresh() def refresh(self, uri=None): - tracks = parse_mpd_tag_cache(settings.LOCAL_TAG_CACHE_FILE, - settings.LOCAL_MUSIC_PATH) + tracks = parse_mpd_tag_cache( + settings.LOCAL_TAG_CACHE_FILE, settings.LOCAL_MUSIC_PATH) - logger.info('Loading tracks in %s from %s', settings.LOCAL_MUSIC_PATH, - settings.LOCAL_TAG_CACHE_FILE) + logger.info( + 'Loading tracks in %s from %s', + settings.LOCAL_MUSIC_PATH, settings.LOCAL_TAG_CACHE_FILE) for track in tracks: self._uri_mapping[track.uri] = track @@ -150,7 +150,8 @@ class LocalLibraryProvider(base.BaseLibraryProvider): artist_filter = lambda t: filter( lambda a: q == a.name, t.artists) uri_filter = lambda t: q == t.uri - any_filter = lambda t: (track_filter(t) or album_filter(t) or + any_filter = lambda t: ( + track_filter(t) or album_filter(t) or artist_filter(t) or uri_filter(t)) if field == 'track': @@ -178,7 +179,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): for value in values: q = value.strip().lower() - track_filter = lambda t: q in t.name.lower() + track_filter = lambda t: q in t.name.lower() album_filter = lambda t: q in getattr( t, 'album', Album()).name.lower() artist_filter = lambda t: filter( diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 1fea555c..fbdace15 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -1,5 +1,4 @@ import logging -import os logger = logging.getLogger('mopidy.backends.local.translator') @@ -7,6 +6,7 @@ from mopidy.models import Track, Artist, Album from mopidy.utils import locale_decode from mopidy.utils.path import path_to_uri + def parse_m3u(file_path, music_folder): """ Convert M3U file list of uris @@ -51,6 +51,7 @@ def parse_m3u(file_path, music_folder): return uris + def parse_mpd_tag_cache(tag_cache, music_dir=''): """ Converts a MPD tag_cache into a lists of tracks, artists and albums. @@ -89,6 +90,7 @@ def parse_mpd_tag_cache(tag_cache, music_dir=''): return tracks + def _convert_mpd_data(data, tracks, music_dir): if not data: return @@ -128,7 +130,8 @@ def _convert_mpd_data(data, tracks, music_dir): artist_kwargs['musicbrainz_id'] = data['musicbrainz_artistid'] if 'musicbrainz_albumartistid' in data: - albumartist_kwargs['musicbrainz_id'] = data['musicbrainz_albumartistid'] + albumartist_kwargs['musicbrainz_id'] = ( + data['musicbrainz_albumartistid']) if data['file'][0] == '/': path = data['file'][1:] @@ -142,7 +145,7 @@ def _convert_mpd_data(data, tracks, music_dir): if albumartist_kwargs: albumartist = Artist(**albumartist_kwargs) album_kwargs['artists'] = [albumartist] - + if album_kwargs: album = Album(**album_kwargs) track_kwargs['album'] = album diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 0e2a2bfa..749a43c0 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -9,6 +9,7 @@ logger = logging.getLogger('mopidy.backends.spotify') BITRATES = {96: 2, 160: 0, 320: 1} + class SpotifyBackend(ThreadingActor, base.Backend): """ A backend for playing music from the `Spotify `_ @@ -57,8 +58,8 @@ class SpotifyBackend(ThreadingActor, base.Backend): username = settings.SPOTIFY_USERNAME password = settings.SPOTIFY_PASSWORD - self.spotify = SpotifySessionManager(username, password, - audio=audio, backend_ref=self.actor_ref) + self.spotify = SpotifySessionManager( + username, password, audio=audio, backend_ref=self.actor_ref) def on_start(self): logger.info(u'Mopidy uses SPOTIFY(R) CORE') diff --git a/mopidy/backends/spotify/container_manager.py b/mopidy/backends/spotify/container_manager.py index 27a4d78a..a45b1adc 100644 --- a/mopidy/backends/spotify/container_manager.py +++ b/mopidy/backends/spotify/container_manager.py @@ -5,6 +5,7 @@ from spotify.manager import SpotifyContainerManager as \ logger = logging.getLogger('mopidy.backends.spotify.container_manager') + class SpotifyContainerManager(PyspotifyContainerManager): def __init__(self, session_manager): PyspotifyContainerManager.__init__(self) @@ -25,13 +26,13 @@ class SpotifyContainerManager(PyspotifyContainerManager): def playlist_added(self, container, playlist, position, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: playlist added at position %d', - position) + logger.debug( + u'Callback called: playlist added at position %d', position) # container_loaded() is called after this callback, so we do not need # to handle this callback. def playlist_moved(self, container, playlist, old_position, new_position, - userdata): + userdata): """Callback used by pyspotify""" logger.debug( u'Callback called: playlist "%s" moved from position %d to %d', diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 18276ecd..8519a650 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -22,7 +22,8 @@ class SpotifyTrack(Track): if self._track: return self._track elif self._spotify_track.is_loaded(): - self._track = SpotifyTranslator.to_mopidy_track(self._spotify_track) + self._track = SpotifyTranslator.to_mopidy_track( + self._spotify_track) return self._track else: return self._unloaded_track @@ -59,7 +60,7 @@ class SpotifyLibraryProvider(BaseLibraryProvider): return None def refresh(self, uri=None): - pass # TODO + pass # TODO def search(self, **query): if not query: @@ -81,7 +82,7 @@ class SpotifyLibraryProvider(BaseLibraryProvider): if field == u'any': spotify_query.append(value) elif field == u'year': - value = int(value.split('-')[0]) # Extract year + value = int(value.split('-')[0]) # Extract year spotify_query.append(u'%s:%d' % (field, value)) else: spotify_query.append(u'%s:"%s"' % (field, value)) @@ -90,6 +91,6 @@ class SpotifyLibraryProvider(BaseLibraryProvider): queue = Queue.Queue() self.backend.spotify.search(spotify_query, queue) try: - return queue.get(timeout=3) # XXX What is an reasonable timeout? + return queue.get(timeout=3) # XXX What is an reasonable timeout? except Queue.Empty: return Playlist(tracks=[]) diff --git a/mopidy/backends/spotify/playlist_manager.py b/mopidy/backends/spotify/playlist_manager.py index 05f9514d..e1308a49 100644 --- a/mopidy/backends/spotify/playlist_manager.py +++ b/mopidy/backends/spotify/playlist_manager.py @@ -5,6 +5,7 @@ from spotify.manager import SpotifyPlaylistManager as PyspotifyPlaylistManager logger = logging.getLogger('mopidy.backends.spotify.playlist_manager') + class SpotifyPlaylistManager(PyspotifyPlaylistManager): def __init__(self, session_manager): PyspotifyPlaylistManager.__init__(self) @@ -12,48 +13,55 @@ class SpotifyPlaylistManager(PyspotifyPlaylistManager): def tracks_added(self, playlist, tracks, position, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: ' + logger.debug( + u'Callback called: ' u'%d track(s) added to position %d in playlist "%s"', len(tracks), position, playlist.name()) self.session_manager.refresh_stored_playlists() def tracks_moved(self, playlist, tracks, new_position, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: ' + logger.debug( + u'Callback called: ' u'%d track(s) moved to position %d in playlist "%s"', len(tracks), new_position, playlist.name()) self.session_manager.refresh_stored_playlists() def tracks_removed(self, playlist, tracks, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: ' + logger.debug( + u'Callback called: ' u'%d track(s) removed from playlist "%s"', len(tracks), playlist.name()) self.session_manager.refresh_stored_playlists() def playlist_renamed(self, playlist, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: Playlist renamed to "%s"', - playlist.name()) + logger.debug( + u'Callback called: Playlist renamed to "%s"', playlist.name()) self.session_manager.refresh_stored_playlists() def playlist_state_changed(self, playlist, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: The state of playlist "%s" changed', + logger.debug( + u'Callback called: The state of playlist "%s" changed', playlist.name()) def playlist_update_in_progress(self, playlist, done, userdata): """Callback used by pyspotify""" if done: - logger.debug(u'Callback called: ' - u'Update of playlist "%s" done', playlist.name()) + logger.debug( + u'Callback called: Update of playlist "%s" done', + playlist.name()) else: - logger.debug(u'Callback called: ' - u'Update of playlist "%s" in progress', playlist.name()) + logger.debug( + u'Callback called: Update of playlist "%s" in progress', + playlist.name()) def playlist_metadata_updated(self, playlist, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: Metadata updated for playlist "%s"', + logger.debug( + u'Callback called: Metadata updated for playlist "%s"', playlist.name()) def track_created_changed(self, playlist, position, user, when, userdata): @@ -90,5 +98,6 @@ class SpotifyPlaylistManager(PyspotifyPlaylistManager): def image_changed(self, playlist, image, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: Image changed for playlist "%s"', + logger.debug( + u'Callback called: Image changed for playlist "%s"', playlist.name()) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index dab759c9..99859abd 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -53,7 +53,8 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): logger.info(u'Connected to Spotify') self.session = session - logger.debug(u'Preferred Spotify bitrate is %s kbps', + logger.debug( + u'Preferred Spotify bitrate is %s kbps', settings.SPOTIFY_BITRATE) self.session.set_preferred_bitrate(BITRATES[settings.SPOTIFY_BITRATE]) @@ -85,7 +86,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): logger.debug(u'User message: %s', message.strip()) def music_delivery(self, session, frames, frame_size, num_frames, - sample_type, sample_rate, channels): + sample_type, sample_rate, channels): """Callback used by pyspotify""" # pylint: disable = R0913 # Too many arguments (8/5) @@ -136,7 +137,8 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): if not self._initial_data_receive_completed: logger.debug(u'Still getting data; skipped refresh of playlists') return - playlists = map(SpotifyTranslator.to_mopidy_playlist, + playlists = map( + SpotifyTranslator.to_mopidy_playlist, self.session.playlist_container()) playlists = filter(None, playlists) self.backend.stored_playlists.playlists = playlists @@ -153,8 +155,8 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): for t in results.tracks()]) queue.put(playlist) self.connected.wait() - self.session.search(query, callback, track_count=100, - album_count=0, artist_count=0) + self.session.search( + query, callback, track_count=100, album_count=0, artist_count=0) def logout(self): """Log out from spotify""" diff --git a/mopidy/backends/spotify/stored_playlists.py b/mopidy/backends/spotify/stored_playlists.py index 054e2bd1..85695c40 100644 --- a/mopidy/backends/spotify/stored_playlists.py +++ b/mopidy/backends/spotify/stored_playlists.py @@ -1,20 +1,21 @@ from mopidy.backends.base import BaseStoredPlaylistsProvider + class SpotifyStoredPlaylistsProvider(BaseStoredPlaylistsProvider): def create(self, name): - pass # TODO + pass # TODO def delete(self, playlist): - pass # TODO + pass # TODO def lookup(self, uri): - pass # TODO + pass # TODO def refresh(self): - pass # TODO + pass # TODO def rename(self, playlist, new_name): - pass # TODO + pass # TODO def save(self, playlist): - pass # TODO + pass # TODO diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 1a8f048d..82c11ef7 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -7,6 +7,7 @@ from mopidy.models import Artist, Album, Track, Playlist logger = logging.getLogger('mopidy.backends.spotify.translator') + class SpotifyTranslator(object): @classmethod def to_mopidy_artist(cls, spotify_artist): @@ -57,7 +58,7 @@ class SpotifyTranslator(object): name=spotify_playlist.name(), # FIXME if check on link is a hackish workaround for is_local tracks=[cls.to_mopidy_track(t) for t in spotify_playlist - if str(Link.from_track(t, 0))], + if str(Link.from_track(t, 0))], ) except SpotifyError, e: logger.warning(u'Failed translating Spotify playlist: %s', e) diff --git a/mopidy/core/current_playlist.py b/mopidy/core/current_playlist.py index 17cd70ad..5aa7ed5d 100644 --- a/mopidy/core/current_playlist.py +++ b/mopidy/core/current_playlist.py @@ -6,7 +6,6 @@ from mopidy.models import CpTrack from . import listener - logger = logging.getLogger('mopidy.core') @@ -57,7 +56,7 @@ class CurrentPlaylistController(object): """ return self._version - @version.setter + @version.setter # noqa def version(self, version): self._version = version self.core.playback.on_current_playlist_change() @@ -128,8 +127,8 @@ class CurrentPlaylistController(object): if key == 'cpid': matches = filter(lambda ct: ct.cpid == value, matches) else: - matches = filter(lambda ct: getattr(ct.track, key) == value, - matches) + matches = filter( + lambda ct: getattr(ct.track, key) == value, matches) if len(matches) == 1: return matches[0] criteria_string = ', '.join( diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index f3592831..90e7e639 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -283,7 +283,7 @@ class PlaybackController(object): """ return self._state - @state.setter + @state.setter # noqa def state(self, new_state): (old_state, self._state) = (self.state, new_state) logger.debug(u'Changing state: %s -> %s', old_state, new_state) @@ -304,7 +304,7 @@ class PlaybackController(object): # For testing return self._volume - @volume.setter + @volume.setter # noqa def volume(self, volume): if self.audio: self.audio.set_volume(volume) @@ -488,36 +488,37 @@ class PlaybackController(object): logger.debug(u'Triggering track playback paused event') if self.current_track is None: return - listener.CoreListener.send('track_playback_paused', - track=self.current_track, - time_position=self.time_position) + listener.CoreListener.send( + 'track_playback_paused', + track=self.current_track, time_position=self.time_position) def _trigger_track_playback_resumed(self): logger.debug(u'Triggering track playback resumed event') if self.current_track is None: return - listener.CoreListener.send('track_playback_resumed', - track=self.current_track, - time_position=self.time_position) + listener.CoreListener.send( + 'track_playback_resumed', + track=self.current_track, time_position=self.time_position) def _trigger_track_playback_started(self): logger.debug(u'Triggering track playback started event') if self.current_track is None: return - listener.CoreListener.send('track_playback_started', - track=self.current_track) + listener.CoreListener.send( + 'track_playback_started', track=self.current_track) def _trigger_track_playback_ended(self): logger.debug(u'Triggering track playback ended event') if self.current_track is None: return - listener.CoreListener.send('track_playback_ended', - track=self.current_track, - time_position=self.time_position) + listener.CoreListener.send( + 'track_playback_ended', + track=self.current_track, time_position=self.time_position) def _trigger_playback_state_changed(self, old_state, new_state): logger.debug(u'Triggering playback state change event') - listener.CoreListener.send('playback_state_changed', + listener.CoreListener.send( + 'playback_state_changed', old_state=old_state, new_state=new_state) def _trigger_options_changed(self): diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index 6ea9b1d3..2c5ef752 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -21,7 +21,7 @@ class StoredPlaylistsController(object): """ return self.backend.stored_playlists.playlists.get() - @playlists.setter + @playlists.setter # noqa def playlists(self, playlists): self.backend.stored_playlists.playlists = playlists @@ -71,8 +71,8 @@ class StoredPlaylistsController(object): if len(matches) == 0: raise LookupError('"%s" match no playlists' % criteria_string) else: - raise LookupError('"%s" match multiple playlists' - % criteria_string) + raise LookupError( + '"%s" match multiple playlists' % criteria_string) def lookup(self, uri): """ diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index d7eeaaa3..e5bafcf1 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -7,7 +7,6 @@ from mopidy import core, settings from mopidy.frontends.mpd import dispatcher, protocol from mopidy.utils import locale_decode, log, network, process - logger = logging.getLogger('mopidy.frontends.mpd') @@ -32,11 +31,13 @@ class MpdFrontend(actor.ThreadingActor, core.CoreListener): port = settings.MPD_SERVER_PORT try: - network.Server(hostname, port, + network.Server( + hostname, port, protocol=MpdSession, protocol_kwargs={'core': core}, max_connections=settings.MPD_SERVER_MAX_CONNECTIONS) except IOError as error: - logger.error(u'MPD server startup failed: %s', locale_decode(error)) + logger.error( + u'MPD server startup failed: %s', locale_decode(error)) sys.exit(1) logger.info(u'MPD server running at [%s]:%s', hostname, port) @@ -86,15 +87,18 @@ class MpdSession(network.LineProtocol): self.send_lines([u'OK MPD %s' % protocol.VERSION]) def on_line_received(self, line): - logger.debug(u'Request from [%s]:%s to %s: %s', self.host, self.port, - self.actor_urn, line) + logger.debug( + u'Request from [%s]:%s to %s: %s', + self.host, self.port, self.actor_urn, line) response = self.dispatcher.handle_request(line) if not response: return - logger.debug(u'Response to [%s]:%s from %s: %s', self.host, self.port, - self.actor_urn, log.indent(self.terminator.join(response))) + logger.debug( + u'Response to [%s]:%s from %s: %s', + self.host, self.port, self.actor_urn, + log.indent(self.terminator.join(response))) self.send_lines(response) @@ -105,8 +109,9 @@ class MpdSession(network.LineProtocol): try: return super(MpdSession, self).decode(line.decode('string_escape')) except ValueError: - logger.warning(u'Stopping actor due to unescaping error, data ' - 'supplied by client was not valid.') + logger.warning( + u'Stopping actor due to unescaping error, data ' + u'supplied by client was not valid.') self.stop() def close(self): diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index c29cdf4d..24db6a7a 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -2,22 +2,22 @@ import logging import re from pykka import ActorDeadError -from pykka.registry import ActorRegistry -from mopidy import core, settings +from mopidy import settings from mopidy.frontends.mpd import exceptions from mopidy.frontends.mpd.protocol import mpd_commands, request_handlers # Do not remove the following import. The protocol modules must be imported to # get them registered as request handlers. # pylint: disable = W0611 -from mopidy.frontends.mpd.protocol import (audio_output, command_list, - connection, current_playlist, empty, music_db, playback, reflection, - status, stickers, stored_playlists) +from mopidy.frontends.mpd.protocol import ( + audio_output, command_list, connection, current_playlist, empty, music_db, + playback, reflection, status, stickers, stored_playlists) # pylint: enable = W0611 from mopidy.utils import flatten logger = logging.getLogger('mopidy.frontends.mpd.dispatcher') + class MpdDispatcher(object): """ The MPD session feeds the MPD dispatcher with requests. The dispatcher @@ -71,7 +71,6 @@ class MpdDispatcher(object): else: return response - ### Filter: catch MPD ACK errors def _catch_mpd_ack_errors_filter(self, request, response, filter_chain): @@ -82,7 +81,6 @@ class MpdDispatcher(object): mpd_ack_error.index = self.command_list_index return [mpd_ack_error.get_mpd_ack()] - ### Filter: authenticate def _authenticate_filter(self, request, response, filter_chain): @@ -101,7 +99,6 @@ class MpdDispatcher(object): else: raise exceptions.MpdPermissionError(command=command_name) - ### Filter: command list def _command_list_filter(self, request, response, filter_chain): @@ -117,25 +114,27 @@ class MpdDispatcher(object): return response def _is_receiving_command_list(self, request): - return (self.command_list is not False - and request != u'command_list_end') + return ( + self.command_list is not False and + request != u'command_list_end') def _is_processing_command_list(self, request): - return (self.command_list_index is not None - and request != u'command_list_end') - + return ( + self.command_list_index is not None and + request != u'command_list_end') ### Filter: idle def _idle_filter(self, request, response, filter_chain): if self._is_currently_idle() and not self._noidle.match(request): - logger.debug(u'Client sent us %s, only %s is allowed while in ' - 'the idle state', repr(request), repr(u'noidle')) + logger.debug( + u'Client sent us %s, only %s is allowed while in ' + u'the idle state', repr(request), repr(u'noidle')) self.context.session.close() return [] if not self._is_currently_idle() and self._noidle.match(request): - return [] # noidle was called before idle + return [] # noidle was called before idle response = self._call_next_filter(request, response, filter_chain) @@ -147,7 +146,6 @@ class MpdDispatcher(object): def _is_currently_idle(self): return bool(self.context.subscriptions) - ### Filter: add OK def _add_ok_filter(self, request, response, filter_chain): @@ -159,7 +157,6 @@ class MpdDispatcher(object): def _has_error(self, response): return response and response[-1].startswith(u'ACK') - ### Filter: call handler def _call_handler_filter(self, request, response, filter_chain): @@ -181,8 +178,8 @@ class MpdDispatcher(object): return (request_handlers[pattern], matches.groupdict()) command_name = request.split(' ')[0] if command_name in [command.name for command in mpd_commands]: - raise exceptions.MpdArgError(u'incorrect arguments', - command=command_name) + raise exceptions.MpdArgError( + u'incorrect arguments', command=command_name) raise exceptions.MpdUnknownCommand(command=command_name) def _format_response(self, response): diff --git a/mopidy/frontends/mpd/exceptions.py b/mopidy/frontends/mpd/exceptions.py index 661d6905..e5844b60 100644 --- a/mopidy/frontends/mpd/exceptions.py +++ b/mopidy/frontends/mpd/exceptions.py @@ -1,5 +1,6 @@ from mopidy import MopidyException + class MpdAckError(MopidyException): """See fields on this class for available MPD error codes""" @@ -33,12 +34,15 @@ class MpdAckError(MopidyException): return u'ACK [%i@%i] {%s} %s' % ( self.__class__.error_code, self.index, self.command, self.message) + class MpdArgError(MpdAckError): error_code = MpdAckError.ACK_ERROR_ARG + class MpdPasswordError(MpdAckError): error_code = MpdAckError.ACK_ERROR_PASSWORD + class MpdPermissionError(MpdAckError): error_code = MpdAckError.ACK_ERROR_PERMISSION @@ -46,6 +50,7 @@ class MpdPermissionError(MpdAckError): super(MpdPermissionError, self).__init__(*args, **kwargs) self.message = u'you don\'t have permission for "%s"' % self.command + class MpdUnknownCommand(MpdAckError): error_code = MpdAckError.ACK_ERROR_UNKNOWN @@ -54,12 +59,15 @@ class MpdUnknownCommand(MpdAckError): self.message = u'unknown command "%s"' % self.command self.command = u'' + class MpdNoExistError(MpdAckError): error_code = MpdAckError.ACK_ERROR_NO_EXIST + class MpdSystemError(MpdAckError): error_code = MpdAckError.ACK_ERROR_SYSTEM + class MpdNotImplemented(MpdAckError): error_code = 0 diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index f0b56a57..590a8ef4 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -29,6 +29,7 @@ mpd_commands = set() request_handlers = {} + def handle_request(pattern, auth_required=True): """ Decorator for connecting command handlers to command requests. diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/frontends/mpd/protocol/audio_output.py index 7147963a..7e50c8c0 100644 --- a/mopidy/frontends/mpd/protocol/audio_output.py +++ b/mopidy/frontends/mpd/protocol/audio_output.py @@ -1,6 +1,7 @@ from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import MpdNotImplemented + @handle_request(r'^disableoutput "(?P\d+)"$') def disableoutput(context, outputid): """ @@ -10,7 +11,8 @@ def disableoutput(context, outputid): Turns an output off. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^enableoutput "(?P\d+)"$') def enableoutput(context, outputid): @@ -21,7 +23,8 @@ def enableoutput(context, outputid): Turns an output on. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^outputs$') def outputs(context): diff --git a/mopidy/frontends/mpd/protocol/command_list.py b/mopidy/frontends/mpd/protocol/command_list.py index 37e5c93d..a58c11e2 100644 --- a/mopidy/frontends/mpd/protocol/command_list.py +++ b/mopidy/frontends/mpd/protocol/command_list.py @@ -1,6 +1,7 @@ from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import MpdUnknownCommand + @handle_request(r'^command_list_begin$') def command_list_begin(context): """ @@ -21,6 +22,7 @@ def command_list_begin(context): context.dispatcher.command_list = [] context.dispatcher.command_list_ok = False + @handle_request(r'^command_list_end$') def command_list_end(context): """See :meth:`command_list_begin()`.""" @@ -43,6 +45,7 @@ def command_list_end(context): command_list_response.append(u'list_OK') return command_list_response + @handle_request(r'^command_list_ok_begin$') def command_list_ok_begin(context): """See :meth:`command_list_begin()`.""" diff --git a/mopidy/frontends/mpd/protocol/connection.py b/mopidy/frontends/mpd/protocol/connection.py index ff230173..3228807f 100644 --- a/mopidy/frontends/mpd/protocol/connection.py +++ b/mopidy/frontends/mpd/protocol/connection.py @@ -1,7 +1,8 @@ from mopidy import settings from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.exceptions import (MpdPasswordError, - MpdPermissionError) +from mopidy.frontends.mpd.exceptions import ( + MpdPasswordError, MpdPermissionError) + @handle_request(r'^close$', auth_required=False) def close(context): @@ -14,6 +15,7 @@ def close(context): """ context.session.close() + @handle_request(r'^kill$') def kill(context): """ @@ -25,6 +27,7 @@ def kill(context): """ raise MpdPermissionError(command=u'kill') + @handle_request(r'^password "(?P[^"]+)"$', auth_required=False) def password_(context, password): """ @@ -40,6 +43,7 @@ def password_(context, password): else: raise MpdPasswordError(u'incorrect password', command=u'password') + @handle_request(r'^ping$', auth_required=False) def ping(context): """ diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 622f79c9..429af2cc 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -1,8 +1,8 @@ -from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError, - MpdNotImplemented) +from mopidy.frontends.mpd import translator +from mopidy.frontends.mpd.exceptions import ( + MpdArgError, MpdNoExistError, MpdNotImplemented) from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.translator import (track_to_mpd_format, - tracks_to_mpd_format) + @handle_request(r'^add "(?P[^"]*)"$') def add(context, uri): @@ -29,6 +29,7 @@ def add(context, uri): raise MpdNoExistError( u'directory or file not found', command=u'add') + @handle_request(r'^addid "(?P[^"]*)"( "(?P\d+)")*$') def addid(context, uri, songpos=None): """ @@ -57,10 +58,11 @@ def addid(context, uri, songpos=None): raise MpdNoExistError(u'No such song', command=u'addid') if songpos and songpos > context.core.current_playlist.length.get(): raise MpdArgError(u'Bad song index', command=u'addid') - cp_track = context.core.current_playlist.add(track, - at_position=songpos).get() + cp_track = context.core.current_playlist.add( + track, at_position=songpos).get() return ('Id', cp_track.cpid) + @handle_request(r'^delete "(?P\d+):(?P\d+)*"$') def delete_range(context, start, end=None): """ @@ -81,6 +83,7 @@ def delete_range(context, start, end=None): for (cpid, _) in cp_tracks: context.core.current_playlist.remove(cpid=cpid) + @handle_request(r'^delete "(?P\d+)"$') def delete_songpos(context, songpos): """See :meth:`delete_range`""" @@ -92,6 +95,7 @@ def delete_songpos(context, songpos): except IndexError: raise MpdArgError(u'Bad song index', command=u'delete') + @handle_request(r'^deleteid "(?P\d+)"$') def deleteid(context, cpid): """ @@ -109,6 +113,7 @@ def deleteid(context, cpid): except LookupError: raise MpdNoExistError(u'No such song', command=u'deleteid') + @handle_request(r'^clear$') def clear(context): """ @@ -120,6 +125,7 @@ def clear(context): """ context.core.current_playlist.clear() + @handle_request(r'^move "(?P\d+):(?P\d+)*" "(?P\d+)"$') def move_range(context, start, to, end=None): """ @@ -137,6 +143,7 @@ def move_range(context, start, to, end=None): to = int(to) context.core.current_playlist.move(start, end, to) + @handle_request(r'^move "(?P\d+)" "(?P\d+)"$') def move_songpos(context, songpos, to): """See :meth:`move_range`.""" @@ -144,6 +151,7 @@ def move_songpos(context, songpos, to): to = int(to) context.core.current_playlist.move(songpos, songpos + 1, to) + @handle_request(r'^moveid "(?P\d+)" "(?P\d+)"$') def moveid(context, cpid, to): """ @@ -161,6 +169,7 @@ def moveid(context, cpid, to): position = context.core.current_playlist.index(cp_track).get() context.core.current_playlist.move(position, position + 1, to) + @handle_request(r'^playlist$') def playlist(context): """ @@ -176,6 +185,7 @@ def playlist(context): """ return playlistinfo(context) + @handle_request(r'^playlistfind (?P[^"]+) "(?P[^"]+)"$') @handle_request(r'^playlistfind "(?P[^"]+)" "(?P[^"]+)"$') def playlistfind(context, tag, needle): @@ -194,10 +204,11 @@ def playlistfind(context, tag, needle): try: cp_track = context.core.current_playlist.get(uri=needle).get() position = context.core.current_playlist.index(cp_track).get() - return track_to_mpd_format(cp_track, position=position) + return translator.track_to_mpd_format(cp_track, position=position) except LookupError: return None - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^playlistid( "(?P\d+)")*$') def playlistid(context, cpid=None): @@ -214,19 +225,19 @@ def playlistid(context, cpid=None): cpid = int(cpid) cp_track = context.core.current_playlist.get(cpid=cpid).get() position = context.core.current_playlist.index(cp_track).get() - return track_to_mpd_format(cp_track, position=position) + return translator.track_to_mpd_format(cp_track, position=position) except LookupError: raise MpdNoExistError(u'No such song', command=u'playlistid') else: - return tracks_to_mpd_format( + return translator.tracks_to_mpd_format( context.core.current_playlist.cp_tracks.get()) + @handle_request(r'^playlistinfo$') @handle_request(r'^playlistinfo "-1"$') @handle_request(r'^playlistinfo "(?P-?\d+)"$') @handle_request(r'^playlistinfo "(?P\d+):(?P\d+)*"$') -def playlistinfo(context, songpos=None, - start=None, end=None): +def playlistinfo(context, songpos=None, start=None, end=None): """ *musicpd.org, current playlist section:* @@ -244,7 +255,7 @@ def playlistinfo(context, songpos=None, if songpos is not None: songpos = int(songpos) cp_track = context.core.current_playlist.cp_tracks.get()[songpos] - return track_to_mpd_format(cp_track, position=songpos) + return translator.track_to_mpd_format(cp_track, position=songpos) else: if start is None: start = 0 @@ -256,7 +267,8 @@ def playlistinfo(context, songpos=None, if end > context.core.current_playlist.length.get(): end = None cp_tracks = context.core.current_playlist.cp_tracks.get() - return tracks_to_mpd_format(cp_tracks, start, end) + return translator.tracks_to_mpd_format(cp_tracks, start, end) + @handle_request(r'^playlistsearch "(?P[^"]+)" "(?P[^"]+)"$') @handle_request(r'^playlistsearch (?P\S+) "(?P[^"]+)"$') @@ -274,7 +286,8 @@ def playlistsearch(context, tag, needle): - does not add quotes around the tag - uses ``filename`` and ``any`` as tags """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^plchanges (?P-?\d+)$') @handle_request(r'^plchanges "(?P-?\d+)"$') @@ -295,9 +308,10 @@ def plchanges(context, version): """ # XXX Naive implementation that returns all tracks as changed if int(version) < context.core.current_playlist.version: - return tracks_to_mpd_format( + return translator.tracks_to_mpd_format( context.core.current_playlist.cp_tracks.get()) + @handle_request(r'^plchangesposid "(?P\d+)"$') def plchangesposid(context, version): """ @@ -321,6 +335,7 @@ def plchangesposid(context, version): result.append((u'Id', cpid)) return result + @handle_request(r'^shuffle$') @handle_request(r'^shuffle "(?P\d+):(?P\d+)*"$') def shuffle(context, start=None, end=None): @@ -338,6 +353,7 @@ def shuffle(context, start=None, end=None): end = int(end) context.core.current_playlist.shuffle(start, end) + @handle_request(r'^swap "(?P\d+)" "(?P\d+)"$') def swap(context, songpos1, songpos2): """ @@ -359,6 +375,7 @@ def swap(context, songpos1, songpos2): context.core.current_playlist.clear() context.core.current_playlist.append(tracks) + @handle_request(r'^swapid "(?P\d+)" "(?P\d+)"$') def swapid(context, cpid1, cpid2): """ diff --git a/mopidy/frontends/mpd/protocol/empty.py b/mopidy/frontends/mpd/protocol/empty.py index 4cdafd87..f2ee4757 100644 --- a/mopidy/frontends/mpd/protocol/empty.py +++ b/mopidy/frontends/mpd/protocol/empty.py @@ -1,5 +1,6 @@ from mopidy.frontends.mpd.protocol import handle_request + @handle_request(r'^[ ]*$') def empty(context): """The original MPD server returns ``OK`` on an empty request.""" diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 2678714a..a5d5b214 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -5,6 +5,7 @@ from mopidy.frontends.mpd.exceptions import MpdArgError, MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request, stored_playlists from mopidy.frontends.mpd.translator import playlist_to_mpd_format + def _build_query(mpd_query): """ Parses a MPD query string and converts it to the Mopidy query format. @@ -21,7 +22,7 @@ def _build_query(mpd_query): field = m.groupdict()['field'].lower() if field == u'title': field = u'track' - field = str(field) # Needed for kwargs keys on OS X and Windows + field = str(field) # Needed for kwargs keys on OS X and Windows what = m.groupdict()['what'].lower() if field in query: query[field].append(what) @@ -29,6 +30,7 @@ def _build_query(mpd_query): query[field] = [what] return query + @handle_request(r'^count "(?P[^"]+)" "(?P[^"]*)"$') def count(context, tag, needle): """ @@ -39,11 +41,12 @@ def count(context, tag, needle): Counts the number of songs and their total playtime in the db matching ``TAG`` exactly. """ - return [('songs', 0), ('playtime', 0)] # TODO + return [('songs', 0), ('playtime', 0)] # TODO -@handle_request(r'^find ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|' - r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') + +@handle_request( + r'^find (?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|' + r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') def find(context, mpd_query): """ *musicpd.org, music database section:* @@ -72,9 +75,11 @@ def find(context, mpd_query): return playlist_to_mpd_format( context.core.library.find_exact(**query).get()) -@handle_request(r'^findadd ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? ' - '"[^"]+"\s?)+)$') + +@handle_request( + r'^findadd ' + r'(?P("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? ' + r'"[^"]+"\s?)+)$') def findadd(context, query): """ *musicpd.org, music database section:* @@ -88,8 +93,10 @@ def findadd(context, query): # TODO Add result to current playlist #result = context.find(query) -@handle_request(r'^list "?(?P([Aa]rtist|[Aa]lbum|[Dd]ate|[Gg]enre))"?' - '( (?P.*))?$') + +@handle_request( + r'^list "?(?P([Aa]rtist|[Aa]lbum|[Dd]ate|[Gg]enre))"?' + r'( (?P.*))?$') def list_(context, field, mpd_query=None): """ *musicpd.org, music database section:* @@ -183,7 +190,8 @@ def list_(context, field, mpd_query=None): elif field == u'date': return _list_date(context, query) elif field == u'genre': - pass # TODO We don't have genre in our internal data structures yet + pass # TODO We don't have genre in our internal data structures yet + def _list_build_query(field, mpd_query): """Converts a ``list`` query to a Mopidy query.""" @@ -208,7 +216,7 @@ def _list_build_query(field, mpd_query): query = {} while tokens: key = tokens[0].lower() - key = str(key) # Needed for kwargs keys on OS X and Windows + key = str(key) # Needed for kwargs keys on OS X and Windows value = tokens[1] tokens = tokens[2:] if key not in (u'artist', u'album', u'date', u'genre'): @@ -221,6 +229,7 @@ def _list_build_query(field, mpd_query): else: raise MpdArgError(u'not able to parse args', command=u'list') + def _list_artist(context, query): artists = set() playlist = context.core.library.find_exact(**query).get() @@ -229,6 +238,7 @@ def _list_artist(context, query): artists.add((u'Artist', artist.name)) return artists + def _list_album(context, query): albums = set() playlist = context.core.library.find_exact(**query).get() @@ -237,6 +247,7 @@ def _list_album(context, query): albums.add((u'Album', track.album.name)) return albums + def _list_date(context, query): dates = set() playlist = context.core.library.find_exact(**query).get() @@ -245,6 +256,7 @@ def _list_date(context, query): dates.add((u'Date', track.date)) return dates + @handle_request(r'^listall "(?P[^"]+)"') def listall(context, uri): """ @@ -254,7 +266,8 @@ def listall(context, uri): Lists all songs and directories in ``URI``. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^listallinfo "(?P[^"]+)"') def listallinfo(context, uri): @@ -266,7 +279,8 @@ def listallinfo(context, uri): Same as ``listall``, except it also returns metadata info in the same format as ``lsinfo``. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^lsinfo$') @handle_request(r'^lsinfo "(?P[^"]*)"$') @@ -288,7 +302,8 @@ def lsinfo(context, uri=None): """ if uri is None or uri == u'/' or uri == u'': return stored_playlists.listplaylists(context) - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^rescan( "(?P[^"]+)")*$') def rescan(context, uri=None): @@ -301,9 +316,10 @@ def rescan(context, uri=None): """ return update(context, uri, rescan_unmodified_files=True) -@handle_request(r'^search ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|' - r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') + +@handle_request( + r'^search (?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|' + r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') def search(context, mpd_query): """ *musicpd.org, music database section:* @@ -335,6 +351,7 @@ def search(context, mpd_query): return playlist_to_mpd_format( context.core.library.search(**query).get()) + @handle_request(r'^update( "(?P[^"]+)")*$') def update(context, uri=None, rescan_unmodified_files=False): """ @@ -352,4 +369,4 @@ def update(context, uri=None, rescan_unmodified_files=False): identifying the update job. You can read the current job id in the ``status`` response. """ - return {'updating_db': 0} # TODO + return {'updating_db': 0} # TODO diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 76cefdc3..7851ebe0 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -1,7 +1,8 @@ from mopidy.core import PlaybackState from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError, - MpdNotImplemented) +from mopidy.frontends.mpd.exceptions import ( + MpdArgError, MpdNoExistError, MpdNotImplemented) + @handle_request(r'^consume (?P[01])$') @handle_request(r'^consume "(?P[01])"$') @@ -20,6 +21,7 @@ def consume(context, state): else: context.core.playback.consume = False + @handle_request(r'^crossfade "(?P\d+)"$') def crossfade(context, seconds): """ @@ -30,7 +32,8 @@ def crossfade(context, seconds): Sets crossfading between songs. """ seconds = int(seconds) - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^next$') def next_(context): @@ -89,6 +92,7 @@ def next_(context): """ return context.core.playback.next().get() + @handle_request(r'^pause$') @handle_request(r'^pause "(?P[01])"$') def pause(context, state=None): @@ -113,6 +117,7 @@ def pause(context, state=None): else: context.core.playback.resume() + @handle_request(r'^play$') def play(context): """ @@ -121,6 +126,7 @@ def play(context): """ return context.core.playback.play().get() + @handle_request(r'^playid (?P-?\d+)$') @handle_request(r'^playid "(?P-?\d+)"$') def playid(context, cpid): @@ -149,6 +155,7 @@ def playid(context, cpid): except LookupError: raise MpdNoExistError(u'No such song', command=u'playid') + @handle_request(r'^play (?P-?\d+)$') @handle_request(r'^play "(?P-?\d+)"$') def playpos(context, songpos): @@ -182,9 +189,10 @@ def playpos(context, songpos): except IndexError: raise MpdArgError(u'Bad song index', command=u'play') + def _play_minus_one(context): if (context.core.playback.state.get() == PlaybackState.PLAYING): - return # Nothing to do + return # Nothing to do elif (context.core.playback.state.get() == PlaybackState.PAUSED): return context.core.playback.resume().get() elif context.core.playback.current_cp_track.get() is not None: @@ -194,7 +202,8 @@ def _play_minus_one(context): cp_track = context.core.current_playlist.slice(0, 1).get()[0] return context.core.playback.play(cp_track).get() else: - return # Fail silently + return # Fail silently + @handle_request(r'^previous$') def previous(context): @@ -242,6 +251,7 @@ def previous(context): """ return context.core.playback.previous().get() + @handle_request(r'^random (?P[01])$') @handle_request(r'^random "(?P[01])"$') def random(context, state): @@ -257,6 +267,7 @@ def random(context, state): else: context.core.playback.random = False + @handle_request(r'^repeat (?P[01])$') @handle_request(r'^repeat "(?P[01])"$') def repeat(context, state): @@ -272,6 +283,7 @@ def repeat(context, state): else: context.core.playback.repeat = False + @handle_request(r'^replay_gain_mode "(?P(off|track|album))"$') def replay_gain_mode(context, mode): """ @@ -286,7 +298,8 @@ def replay_gain_mode(context, mode): This command triggers the options idle event. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^replay_gain_status$') def replay_gain_status(context): @@ -298,7 +311,8 @@ def replay_gain_status(context): Prints replay gain options. Currently, only the variable ``replay_gain_mode`` is returned. """ - return u'off' # TODO + return u'off' # TODO + @handle_request(r'^seek (?P\d+) (?P\d+)$') @handle_request(r'^seek "(?P\d+)" "(?P\d+)"$') @@ -319,6 +333,7 @@ def seek(context, songpos, seconds): playpos(context, songpos) context.core.playback.seek(int(seconds) * 1000) + @handle_request(r'^seekid "(?P\d+)" "(?P\d+)"$') def seekid(context, cpid, seconds): """ @@ -332,6 +347,7 @@ def seekid(context, cpid, seconds): playid(context, cpid) context.core.playback.seek(int(seconds) * 1000) + @handle_request(r'^setvol (?P[-+]*\d+)$') @handle_request(r'^setvol "(?P[-+]*\d+)"$') def setvol(context, volume): @@ -353,6 +369,7 @@ def setvol(context, volume): volume = 100 context.core.playback.volume = volume + @handle_request(r'^single (?P[01])$') @handle_request(r'^single "(?P[01])"$') def single(context, state): @@ -370,6 +387,7 @@ def single(context, state): else: context.core.playback.single = False + @handle_request(r'^stop$') def stop(context): """ diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index 8cd1337b..bc18eb3a 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -1,6 +1,7 @@ from mopidy.frontends.mpd.protocol import handle_request, mpd_commands from mopidy.frontends.mpd.exceptions import MpdNotImplemented + @handle_request(r'^commands$', auth_required=False) def commands(context): """ @@ -13,16 +14,20 @@ def commands(context): if context.dispatcher.authenticated: command_names = set([command.name for command in mpd_commands]) else: - command_names = set([command.name for command in mpd_commands + command_names = set([ + command.name for command in mpd_commands if not command.auth_required]) # No one is permited to use kill, rest of commands are not listed by MPD, # so we shouldn't either. - command_names = command_names - set(['kill', 'command_list_begin', - 'command_list_ok_begin', 'command_list_ok_begin', 'command_list_end', - 'idle', 'noidle', 'sticker']) + command_names = command_names - set([ + 'kill', 'command_list_begin', 'command_list_ok_begin', + 'command_list_ok_begin', 'command_list_end', 'idle', 'noidle', + 'sticker']) + + return [ + ('command', command_name) for command_name in sorted(command_names)] - return [('command', command_name) for command_name in sorted(command_names)] @handle_request(r'^decoders$') def decoders(context): @@ -41,7 +46,8 @@ def decoders(context): plugin: mpcdec suffix: mpc """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^notcommands$', auth_required=False) def notcommands(context): @@ -55,13 +61,15 @@ def notcommands(context): if context.dispatcher.authenticated: command_names = [] else: - command_names = [command.name for command in mpd_commands - if command.auth_required] + command_names = [ + command.name for command in mpd_commands if command.auth_required] # No permission to use command_names.append('kill') - return [('command', command_name) for command_name in sorted(command_names)] + return [ + ('command', command_name) for command_name in sorted(command_names)] + @handle_request(r'^tagtypes$') def tagtypes(context): @@ -72,7 +80,8 @@ def tagtypes(context): Shows a list of available song metadata. """ - pass # TODO + pass # TODO + @handle_request(r'^urlhandlers$') def urlhandlers(context): @@ -83,5 +92,6 @@ def urlhandlers(context): Gets a list of available URL handlers. """ - return [(u'handler', uri_scheme) + return [ + (u'handler', uri_scheme) for uri_scheme in context.core.uri_schemes.get()] diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index 4f48265c..deda4986 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -6,8 +6,10 @@ from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.translator import track_to_mpd_format #: Subsystems that can be registered with idle command. -SUBSYSTEMS = ['database', 'mixer', 'options', 'output', - 'player', 'playlist', 'stored_playlist', 'update', ] +SUBSYSTEMS = [ + 'database', 'mixer', 'options', 'output', 'player', 'playlist', + 'stored_playlist', 'update'] + @handle_request(r'^clearerror$') def clearerror(context): @@ -19,7 +21,8 @@ def clearerror(context): Clears the current error message in status (this is also accomplished by any command that starts playback). """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^currentsong$') def currentsong(context): @@ -36,6 +39,7 @@ def currentsong(context): position = context.core.playback.current_playlist_position.get() return track_to_mpd_format(current_cp_track, position=position) + @handle_request(r'^idle$') @handle_request(r'^idle (?P.+)$') def idle(context, subsystems=None): @@ -93,6 +97,7 @@ def idle(context, subsystems=None): response.append(u'changed: %s' % subsystem) return response + @handle_request(r'^noidle$') def noidle(context): """See :meth:`_status_idle`.""" @@ -102,6 +107,7 @@ def noidle(context): context.events = set() context.session.prevent_timeout = False + @handle_request(r'^stats$') def stats(context): """ @@ -119,15 +125,16 @@ def stats(context): - ``playtime``: time length of music played """ return { - 'artists': 0, # TODO - 'albums': 0, # TODO - 'songs': 0, # TODO - 'uptime': 0, # TODO - 'db_playtime': 0, # TODO - 'db_update': 0, # TODO - 'playtime': 0, # TODO + 'artists': 0, # TODO + 'albums': 0, # TODO + 'songs': 0, # TODO + 'uptime': 0, # TODO + 'db_playtime': 0, # TODO + 'db_update': 0, # TODO + 'playtime': 0, # TODO } + @handle_request(r'^status$') def status(context): """ @@ -153,7 +160,7 @@ def status(context): - ``nextsongid``: playlist songid of the next song to be played - ``time``: total time elapsed (of current playing/paused song) - ``elapsed``: Total time elapsed within the current song, but with - higher resolution. + higher resolution. - ``bitrate``: instantaneous bitrate in kbps - ``xfade``: crossfade in seconds - ``audio``: sampleRate``:bits``:channels @@ -175,8 +182,8 @@ def status(context): 'playback.single': context.core.playback.single, 'playback.state': context.core.playback.state, 'playback.current_cp_track': context.core.playback.current_cp_track, - 'playback.current_playlist_position': - context.core.playback.current_playlist_position, + 'playback.current_playlist_position': ( + context.core.playback.current_playlist_position), 'playback.time_position': context.core.playback.time_position, } pykka.future.get_all(futures.values()) @@ -194,39 +201,47 @@ def status(context): if futures['playback.current_cp_track'].get() is not None: result.append(('song', _status_songpos(futures))) result.append(('songid', _status_songid(futures))) - if futures['playback.state'].get() in (PlaybackState.PLAYING, - PlaybackState.PAUSED): + if futures['playback.state'].get() in ( + PlaybackState.PLAYING, PlaybackState.PAUSED): result.append(('time', _status_time(futures))) result.append(('elapsed', _status_time_elapsed(futures))) result.append(('bitrate', _status_bitrate(futures))) return result + def _status_bitrate(futures): current_cp_track = futures['playback.current_cp_track'].get() if current_cp_track is not None: return current_cp_track.track.bitrate + def _status_consume(futures): if futures['playback.consume'].get(): return 1 else: return 0 + def _status_playlist_length(futures): return futures['current_playlist.length'].get() + def _status_playlist_version(futures): return futures['current_playlist.version'].get() + def _status_random(futures): return int(futures['playback.random'].get()) + def _status_repeat(futures): return int(futures['playback.repeat'].get()) + def _status_single(futures): return int(futures['playback.single'].get()) + def _status_songid(futures): current_cp_track = futures['playback.current_cp_track'].get() if current_cp_track is not None: @@ -234,9 +249,11 @@ def _status_songid(futures): else: return _status_songpos(futures) + def _status_songpos(futures): return futures['playback.current_playlist_position'].get() + def _status_state(futures): state = futures['playback.state'].get() if state == PlaybackState.PLAYING: @@ -246,13 +263,17 @@ def _status_state(futures): elif state == PlaybackState.PAUSED: return u'pause' + def _status_time(futures): - return u'%d:%d' % (futures['playback.time_position'].get() // 1000, + return u'%d:%d' % ( + futures['playback.time_position'].get() // 1000, _status_time_total(futures) // 1000) + def _status_time_elapsed(futures): return u'%.3f' % (futures['playback.time_position'].get() / 1000.0) + def _status_time_total(futures): current_cp_track = futures['playback.current_cp_track'].get() if current_cp_track is None: @@ -262,6 +283,7 @@ def _status_time_total(futures): else: return current_cp_track.track.length + def _status_volume(futures): volume = futures['playback.volume'].get() if volume is not None: @@ -269,5 +291,6 @@ def _status_volume(futures): else: return -1 + def _status_xfade(futures): - return 0 # Not supported + return 0 # Not supported diff --git a/mopidy/frontends/mpd/protocol/stickers.py b/mopidy/frontends/mpd/protocol/stickers.py index c3663ff1..074a306d 100644 --- a/mopidy/frontends/mpd/protocol/stickers.py +++ b/mopidy/frontends/mpd/protocol/stickers.py @@ -1,7 +1,9 @@ from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import MpdNotImplemented -@handle_request(r'^sticker delete "(?P[^"]+)" ' + +@handle_request( + r'^sticker delete "(?P[^"]+)" ' r'"(?P[^"]+)"( "(?P[^"]+)")*$') def sticker_delete(context, field, uri, name=None): """ @@ -12,9 +14,11 @@ def sticker_delete(context, field, uri, name=None): Deletes a sticker value from the specified object. If you do not specify a sticker name, all sticker values are deleted. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO -@handle_request(r'^sticker find "(?P[^"]+)" "(?P[^"]+)" ' + +@handle_request( + r'^sticker find "(?P[^"]+)" "(?P[^"]+)" ' r'"(?P[^"]+)"$') def sticker_find(context, field, uri, name): """ @@ -26,9 +30,11 @@ def sticker_find(context, field, uri, name): below the specified directory (``URI``). For each matching song, it prints the ``URI`` and that one sticker's value. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO -@handle_request(r'^sticker get "(?P[^"]+)" "(?P[^"]+)" ' + +@handle_request( + r'^sticker get "(?P[^"]+)" "(?P[^"]+)" ' r'"(?P[^"]+)"$') def sticker_get(context, field, uri, name): """ @@ -38,7 +44,8 @@ def sticker_get(context, field, uri, name): Reads a sticker value for the specified object. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^sticker list "(?P[^"]+)" "(?P[^"]+)"$') def sticker_list(context, field, uri): @@ -49,9 +56,11 @@ def sticker_list(context, field, uri): Lists the stickers for the specified object. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO -@handle_request(r'^sticker set "(?P[^"]+)" "(?P[^"]+)" ' + +@handle_request( + r'^sticker set "(?P[^"]+)" "(?P[^"]+)" ' r'"(?P[^"]+)" "(?P[^"]+)"$') def sticker_set(context, field, uri, name, value): """ @@ -62,4 +71,4 @@ def sticker_set(context, field, uri, name, value): Adds a sticker value to the specified object. If a sticker item with that name already exists, it is replaced. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index c21f4714..ed1c38ab 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -4,6 +4,7 @@ from mopidy.frontends.mpd.exceptions import MpdNoExistError, MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.translator import playlist_to_mpd_format + @handle_request(r'^listplaylist "(?P[^"]+)"$') def listplaylist(context, name): """ @@ -25,6 +26,7 @@ def listplaylist(context, name): except LookupError: raise MpdNoExistError(u'No such playlist', command=u'listplaylist') + @handle_request(r'^listplaylistinfo "(?P[^"]+)"$') def listplaylistinfo(context, name): """ @@ -46,6 +48,7 @@ def listplaylistinfo(context, name): raise MpdNoExistError( u'No such playlist', command=u'listplaylistinfo') + @handle_request(r'^listplaylists$') def listplaylists(context): """ @@ -70,8 +73,8 @@ def listplaylists(context): result = [] for playlist in context.core.stored_playlists.playlists.get(): result.append((u'playlist', playlist.name)) - last_modified = (playlist.last_modified or - dt.datetime.now()).isoformat() + last_modified = ( + playlist.last_modified or dt.datetime.now()).isoformat() # Remove microseconds last_modified = last_modified.split('.')[0] # Add time zone information @@ -80,6 +83,7 @@ def listplaylists(context): result.append((u'Last-Modified', last_modified)) return result + @handle_request(r'^load "(?P[^"]+)"$') def load(context, name): """ @@ -99,6 +103,7 @@ def load(context, name): except LookupError: raise MpdNoExistError(u'No such playlist', command=u'load') + @handle_request(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') def playlistadd(context, name, uri): """ @@ -110,7 +115,8 @@ def playlistadd(context, name, uri): ``NAME.m3u`` will be created if it does not exist. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^playlistclear "(?P[^"]+)"$') def playlistclear(context, name): @@ -121,7 +127,8 @@ def playlistclear(context, name): Clears the playlist ``NAME.m3u``. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^playlistdelete "(?P[^"]+)" "(?P\d+)"$') def playlistdelete(context, name, songpos): @@ -132,9 +139,11 @@ def playlistdelete(context, name, songpos): Deletes ``SONGPOS`` from the playlist ``NAME.m3u``. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO -@handle_request(r'^playlistmove "(?P[^"]+)" ' + +@handle_request( + r'^playlistmove "(?P[^"]+)" ' r'"(?P\d+)" "(?P\d+)"$') def playlistmove(context, name, from_pos, to_pos): """ @@ -151,7 +160,8 @@ def playlistmove(context, name, from_pos, to_pos): documentation, but just the ``SONGPOS`` to move *from*, i.e. ``playlistmove {NAME} {FROM_SONGPOS} {TO_SONGPOS}``. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^rename "(?P[^"]+)" "(?P[^"]+)"$') def rename(context, old_name, new_name): @@ -162,7 +172,8 @@ def rename(context, old_name, new_name): Renames the playlist ``NAME.m3u`` to ``NEW_NAME.m3u``. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^rm "(?P[^"]+)"$') def rm(context, name): @@ -173,7 +184,8 @@ def rm(context, name): Removes the playlist ``NAME.m3u`` from the playlist directory. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^save "(?P[^"]+)"$') def save(context, name): @@ -185,4 +197,4 @@ def save(context, name): Saves the current playlist to ``NAME.m3u`` in the playlist directory. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 6ae32c9e..0ab28271 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -6,6 +6,7 @@ from mopidy.frontends.mpd import protocol from mopidy.models import CpTrack from mopidy.utils.path import mtime as get_mtime, uri_to_path, split_path + def track_to_mpd_format(track, position=None): """ Format track for output to MPD client. @@ -48,8 +49,8 @@ def track_to_mpd_format(track, position=None): # FIXME don't use first and best artist? # FIXME don't duplicate following code? if track.album is not None and track.album.artists: - artists = filter(lambda a: a.musicbrainz_id is not None, - track.album.artists) + artists = filter( + lambda a: a.musicbrainz_id is not None, track.album.artists) if artists: result.append( ('MUSICBRAINZ_ALBUMARTISTID', artists[0].musicbrainz_id)) @@ -61,16 +62,19 @@ def track_to_mpd_format(track, position=None): result.append(('MUSICBRAINZ_TRACKID', track.musicbrainz_id)) return result + MPD_KEY_ORDER = ''' key file Time Artist AlbumArtist Title Album Track Date MUSICBRAINZ_ALBUMID MUSICBRAINZ_ALBUMARTISTID MUSICBRAINZ_ARTISTID MUSICBRAINZ_TRACKID mtime '''.split() + def order_mpd_track_info(result): """ - Order results from :func:`mopidy.frontends.mpd.translator.track_to_mpd_format` - so that it matches MPD's ordering. Simply a cosmetic fix for easier - diffing of tag_caches. + Order results from + :func:`mopidy.frontends.mpd.translator.track_to_mpd_format` so that it + matches MPD's ordering. Simply a cosmetic fix for easier diffing of + tag_caches. :param result: the track info :type result: list of tuples @@ -78,6 +82,7 @@ def order_mpd_track_info(result): """ return sorted(result, key=lambda i: MPD_KEY_ORDER.index(i[0])) + def artists_to_mpd_format(artists): """ Format track artists for output to MPD client. @@ -90,6 +95,7 @@ def artists_to_mpd_format(artists): artists.sort(key=lambda a: a.name) return u', '.join([a.name for a in artists if a.name]) + def tracks_to_mpd_format(tracks, start=0, end=None): """ Format list of tracks for output to MPD client. @@ -115,6 +121,7 @@ def tracks_to_mpd_format(tracks, start=0, end=None): result.append(track_to_mpd_format(track, position)) return result + def playlist_to_mpd_format(playlist, *args, **kwargs): """ Format playlist for output to MPD client. @@ -123,6 +130,7 @@ def playlist_to_mpd_format(playlist, *args, **kwargs): """ return tracks_to_mpd_format(playlist.tracks, *args, **kwargs) + def tracks_to_tag_cache_format(tracks): """ Format list of tracks for output to MPD tag cache @@ -141,6 +149,7 @@ def tracks_to_tag_cache_format(tracks): _add_to_tag_cache(result, *tracks_to_directory_tree(tracks)) return result + def _add_to_tag_cache(result, folders, files): music_folder = settings.LOCAL_MUSIC_PATH regexp = '^' + re.escape(music_folder).rstrip('/') + '/?' @@ -165,6 +174,7 @@ def _add_to_tag_cache(result, folders, files): result.extend(track_result) result.append(('songList end',)) + def tracks_to_directory_tree(tracks): directories = ({}, []) for track in tracks: diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 1a8797f2..80995adf 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -5,7 +5,7 @@ logger = logging.getLogger('mopidy.frontends.mpris') try: import indicate except ImportError as import_error: - indicate = None + indicate = None # noqa logger.debug(u'Startup notification will not be sent (%s)', import_error) from pykka.actor import ThreadingActor @@ -100,8 +100,8 @@ class MprisFrontend(ThreadingActor, core.CoreListener): props_with_new_values = [ (p, self.mpris_object.Get(objects.PLAYER_IFACE, p)) for p in changed_properties] - self.mpris_object.PropertiesChanged(objects.PLAYER_IFACE, - dict(props_with_new_values), []) + self.mpris_object.PropertiesChanged( + objects.PLAYER_IFACE, dict(props_with_new_values), []) def track_playback_paused(self, track, time_position): logger.debug(u'Received track playback paused event') diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 7c8b6f5a..ee54f91c 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -77,8 +77,8 @@ class MprisObject(dbus.service.Object): def _connect_to_dbus(self): logger.debug(u'Connecting to D-Bus...') mainloop = dbus.mainloop.glib.DBusGMainLoop() - bus_name = dbus.service.BusName(BUS_NAME, - dbus.SessionBus(mainloop=mainloop)) + bus_name = dbus.service.BusName( + BUS_NAME, dbus.SessionBus(mainloop=mainloop)) logger.info(u'Connected to D-Bus') return bus_name @@ -92,9 +92,10 @@ class MprisObject(dbus.service.Object): ### Properties interface @dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE, - in_signature='ss', out_signature='v') + in_signature='ss', out_signature='v') def Get(self, interface, prop): - logger.debug(u'%s.Get(%s, %s) called', + logger.debug( + u'%s.Get(%s, %s) called', dbus.PROPERTIES_IFACE, repr(interface), repr(prop)) (getter, setter) = self.properties[interface][prop] if callable(getter): @@ -103,35 +104,36 @@ class MprisObject(dbus.service.Object): return getter @dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE, - in_signature='s', out_signature='a{sv}') + in_signature='s', out_signature='a{sv}') def GetAll(self, interface): - logger.debug(u'%s.GetAll(%s) called', - dbus.PROPERTIES_IFACE, repr(interface)) + logger.debug( + u'%s.GetAll(%s) called', dbus.PROPERTIES_IFACE, repr(interface)) getters = {} for key, (getter, setter) in self.properties[interface].iteritems(): getters[key] = getter() if callable(getter) else getter return getters @dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE, - in_signature='ssv', out_signature='') + in_signature='ssv', out_signature='') def Set(self, interface, prop, value): - logger.debug(u'%s.Set(%s, %s, %s) called', + logger.debug( + u'%s.Set(%s, %s, %s) called', dbus.PROPERTIES_IFACE, repr(interface), repr(prop), repr(value)) getter, setter = self.properties[interface][prop] if setter is not None: setter(value) - self.PropertiesChanged(interface, - {prop: self.Get(interface, prop)}, []) + self.PropertiesChanged( + interface, {prop: self.Get(interface, prop)}, []) @dbus.service.signal(dbus_interface=dbus.PROPERTIES_IFACE, - signature='sa{sv}as') + signature='sa{sv}as') def PropertiesChanged(self, interface, changed_properties, - invalidated_properties): - logger.debug(u'%s.PropertiesChanged(%s, %s, %s) signaled', + invalidated_properties): + logger.debug( + u'%s.PropertiesChanged(%s, %s, %s) signaled', dbus.PROPERTIES_IFACE, interface, changed_properties, invalidated_properties) - ### Root interface methods @dbus.service.method(dbus_interface=ROOT_IFACE) @@ -144,7 +146,6 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.Quit called', ROOT_IFACE) exit_process() - ### Root interface properties def get_DesktopEntry(self): @@ -153,7 +154,6 @@ class MprisObject(dbus.service.Object): def get_SupportedUriSchemes(self): return dbus.Array(self.core.uri_schemes.get(), signature='s') - ### Player interface methods @dbus.service.method(dbus_interface=PLAYER_IFACE) @@ -263,7 +263,6 @@ class MprisObject(dbus.service.Object): else: logger.debug(u'Track with URI "%s" not found in library.', uri) - ### Player interface signals @dbus.service.signal(dbus_interface=PLAYER_IFACE, signature='x') @@ -271,7 +270,6 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.Seeked signaled', PLAYER_IFACE) # Do nothing, as just calling the method is enough to emit the signal. - ### Player interface properties def get_PlaybackStatus(self): @@ -383,20 +381,23 @@ class MprisObject(dbus.service.Object): def get_CanGoNext(self): if not self.get_CanControl(): return False - return (self.core.playback.cp_track_at_next.get() != + return ( + self.core.playback.cp_track_at_next.get() != self.core.playback.current_cp_track.get()) def get_CanGoPrevious(self): if not self.get_CanControl(): return False - return (self.core.playback.cp_track_at_previous.get() != + return ( + self.core.playback.cp_track_at_previous.get() != self.core.playback.current_cp_track.get()) def get_CanPlay(self): if not self.get_CanControl(): return False - return (self.core.playback.current_track.get() is not None - or self.core.playback.track_at_next.get() is not None) + return ( + self.core.playback.current_track.get() is not None or + self.core.playback.track_at_next.get() is not None) def get_CanPause(self): if not self.get_CanControl(): diff --git a/mopidy/models.py b/mopidy/models.py index 507ca088..8eaa4ee5 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -13,8 +13,9 @@ class ImmutableObject(object): def __init__(self, *args, **kwargs): for key, value in kwargs.items(): if not hasattr(self, key): - raise TypeError('__init__() got an unexpected keyword ' + \ - 'argument \'%s\'' % key) + raise TypeError( + u"__init__() got an unexpected keyword argument '%s'" % + key) self.__dict__[key] = value def __setattr__(self, name, value): @@ -71,8 +72,8 @@ class ImmutableObject(object): if hasattr(self, key): data[key] = values.pop(key) if values: - raise TypeError("copy() got an unexpected keyword argument '%s'" - % key) + raise TypeError( + u"copy() got an unexpected keyword argument '%s'" % key) return self.__class__(**data) def serialize(self): diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 29511c80..2c12d26a 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -10,6 +10,7 @@ import datetime from mopidy.utils.path import path_to_uri, find_files from mopidy.models import Track, Artist, Album + def translator(data): albumartist_kwargs = {} album_kwargs = {} @@ -37,7 +38,8 @@ def translator(data): _retrieve('musicbrainz-trackid', 'musicbrainz_id', track_kwargs) _retrieve('musicbrainz-artistid', 'musicbrainz_id', artist_kwargs) _retrieve('musicbrainz-albumid', 'musicbrainz_id', album_kwargs) - _retrieve('musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs) + _retrieve( + 'musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs) if albumartist_kwargs: album_kwargs['artists'] = [Artist(**albumartist_kwargs)] @@ -61,8 +63,8 @@ class Scanner(object): self.uribin = gst.element_factory_make('uridecodebin') self.uribin.set_property('caps', gst.Caps('audio/x-raw-int')) - self.uribin.connect('pad-added', self.process_new_pad, - fakesink.get_pad('sink')) + self.uribin.connect( + 'pad-added', self.process_new_pad, fakesink.get_pad('sink')) self.pipe = gst.element_factory_make('pipeline') self.pipe.add(self.uribin) @@ -106,7 +108,7 @@ class Scanner(object): self.next_uri() def get_duration(self): - self.pipe.get_state() # Block until state change is done. + self.pipe.get_state() # Block until state change is done. try: return self.pipe.query_duration( gst.FORMAT_TIME, None)[0] // gst.MSECOND diff --git a/mopidy/utils/__init__.py b/mopidy/utils/__init__.py index aacc2e85..839e4f79 100644 --- a/mopidy/utils/__init__.py +++ b/mopidy/utils/__init__.py @@ -2,7 +2,6 @@ from __future__ import division import locale import logging -import os import sys logger = logging.getLogger('mopidy.utils') diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index 2c68e429..d72f1392 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -61,8 +61,8 @@ def platform_info(): def python_info(): return { 'name': 'Python', - 'version': '%s %s' % (platform.python_implementation(), - platform.python_version()), + 'version': '%s %s' % ( + platform.python_implementation(), platform.python_version()), 'path': platform.__file__, } @@ -125,9 +125,11 @@ def _gstreamer_check_elements(): # Shoutcast output 'shout2send', ] - known_elements = [factory.get_name() for factory in + known_elements = [ + factory.get_name() for factory in gst.registry_get_default().get_feature_list(gst.TYPE_ELEMENT_FACTORY)] - return [(element, element in known_elements) for element in elements_to_check] + return [ + (element, element in known_elements) for element in elements_to_check] def pykka_info(): diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 191efa2f..9b9495d5 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -3,6 +3,7 @@ import logging.handlers from mopidy import get_version, get_platform, get_python, settings + def setup_logging(verbosity_level, save_debug_log): setup_root_logger() setup_console_logging(verbosity_level) @@ -13,10 +14,12 @@ def setup_logging(verbosity_level, save_debug_log): logger.info(u'Platform: %s', get_platform()) logger.info(u'Python: %s', get_python()) + def setup_root_logger(): root = logging.getLogger('') root.setLevel(logging.DEBUG) + def setup_console_logging(verbosity_level): if verbosity_level == 0: log_level = logging.WARNING @@ -37,6 +40,7 @@ def setup_console_logging(verbosity_level): if verbosity_level < 3: logging.getLogger('pykka').setLevel(logging.INFO) + def setup_debug_logging_to_file(): formatter = logging.Formatter(settings.DEBUG_LOG_FORMAT) handler = logging.handlers.RotatingFileHandler( @@ -46,6 +50,7 @@ def setup_debug_logging_to_file(): root = logging.getLogger('') root.addHandler(handler) + def indent(string, places=4, linebreak='\n'): lines = string.split(linebreak) if len(lines) == 1: diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index d2e0690b..2a637c9b 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -27,8 +27,10 @@ def try_ipv6_socket(): socket.socket(socket.AF_INET6).close() return True except IOError as error: - logger.debug(u'Platform supports IPv6, but socket ' - 'creation failed, disabling: %s', locale_decode(error)) + logger.debug( + u'Platform supports IPv6, but socket creation failed, ' + u'disabling: %s', + locale_decode(error)) return False @@ -59,7 +61,7 @@ class Server(object): """Setup listener and register it with gobject's event loop.""" def __init__(self, host, port, protocol, protocol_kwargs=None, - max_connections=5, timeout=30): + max_connections=5, timeout=30): self.protocol = protocol self.protocol_kwargs = protocol_kwargs or {} self.max_connections = max_connections @@ -114,8 +116,8 @@ class Server(object): pass def init_connection(self, sock, addr): - Connection(self.protocol, self.protocol_kwargs, - sock, addr, self.timeout) + Connection( + self.protocol, self.protocol_kwargs, sock, addr, self.timeout) class Connection(object): @@ -130,7 +132,7 @@ class Connection(object): def __init__(self, protocol, protocol_kwargs, sock, addr, timeout): sock.setblocking(False) - self.host, self.port = addr[:2] # IPv6 has larger addr + self.host, self.port = addr[:2] # IPv6 has larger addr self.sock = sock self.protocol = protocol @@ -214,7 +216,8 @@ class Connection(object): return try: - self.recv_id = gobject.io_add_watch(self.sock.fileno(), + self.recv_id = gobject.io_add_watch( + self.sock.fileno(), gobject.IO_IN | gobject.IO_ERR | gobject.IO_HUP, self.recv_callback) except socket.error as e: @@ -231,7 +234,8 @@ class Connection(object): return try: - self.send_id = gobject.io_add_watch(self.sock.fileno(), + self.send_id = gobject.io_add_watch( + self.sock.fileno(), gobject.IO_OUT | gobject.IO_ERR | gobject.IO_HUP, self.send_callback) except socket.error as e: @@ -372,8 +376,10 @@ class LineProtocol(ThreadingActor): try: return line.encode(self.encoding) except UnicodeError: - logger.warning(u'Stopping actor due to encode problem, data ' - 'supplied by client was not valid %s', self.encoding) + logger.warning( + u'Stopping actor due to encode problem, data ' + u'supplied by client was not valid %s', + self.encoding) self.stop() def decode(self, line): @@ -385,8 +391,10 @@ class LineProtocol(ThreadingActor): try: return line.decode(self.encoding) except UnicodeError: - logger.warning(u'Stopping actor due to decode problem, data ' - 'supplied by client was not valid %s', self.encoding) + logger.warning( + u'Stopping actor due to decode problem, data ' + u'supplied by client was not valid %s', + self.encoding) self.stop() def join_lines(self, lines): diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 7f1b9233..0cf02a4a 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -18,8 +18,9 @@ XDG_DIRS = { def get_or_create_folder(folder): folder = os.path.expanduser(folder) if os.path.isfile(folder): - raise OSError('A file with the same name as the desired ' \ - 'dir, "%s", already exists.' % folder) + raise OSError( + u'A file with the same name as the desired dir, ' + u'"%s", already exists.' % folder) elif not os.path.isdir(folder): logger.info(u'Creating dir %s', folder) os.makedirs(folder, 0755) @@ -47,7 +48,7 @@ def uri_to_path(uri): path = urllib.url2pathname(re.sub('^file:', '', uri)) else: path = urllib.url2pathname(re.sub('^file://', '', uri)) - return path.encode('latin1').decode('utf-8') # Undo double encoding + return path.encode('latin1').decode('utf-8') # Undo double encoding def split_path(path): diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 80d850fe..c45659bb 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -10,30 +10,35 @@ from mopidy import SettingsError logger = logging.getLogger('mopidy.utils.process') + def exit_process(): logger.debug(u'Interrupting main...') thread.interrupt_main() logger.debug(u'Interrupted main') + def exit_handler(signum, frame): """A :mod:`signal` handler which will exit the program on signal.""" signals = dict((k, v) for v, k in signal.__dict__.iteritems() - if v.startswith('SIG') and not v.startswith('SIG_')) + if v.startswith('SIG') and not v.startswith('SIG_')) logger.info(u'Got %s signal', signals[signum]) exit_process() + def stop_actors_by_class(klass): actors = ActorRegistry.get_by_class(klass) logger.debug(u'Stopping %d instance(s) of %s', len(actors), klass.__name__) for actor in actors: actor.stop() + def stop_remaining_actors(): num_actors = len(ActorRegistry.get_all()) while num_actors: logger.error( u'There are actor threads still running, this is probably a bug') - logger.debug(u'Seeing %d actor and %d non-actor thread(s): %s', + logger.debug( + u'Seeing %d actor and %d non-actor thread(s): %s', num_actors, threading.active_count() - num_actors, ', '.join([t.name for t in threading.enumerate()])) logger.debug(u'Stopping %d actor(s)...', num_actors) @@ -41,6 +46,7 @@ def stop_remaining_actors(): num_actors = len(ActorRegistry.get_all()) logger.debug(u'All actors stopped.') + class BaseThread(threading.Thread): def __init__(self): super(BaseThread, self).__init__() diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 5468b9bf..0ecdd827 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -32,7 +32,8 @@ class SettingsProxy(object): return self._get_settings_dict_from_module(local_settings_module) def _get_settings_dict_from_module(self, module): - settings = filter(lambda (key, value): self._is_setting(key), + settings = filter( + lambda (key, value): self._is_setting(key), module.__dict__.iteritems()) return dict(settings) @@ -50,7 +51,7 @@ class SettingsProxy(object): if not self._is_setting(attr): return - current = self.current # bind locally to avoid copying+updates + current = self.current # bind locally to avoid copying+updates if attr not in current: raise SettingsError(u'Setting "%s" is not set.' % attr) @@ -73,7 +74,8 @@ class SettingsProxy(object): if interactive: self._read_missing_settings_from_stdin(self.current, self.runtime) if self.get_errors(): - logger.error(u'Settings validation errors: %s', + logger.error( + u'Settings validation errors: %s', log.indent(self.get_errors_as_string())) raise SettingsError(u'Settings validation failed.') @@ -84,11 +86,13 @@ class SettingsProxy(object): def _read_from_stdin(self, prompt): if u'_PASSWORD' in prompt: - return (getpass.getpass(prompt) + return ( + getpass.getpass(prompt) .decode(sys.stdin.encoding, 'ignore')) else: sys.stdout.write(prompt) - return (sys.stdin.readline().strip() + return ( + sys.stdin.readline().strip() .decode(sys.stdin.encoding, 'ignore')) def get_errors(self): @@ -201,7 +205,8 @@ def format_settings_list(settings): lines.append(u'%s: %s' % ( key, log.indent(pprint.pformat(masked_value), places=2))) if value != default_value and default_value is not None: - lines.append(u' Default: %s' % + lines.append( + u' Default: %s' % log.indent(pprint.pformat(default_value), places=4)) if errors.get(key) is not None: lines.append(u' Error: %s' % errors[key]) @@ -235,13 +240,13 @@ def levenshtein(a, b, max=3): if n > m: return levenshtein(b, a) - current = xrange(n+1) - for i in xrange(1, m+1): + current = xrange(n + 1) + for i in xrange(1, m + 1): previous, current = current, [i] + [0] * n - for j in xrange(1, n+1): - add, delete = previous[j] + 1, current[j-1] + 1 - change = previous[j-1] - if a[j-1] != b[i-1]: + for j in xrange(1, n + 1): + add, delete = previous[j] + 1, current[j - 1] + 1 + change = previous[j - 1] + if a[j - 1] != b[i - 1]: change += 1 current[j] = min(add, delete, change) return current[n] From 4341b7c2efaa98b9997cf5841c050b3e52ca6978 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 14:01:17 +0200 Subject: [PATCH 071/233] Change author of mixers to 'Mopidy' --- mopidy/audio/mixers/auto.py | 2 +- mopidy/audio/mixers/fake.py | 2 +- mopidy/audio/mixers/nad.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py index 3dce11f7..a4bd8bdb 100644 --- a/mopidy/audio/mixers/auto.py +++ b/mopidy/audio/mixers/auto.py @@ -14,7 +14,7 @@ class AutoAudioMixer(gst.Bin): 'AutoAudioMixer', 'Mixer', 'Element automatically selects a mixer.', - 'Thomas Adamcik') + 'Mopidy') def __init__(self): gst.Bin.__init__(self) diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py index d44fbd71..0e397e55 100644 --- a/mopidy/audio/mixers/fake.py +++ b/mopidy/audio/mixers/fake.py @@ -11,7 +11,7 @@ class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): 'FakeMixer', 'Mixer', 'Fake mixer for use in tests.', - 'Thomas Adamcik') + 'Mopidy') track_label = gobject.property(type=str, default='Master') track_initial_volume = gobject.property(type=int, default=0) diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index d50c1242..39a7b25e 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -23,7 +23,7 @@ class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): 'NadMixer', 'Mixer', 'Mixer to control NAD amplifiers using a serial link', - 'Stein Magnus Jodal') + 'Mopidy') port = gobject.property(type=str, default='/dev/ttyUSB0') source = gobject.property(type=str) From ac60bcdf8e1162f9a72d1ed9aa3b003868be5332 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 14:43:31 +0200 Subject: [PATCH 072/233] Fix all flake8 warnings in tests (#211) --- tests/__init__.py | 2 +- tests/audio_test.py | 14 +-- tests/backends/base/current_playlist.py | 11 ++- tests/backends/base/library.py | 12 ++- tests/backends/base/playback.py | 45 +++++---- tests/backends/base/stored_playlists.py | 2 +- tests/backends/local/current_playlist_test.py | 10 +- tests/backends/local/library_test.py | 4 - tests/backends/local/playback_test.py | 8 +- tests/backends/local/stored_playlists_test.py | 6 +- tests/backends/local/translator_test.py | 57 ++++++----- tests/frontends/mpd/dispatcher_test.py | 8 +- tests/frontends/mpd/exception_test.py | 14 ++- tests/frontends/mpd/protocol/__init__.py | 18 ++-- .../mpd/protocol/command_list_test.py | 4 +- .../frontends/mpd/protocol/connection_test.py | 2 +- .../mpd/protocol/current_playlist_test.py | 16 +-- tests/frontends/mpd/protocol/playback_test.py | 44 ++++----- .../frontends/mpd/protocol/regression_test.py | 5 +- .../mpd/protocol/stored_playlists_test.py | 9 +- tests/frontends/mpd/serializer_test.py | 11 ++- tests/frontends/mpd/status_test.py | 4 +- tests/frontends/mpris/events_test.py | 7 +- .../frontends/mpris/player_interface_test.py | 92 +++++++++--------- tests/models_test.py | 97 ++++++++++--------- tests/scanner_test.py | 9 +- tests/utils/deps_test.py | 6 +- tests/utils/network/connection_test.py | 59 ++++++----- tests/utils/network/lineprotocol_test.py | 4 +- tests/utils/network/server_test.py | 65 +++++++------ tests/utils/network/utils_test.py | 8 +- tests/utils/path_test.py | 21 ++-- tests/utils/settings_test.py | 70 +++++++------ 33 files changed, 396 insertions(+), 348 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 833ff239..5d9ea2b5 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -4,7 +4,7 @@ import sys if sys.version_info < (2, 7): import unittest2 as unittest else: - import unittest + import unittest # noqa from mopidy import settings diff --git a/tests/audio_test.py b/tests/audio_test.py index fcafa75f..852ce36b 100644 --- a/tests/audio_test.py +++ b/tests/audio_test.py @@ -1,13 +1,9 @@ -import sys - from mopidy import audio, settings from mopidy.utils.path import path_to_uri from tests import unittest, path_to_data_dir -@unittest.skipIf(sys.platform == 'win32', - 'Our Windows build server does not support GStreamer yet') class AudioTest(unittest.TestCase): def setUp(self): settings.MIXER = 'fakemixer track_max_volume=65536' @@ -43,11 +39,11 @@ class AudioTest(unittest.TestCase): @unittest.SkipTest def test_deliver_data(self): - pass # TODO + pass # TODO @unittest.SkipTest def test_end_of_data_stream(self): - pass # TODO + pass # TODO def test_set_volume(self): for value in range(0, 101): @@ -56,12 +52,12 @@ class AudioTest(unittest.TestCase): @unittest.SkipTest def test_set_state_encapsulation(self): - pass # TODO + pass # TODO @unittest.SkipTest def test_set_position(self): - pass # TODO + pass # TODO @unittest.SkipTest def test_invalid_output_raises_error(self): - pass # TODO + pass # TODO diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index db4473bb..00ffaea8 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -48,7 +48,8 @@ class CurrentPlaylistControllerTest(object): @populate_playlist def test_add_at_position_outside_of_playlist(self): - test = lambda: self.controller.add(self.tracks[0], len(self.tracks)+2) + test = lambda: self.controller.add( + self.tracks[0], len(self.tracks) + 2) self.assertRaises(AssertionError, test) @populate_playlist @@ -180,19 +181,19 @@ class CurrentPlaylistControllerTest(object): @populate_playlist def test_moving_track_outside_of_playlist(self): tracks = len(self.controller.tracks) - test = lambda: self.controller.move(0, 0, tracks+5) + test = lambda: self.controller.move(0, 0, tracks + 5) self.assertRaises(AssertionError, test) @populate_playlist def test_move_group_outside_of_playlist(self): tracks = len(self.controller.tracks) - test = lambda: self.controller.move(0, 2, tracks+5) + test = lambda: self.controller.move(0, 2, tracks + 5) self.assertRaises(AssertionError, test) @populate_playlist def test_move_group_out_of_range(self): tracks = len(self.controller.tracks) - test = lambda: self.controller.move(tracks+2, tracks+3, 0) + test = lambda: self.controller.move(tracks + 2, tracks + 3, 0) self.assertRaises(AssertionError, test) @populate_playlist @@ -253,7 +254,7 @@ class CurrentPlaylistControllerTest(object): @populate_playlist def test_shuffle_superset(self): tracks = len(self.controller.tracks) - test = lambda: self.controller.shuffle(1, tracks+5) + test = lambda: self.controller.shuffle(1, tracks + 5) self.assertRaises(AssertionError, test) @populate_playlist diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index 99dce78e..85ba54bb 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -1,5 +1,3 @@ -import mock - from pykka.registry import ActorRegistry from mopidy import core @@ -10,12 +8,16 @@ from tests import unittest, path_to_data_dir class LibraryControllerTest(object): artists = [Artist(name='artist1'), Artist(name='artist2'), Artist()] - albums = [Album(name='album1', artists=artists[:1]), + albums = [ + Album(name='album1', artists=artists[:1]), Album(name='album2', artists=artists[1:2]), Album()] - tracks = [Track(name='track1', length=4000, artists=artists[:1], + tracks = [ + Track( + name='track1', length=4000, artists=artists[:1], album=albums[0], uri='file://' + path_to_data_dir('uri1')), - Track(name='track2', length=4000, artists=artists[1:2], + Track( + name='track2', length=4000, artists=artists[1:2], album=albums[1], uri='file://' + path_to_data_dir('uri2')), Track()] diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 46863f03..5a3b9157 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -125,10 +125,10 @@ class PlaybackControllerTest(object): @populate_playlist def test_previous_more(self): - self.playback.play() # At track 0 - self.playback.next() # At track 1 - self.playback.next() # At track 2 - self.playback.previous() # At track 1 + self.playback.play() # At track 0 + self.playback.next() # At track 1 + self.playback.next() # At track 2 + self.playback.previous() # At track 1 self.assertEqual(self.playback.current_track, self.tracks[1]) @populate_playlist @@ -175,8 +175,8 @@ class PlaybackControllerTest(object): self.playback.next() - self.assertEqual(self.playback.current_playlist_position, - old_position+1) + self.assertEqual( + self.playback.current_playlist_position, old_position + 1) self.assertNotEqual(self.playback.current_track.uri, old_uri) @populate_playlist @@ -311,8 +311,8 @@ class PlaybackControllerTest(object): self.playback.on_end_of_track() - self.assertEqual(self.playback.current_playlist_position, - old_position+1) + self.assertEqual( + self.playback.current_playlist_position, old_position + 1) self.assertNotEqual(self.playback.current_track.uri, old_uri) @populate_playlist @@ -406,7 +406,6 @@ class PlaybackControllerTest(object): self.playback.random = True self.assertEqual(self.playback.track_at_next, self.tracks[2]) - @populate_playlist def test_end_of_track_with_consume(self): self.playback.consume = True @@ -448,10 +447,10 @@ class PlaybackControllerTest(object): @populate_playlist def test_previous_track_after_previous(self): - self.playback.play() # At track 0 - self.playback.next() # At track 1 - self.playback.next() # At track 2 - self.playback.previous() # At track 1 + self.playback.play() # At track 0 + self.playback.next() # At track 1 + self.playback.next() # At track 2 + self.playback.previous() # At track 1 self.assertEqual(self.playback.track_at_previous, self.tracks[0]) def test_previous_track_empty_playlist(self): @@ -462,16 +461,16 @@ class PlaybackControllerTest(object): self.playback.consume = True for _ in self.tracks: self.playback.next() - self.assertEqual(self.playback.track_at_previous, - self.playback.current_track) + self.assertEqual( + self.playback.track_at_previous, self.playback.current_track) @populate_playlist def test_previous_track_with_random(self): self.playback.random = True for _ in self.tracks: self.playback.next() - self.assertEqual(self.playback.track_at_previous, - self.playback.current_track) + self.assertEqual( + self.playback.track_at_previous, self.playback.current_track) @populate_playlist def test_initial_current_track(self): @@ -522,7 +521,7 @@ class PlaybackControllerTest(object): self.assert_(wrapper.called) - @unittest.SkipTest # Blocks for 10ms + @unittest.SkipTest # Blocks for 10ms @populate_playlist def test_end_of_track_callback_gets_called(self): self.playback.play() @@ -601,7 +600,7 @@ class PlaybackControllerTest(object): self.playback.pause() self.assertEqual(self.playback.resume(), None) - @unittest.SkipTest # Uses sleep and might not work with LocalBackend + @unittest.SkipTest # Uses sleep and might not work with LocalBackend @populate_playlist def test_resume_continues_from_right_position(self): self.playback.play() @@ -675,13 +674,13 @@ class PlaybackControllerTest(object): def test_seek_beyond_end_of_song(self): # FIXME need to decide return value self.playback.play() - result = self.playback.seek(self.tracks[0].length*100) + result = self.playback.seek(self.tracks[0].length * 100) self.assert_(not result, 'Seek return value was %s' % result) @populate_playlist def test_seek_beyond_end_of_song_jumps_to_next_song(self): self.playback.play() - self.playback.seek(self.tracks[0].length*100) + self.playback.seek(self.tracks[0].length * 100) self.assertEqual(self.playback.current_track, self.tracks[1]) @populate_playlist @@ -743,7 +742,7 @@ class PlaybackControllerTest(object): self.assertEqual(self.playback.time_position, 0) - @unittest.SkipTest # Uses sleep and does might not work with LocalBackend + @unittest.SkipTest # Uses sleep and does might not work with LocalBackend @populate_playlist def test_time_position_when_playing(self): self.playback.play() @@ -752,7 +751,7 @@ class PlaybackControllerTest(object): second = self.playback.time_position self.assertGreater(second, first) - @unittest.SkipTest # Uses sleep + @unittest.SkipTest # Uses sleep @populate_playlist def test_time_position_when_paused(self): self.playback.play() diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 4e65c034..c16be173 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -19,7 +19,7 @@ class StoredPlaylistsControllerTest(object): self.audio = mock.Mock(spec=audio.Audio) self.backend = self.backend_class.start(audio=self.audio).proxy() self.core = core.Core(backend=self.backend) - self.stored = self.core.stored_playlists + self.stored = self.core.stored_playlists def tearDown(self): if os.path.exists(settings.LOCAL_PLAYLIST_PATH): diff --git a/tests/backends/local/current_playlist_test.py b/tests/backends/local/current_playlist_test.py index a475a6fd..52fa9eb5 100644 --- a/tests/backends/local/current_playlist_test.py +++ b/tests/backends/local/current_playlist_test.py @@ -1,5 +1,3 @@ -import sys - from mopidy import settings from mopidy.backends.local import LocalBackend from mopidy.models import Track @@ -9,14 +7,12 @@ from tests.backends.base.current_playlist import CurrentPlaylistControllerTest from tests.backends.local import generate_song -@unittest.skipIf(sys.platform == 'win32', - 'Our Windows build server does not support GStreamer yet') class LocalCurrentPlaylistControllerTest(CurrentPlaylistControllerTest, - unittest.TestCase): + unittest.TestCase): backend_class = LocalBackend - tracks = [Track(uri=generate_song(i), length=4464) - for i in range(1, 4)] + tracks = [ + Track(uri=generate_song(i), length=4464) for i in range(1, 4)] def setUp(self): settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index 046e747a..75cebdbc 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -1,5 +1,3 @@ -import sys - from mopidy import settings from mopidy.backends.local import LocalBackend @@ -7,8 +5,6 @@ from tests import unittest, path_to_data_dir from tests.backends.base.library import LibraryControllerTest -@unittest.skipIf(sys.platform == 'win32', - 'Our Windows build server does not support GStreamer yet') class LocalLibraryControllerTest(LibraryControllerTest, unittest.TestCase): backend_class = LocalBackend diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index fe5fee32..fea93ae3 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -1,5 +1,3 @@ -import sys - from mopidy import settings from mopidy.backends.local import LocalBackend from mopidy.core import PlaybackState @@ -11,12 +9,10 @@ from tests.backends.base.playback import PlaybackControllerTest from tests.backends.local import generate_song -@unittest.skipIf(sys.platform == 'win32', - 'Our Windows build server does not support GStreamer yet') class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): backend_class = LocalBackend - tracks = [Track(uri=generate_song(i), length=4464) - for i in range(1, 4)] + tracks = [ + Track(uri=generate_song(i), length=4464) for i in range(1, 4)] def setUp(self): settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index 3f3d9c58..437152fe 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -1,6 +1,4 @@ import os -import sys - from mopidy import settings from mopidy.backends.local import LocalBackend from mopidy.models import Playlist, Track @@ -12,10 +10,8 @@ from tests.backends.base.stored_playlists import ( from tests.backends.local import generate_song -@unittest.skipIf(sys.platform == 'win32', - 'Our Windows build server does not support GStreamer yet') class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest, - unittest.TestCase): + unittest.TestCase): backend_class = LocalBackend diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py index 08f29c1b..6f754399 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/backends/local/translator_test.py @@ -55,7 +55,7 @@ class M3UToUriTest(unittest.TestCase): def test_file_with_multiple_absolute_files(self): with tempfile.NamedTemporaryFile(delete=False) as tmp: - tmp.write(song1_path+'\n') + tmp.write(song1_path + '\n') tmp.write('# comment \n') tmp.write(song2_path) try: @@ -87,17 +87,21 @@ class M3UToUriTest(unittest.TestCase): class URItoM3UTest(unittest.TestCase): pass + expected_artists = [Artist(name='name')] -expected_albums = [Album(name='albumname', artists=expected_artists, - num_tracks=2)] +expected_albums = [ + Album(name='albumname', artists=expected_artists, num_tracks=2)] expected_tracks = [] + def generate_track(path, ident): uri = path_to_uri(path_to_data_dir(path)) - track = Track(name='trackname', artists=expected_artists, track_no=1, + track = Track( + name='trackname', artists=expected_artists, track_no=1, album=expected_albums[0], length=4000, uri=uri) expected_tracks.append(track) + generate_track('song1.mp3', 6) generate_track('song2.mp3', 7) generate_track('song3.mp3', 8) @@ -108,34 +112,36 @@ generate_track('subdir2/song7.mp3', 5) generate_track('subdir1/subsubdir/song8.mp3', 0) generate_track('subdir1/subsubdir/song9.mp3', 1) + class MPDTagCacheToTracksTest(unittest.TestCase): def test_emtpy_cache(self): - tracks = parse_mpd_tag_cache(path_to_data_dir('empty_tag_cache'), - path_to_data_dir('')) + tracks = parse_mpd_tag_cache( + path_to_data_dir('empty_tag_cache'), path_to_data_dir('')) self.assertEqual(set(), tracks) def test_simple_cache(self): - tracks = parse_mpd_tag_cache(path_to_data_dir('simple_tag_cache'), - path_to_data_dir('')) + tracks = parse_mpd_tag_cache( + path_to_data_dir('simple_tag_cache'), path_to_data_dir('')) uri = path_to_uri(path_to_data_dir('song1.mp3')) - track = Track(name='trackname', artists=expected_artists, track_no=1, + track = Track( + name='trackname', artists=expected_artists, track_no=1, album=expected_albums[0], length=4000, uri=uri) self.assertEqual(set([track]), tracks) def test_advanced_cache(self): - tracks = parse_mpd_tag_cache(path_to_data_dir('advanced_tag_cache'), - path_to_data_dir('')) + tracks = parse_mpd_tag_cache( + path_to_data_dir('advanced_tag_cache'), path_to_data_dir('')) self.assertEqual(set(expected_tracks), tracks) def test_unicode_cache(self): - tracks = parse_mpd_tag_cache(path_to_data_dir('utf8_tag_cache'), - path_to_data_dir('')) + tracks = parse_mpd_tag_cache( + path_to_data_dir('utf8_tag_cache'), path_to_data_dir('')) uri = path_to_uri(path_to_data_dir('song1.mp3')) artists = [Artist(name=u'æøå')] album = Album(name=u'æøå', artists=artists) - track = Track(uri=uri, name=u'æøå', artists=artists, - album=album, length=4000) + track = Track( + uri=uri, name=u'æøå', artists=artists, album=album, length=4000) self.assertEqual(track, list(tracks)[0]) @@ -145,32 +151,35 @@ class MPDTagCacheToTracksTest(unittest.TestCase): pass def test_cache_with_blank_track_info(self): - tracks = parse_mpd_tag_cache(path_to_data_dir('blank_tag_cache'), - path_to_data_dir('')) + tracks = parse_mpd_tag_cache( + path_to_data_dir('blank_tag_cache'), path_to_data_dir('')) uri = path_to_uri(path_to_data_dir('song1.mp3')) self.assertEqual(set([Track(uri=uri, length=4000)]), tracks) def test_musicbrainz_tagcache(self): - tracks = parse_mpd_tag_cache(path_to_data_dir('musicbrainz_tag_cache'), - path_to_data_dir('')) + tracks = parse_mpd_tag_cache( + path_to_data_dir('musicbrainz_tag_cache'), path_to_data_dir('')) artist = list(expected_tracks[0].artists)[0].copy( musicbrainz_id='7364dea6-ca9a-48e3-be01-b44ad0d19897') albumartist = list(expected_tracks[0].artists)[0].copy( name='albumartistname', musicbrainz_id='7364dea6-ca9a-48e3-be01-b44ad0d19897') - album = expected_tracks[0].album.copy(artists=[albumartist], + album = expected_tracks[0].album.copy( + artists=[albumartist], musicbrainz_id='cb5f1603-d314-4c9c-91e5-e295cfb125d2') - track = expected_tracks[0].copy(artists=[artist], album=album, + track = expected_tracks[0].copy( + artists=[artist], album=album, musicbrainz_id='90488461-8c1f-4a4e-826b-4c6dc70801f0') self.assertEqual(track, list(tracks)[0]) def test_albumartist_tag_cache(self): - tracks = parse_mpd_tag_cache(path_to_data_dir('albumartist_tag_cache'), - path_to_data_dir('')) + tracks = parse_mpd_tag_cache( + path_to_data_dir('albumartist_tag_cache'), path_to_data_dir('')) uri = path_to_uri(path_to_data_dir('song1.mp3')) artist = Artist(name='albumartistname') album = expected_albums[0].copy(artists=[artist]) - track = Track(name='trackname', artists=expected_artists, track_no=1, + track = Track( + name='trackname', artists=expected_artists, track_no=1, album=album, length=4000, uri=uri) self.assertEqual(track, list(tracks)[0]) diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/frontends/mpd/dispatcher_test.py index 0bff04e7..0b5098c1 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -32,14 +32,16 @@ class MpdDispatcherTest(unittest.TestCase): self.dispatcher._find_handler('an_unknown_command with args') self.fail('Should raise exception') except MpdAckError as e: - self.assertEqual(e.get_mpd_ack(), + self.assertEqual( + e.get_mpd_ack(), u'ACK [5@0] {} unknown command "an_unknown_command"') - def test_finding_handler_for_known_command_returns_handler_and_kwargs(self): + def test_find_handler_for_known_command_returns_handler_and_kwargs(self): expected_handler = lambda x: None request_handlers['known_command (?P.+)'] = \ expected_handler - (handler, kwargs) = self.dispatcher._find_handler('known_command an_arg') + (handler, kwargs) = self.dispatcher._find_handler( + 'known_command an_arg') self.assertEqual(handler, expected_handler) self.assertIn('arg1', kwargs) self.assertEqual(kwargs['arg1'], 'an_arg') diff --git a/tests/frontends/mpd/exception_test.py b/tests/frontends/mpd/exception_test.py index 2ea3fe62..8fb0c933 100644 --- a/tests/frontends/mpd/exception_test.py +++ b/tests/frontends/mpd/exception_test.py @@ -1,5 +1,6 @@ -from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdPermissionError, - MpdUnknownCommand, MpdSystemError, MpdNotImplemented) +from mopidy.frontends.mpd.exceptions import ( + MpdAckError, MpdPermissionError, MpdUnknownCommand, MpdSystemError, + MpdNotImplemented) from tests import unittest @@ -34,19 +35,22 @@ class MpdExceptionsTest(unittest.TestCase): try: raise MpdUnknownCommand(command=u'play') except MpdAckError as e: - self.assertEqual(e.get_mpd_ack(), + self.assertEqual( + e.get_mpd_ack(), u'ACK [5@0] {} unknown command "play"') def test_mpd_system_error(self): try: raise MpdSystemError('foo') except MpdSystemError as e: - self.assertEqual(e.get_mpd_ack(), + self.assertEqual( + e.get_mpd_ack(), u'ACK [52@0] {} foo') def test_mpd_permission_error(self): try: raise MpdPermissionError(command='foo') except MpdPermissionError as e: - self.assertEqual(e.get_mpd_ack(), + self.assertEqual( + e.get_mpd_ack(), u'ACK [4@0] {foo} you don\'t have permission for "foo"') diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index 041b6532..63c253d9 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -45,17 +45,23 @@ class BaseTestCase(unittest.TestCase): self.assertEqual([], self.connection.response) def assertInResponse(self, value): - self.assertIn(value, self.connection.response, u'Did not find %s ' - 'in %s' % (repr(value), repr(self.connection.response))) + self.assertIn( + value, self.connection.response, + u'Did not find %s in %s' % ( + repr(value), repr(self.connection.response))) def assertOnceInResponse(self, value): matched = len([r for r in self.connection.response if r == value]) - self.assertEqual(1, matched, 'Expected to find %s once in %s' % - (repr(value), repr(self.connection.response))) + self.assertEqual( + 1, matched, + u'Expected to find %s once in %s' % ( + repr(value), repr(self.connection.response))) def assertNotInResponse(self, value): - self.assertNotIn(value, self.connection.response, u'Found %s in %s' % - (repr(value), repr(self.connection.response))) + self.assertNotIn( + value, self.connection.response, + u'Found %s in %s' % ( + repr(value), repr(self.connection.response))) def assertEqualResponse(self, value): self.assertEqual(1, len(self.connection.response)) diff --git a/tests/frontends/mpd/protocol/command_list_test.py b/tests/frontends/mpd/protocol/command_list_test.py index 65b051d3..64ef8688 100644 --- a/tests/frontends/mpd/protocol/command_list_test.py +++ b/tests/frontends/mpd/protocol/command_list_test.py @@ -28,8 +28,8 @@ class CommandListsTest(protocol.BaseTestCase): def test_command_list_with_error_returns_ack_with_correct_index(self): self.sendRequest(u'command_list_begin') - self.sendRequest(u'play') # Known command - self.sendRequest(u'paly') # Unknown command + self.sendRequest(u'play') # Known command + self.sendRequest(u'paly') # Unknown command self.sendRequest(u'command_list_end') self.assertEqualResponse(u'ACK [5@1] {} unknown command "paly"') diff --git a/tests/frontends/mpd/protocol/connection_test.py b/tests/frontends/mpd/protocol/connection_test.py index cd08313f..9b8972d3 100644 --- a/tests/frontends/mpd/protocol/connection_test.py +++ b/tests/frontends/mpd/protocol/connection_test.py @@ -8,7 +8,7 @@ from tests.frontends.mpd import protocol class ConnectionHandlerTest(protocol.BaseTestCase): def test_close_closes_the_client_connection(self): with patch.object(self.session, 'close') as close_mock: - response = self.sendRequest(u'close') + self.sendRequest(u'close') close_mock.assertEqualResponsecalled_once_with() self.assertEqualResponse(u'OK') diff --git a/tests/frontends/mpd/protocol/current_playlist_test.py b/tests/frontends/mpd/protocol/current_playlist_test.py index 63c4a42b..a64b08ea 100644 --- a/tests/frontends/mpd/protocol/current_playlist_test.py +++ b/tests/frontends/mpd/protocol/current_playlist_test.py @@ -38,8 +38,8 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.sendRequest(u'addid "dummy://foo"') self.assertEqual(len(self.core.current_playlist.tracks.get()), 6) self.assertEqual(self.core.current_playlist.tracks.get()[5], needle) - self.assertInResponse(u'Id: %d' % - self.core.current_playlist.cp_tracks.get()[5][0]) + self.assertInResponse( + u'Id: %d' % self.core.current_playlist.cp_tracks.get()[5][0]) self.assertInResponse(u'OK') def test_addid_with_empty_uri_acks(self): @@ -57,8 +57,8 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.sendRequest(u'addid "dummy://foo" "3"') self.assertEqual(len(self.core.current_playlist.tracks.get()), 6) self.assertEqual(self.core.current_playlist.tracks.get()[3], needle) - self.assertInResponse(u'Id: %d' % - self.core.current_playlist.cp_tracks.get()[3][0]) + self.assertInResponse( + u'Id: %d' % self.core.current_playlist.cp_tracks.get()[3][0]) self.assertInResponse(u'OK') def test_addid_with_songpos_out_of_bounds_should_ack(self): @@ -91,8 +91,8 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) - self.sendRequest(u'delete "%d"' % - self.core.current_playlist.cp_tracks.get()[2][0]) + self.sendRequest( + u'delete "%d"' % self.core.current_playlist.cp_tracks.get()[2][0]) self.assertEqual(len(self.core.current_playlist.tracks.get()), 4) self.assertInResponse(u'OK') @@ -233,7 +233,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.core.current_playlist.append([ Track(uri='file:///exists')]) - self.sendRequest( u'playlistfind filename "file:///exists"') + self.sendRequest(u'playlistfind filename "file:///exists"') self.assertInResponse(u'file: file:///exists') self.assertInResponse(u'Id: 0') self.assertInResponse(u'Pos: 0') @@ -357,7 +357,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playlistsearch(self): - self.sendRequest( u'playlistsearch "any" "needle"') + self.sendRequest(u'playlistsearch "any" "needle"') self.assertEqualResponse(u'ACK [0@0] {} Not implemented') def test_playlistsearch_without_quotes(self): diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 2380c7bc..431c4663 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -259,29 +259,29 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_play_minus_is_ignored_if_playing(self): self.core.current_playlist.append([Track(length=40000)]) self.core.playback.seek(30000) - self.assertGreaterEqual(self.core.playback.time_position.get(), - 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertEquals(PLAYING, self.core.playback.state.get()) self.sendRequest(u'play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertGreaterEqual(self.core.playback.time_position.get(), - 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_play_minus_one_resumes_if_paused(self): self.core.current_playlist.append([Track(length=40000)]) self.core.playback.seek(30000) - self.assertGreaterEqual(self.core.playback.time_position.get(), - 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertEquals(PLAYING, self.core.playback.state.get()) self.core.playback.pause() self.assertEquals(PAUSED, self.core.playback.state.get()) self.sendRequest(u'play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertGreaterEqual(self.core.playback.time_position.get(), - 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_playid(self): @@ -298,7 +298,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') - def test_playid_minus_one_plays_first_in_playlist_if_no_current_track(self): + 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.current_playlist.append([Track(uri='a'), Track(uri='b')]) @@ -307,7 +307,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertEqual('a', self.core.playback.current_track.get().uri) self.assertInResponse(u'OK') - def test_playid_minus_one_plays_current_track_if_current_track_is_set(self): + def test_playid_minus_1_plays_current_track_if_current_track_is_set(self): self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.assertEqual(self.core.playback.current_track.get(), None) self.core.playback.play() @@ -331,29 +331,29 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_playid_minus_is_ignored_if_playing(self): self.core.current_playlist.append([Track(length=40000)]) self.core.playback.seek(30000) - self.assertGreaterEqual(self.core.playback.time_position.get(), - 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertEquals(PLAYING, self.core.playback.state.get()) self.sendRequest(u'playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertGreaterEqual(self.core.playback.time_position.get(), - 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_playid_minus_one_resumes_if_paused(self): self.core.current_playlist.append([Track(length=40000)]) self.core.playback.seek(30000) - self.assertGreaterEqual(self.core.playback.time_position.get(), - 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertEquals(PLAYING, self.core.playback.state.get()) self.core.playback.pause() self.assertEquals(PAUSED, self.core.playback.state.get()) self.sendRequest(u'playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertGreaterEqual(self.core.playback.time_position.get(), - 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_playid_which_does_not_exist(self): @@ -388,15 +388,15 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.sendRequest(u'seek 0') self.sendRequest(u'seek 0 30') - self.assertGreaterEqual(self.core.playback.time_position.get(), - 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_seekid(self): self.core.current_playlist.append([Track(length=40000)]) self.sendRequest(u'seekid "0" "30"') - self.assertGreaterEqual(self.core.playback.time_position.get(), - 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_seekid_with_cpid(self): diff --git a/tests/frontends/mpd/protocol/regression_test.py b/tests/frontends/mpd/protocol/regression_test.py index 90bcaf60..a7b7611d 100644 --- a/tests/frontends/mpd/protocol/regression_test.py +++ b/tests/frontends/mpd/protocol/regression_test.py @@ -19,7 +19,7 @@ class IssueGH17RegressionTest(protocol.BaseTestCase): self.core.current_playlist.append([ Track(uri='a'), Track(uri='b'), None, Track(uri='d'), Track(uri='e'), Track(uri='f')]) - random.seed(1) # Playlist order: abcfde + random.seed(1) # Playlist order: abcfde self.sendRequest(u'play') self.assertEquals('a', self.core.playback.current_track.get().uri) @@ -158,7 +158,8 @@ class IssueGH137RegressionTest(protocol.BaseTestCase): """ def test(self): - self.sendRequest(u'list Date Artist "Anita Ward" ' + self.sendRequest( + u'list Date Artist "Anita Ward" ' u'Album "This Is Remixed Hits - Mashups & Rare 12" Mixes"') self.assertInResponse('ACK [2@0] {list} Invalid unquoted character') diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index 0bf9756f..8cfcb338 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -35,8 +35,8 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists(self): last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) - self.core.stored_playlists.playlists = [Playlist(name='a', - last_modified=last_modified)] + self.core.stored_playlists.playlists = [ + Playlist(name='a', last_modified=last_modified)] self.sendRequest(u'listplaylists') self.assertInResponse(u'playlist: a') @@ -47,8 +47,9 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): def test_load_known_playlist_appends_to_current_playlist(self): self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) - self.core.stored_playlists.playlists = [Playlist(name='A-list', - tracks=[Track(uri='c'), Track(uri='d'), Track(uri='e')])] + self.core.stored_playlists.playlists = [ + Playlist(name='A-list', tracks=[ + Track(uri='c'), Track(uri='d'), Track(uri='e')])] self.sendRequest(u'load "A-list"') tracks = self.core.current_playlist.tracks.get() diff --git a/tests/frontends/mpd/serializer_test.py b/tests/frontends/mpd/serializer_test.py index e6cd80e2..2d2a9f87 100644 --- a/tests/frontends/mpd/serializer_test.py +++ b/tests/frontends/mpd/serializer_test.py @@ -49,7 +49,8 @@ class TrackMpdFormatTest(unittest.TestCase): self.assertNotIn(('Id', 1), result) def test_track_to_mpd_format_with_position_and_cpid(self): - result = translator.track_to_mpd_format(CpTrack(2, Track()), position=1) + result = translator.track_to_mpd_format( + CpTrack(2, Track()), position=1) self.assertIn(('Pos', 1), result) self.assertIn(('Id', 2), result) @@ -79,7 +80,7 @@ class TrackMpdFormatTest(unittest.TestCase): result = translator.track_to_mpd_format(track) self.assertIn(('MUSICBRAINZ_ALBUMID', 'foo'), result) - def test_track_to_mpd_format_musicbrainz_albumid(self): + def test_track_to_mpd_format_musicbrainz_albumartistid(self): artist = list(self.track.artists)[0].copy(musicbrainz_id='foo') album = self.track.album.copy(artists=[artist]) track = self.track.copy(album=album) @@ -131,7 +132,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): folder = settings.LOCAL_MUSIC_PATH result = dict(translator.track_to_mpd_format(track)) result['file'] = uri_to_path(result['file']) - result['file'] = result['file'][len(folder)+1:] + result['file'] = result['file'][len(folder) + 1:] result['key'] = os.path.basename(result['file']) result['mtime'] = mtime('') return translator.order_mpd_track_info(result.items()) @@ -147,7 +148,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): self.assertEqual(('songList begin',), result[0]) for i, row in enumerate(result): if row == ('songList end',): - return result[1:i], result[i+1:] + return result[1:i], result[i + 1:] self.fail("Couldn't find songList end in result") def consume_directory(self, result): @@ -157,7 +158,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): directory = result[2][1] for i, row in enumerate(result): if row == ('end', directory): - return result[3:i], result[i+1:] + return result[3:i], result[i + 1:] self.fail("Couldn't find end %s in result" % directory) def test_empty_tag_cache_has_header(self): diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 6322ec36..61fd0854 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -1,6 +1,6 @@ from pykka.registry import ActorRegistry -from mopidy import audio, core +from mopidy import core from mopidy.backends import dummy from mopidy.core import PlaybackState from mopidy.frontends.mpd import dispatcher @@ -97,7 +97,7 @@ class StatusHandlerTest(unittest.TestCase): def test_status_method_contains_playlist(self): result = dict(status.status(self.context)) self.assertIn('playlist', result) - self.assertIn(int(result['playlist']), xrange(0, 2**31 - 1)) + self.assertIn(int(result['playlist']), xrange(0, 2 ** 31 - 1)) def test_status_method_contains_playlistlength(self): result = dict(status.status(self.context)) diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py index f466e207..241b9365 100644 --- a/tests/frontends/mpris/events_test.py +++ b/tests/frontends/mpris/events_test.py @@ -16,7 +16,8 @@ from tests import unittest @unittest.skipUnless(sys.platform.startswith('linux'), 'requires Linux') class BackendEventsTest(unittest.TestCase): def setUp(self): - self.mpris_frontend = MprisFrontend(core=None) # As a plain class, not an actor + # As a plain class, not an actor: + self.mpris_frontend = MprisFrontend(core=None) self.mpris_object = mock.Mock(spec=objects.MprisObject) self.mpris_frontend.mpris_object = self.mpris_object @@ -38,7 +39,7 @@ class BackendEventsTest(unittest.TestCase): self.mpris_object.PropertiesChanged.assert_called_with( objects.PLAYER_IFACE, {'PlaybackStatus': 'Playing'}, []) - def test_track_playback_started_event_changes_playback_status_and_metadata(self): + def test_track_playback_started_changes_playback_status_and_metadata(self): self.mpris_object.Get.return_value = '...' self.mpris_frontend.track_playback_started(Track()) self.assertListEqual(self.mpris_object.Get.call_args_list, [ @@ -49,7 +50,7 @@ class BackendEventsTest(unittest.TestCase): objects.PLAYER_IFACE, {'Metadata': '...', 'PlaybackStatus': '...'}, []) - def test_track_playback_ended_event_changes_playback_status_and_metadata(self): + def test_track_playback_ended_changes_playback_status_and_metadata(self): self.mpris_object.Get.return_value = '...' self.mpris_frontend.track_playback_ended(Track(), 0) self.assertListEqual(self.mpris_object.Get.call_args_list, [ diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index 403d05c7..6088a94b 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -59,7 +59,7 @@ class PlayerInterfaceTest(unittest.TestCase): result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') self.assertEqual('Track', result) - def test_get_loop_status_is_playlist_when_looping_the_current_playlist(self): + def test_get_loop_status_is_playlist_when_looping_current_playlist(self): self.core.playback.repeat = True self.core.playback.single = False result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') @@ -126,19 +126,19 @@ class PlayerInterfaceTest(unittest.TestCase): def test_set_shuffle_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False self.core.playback.random = False - result = self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) + self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) self.assertFalse(self.core.playback.random.get()) def test_set_shuffle_to_true_activates_random_mode(self): self.core.playback.random = False self.assertFalse(self.core.playback.random.get()) - result = self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) + self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) self.assertTrue(self.core.playback.random.get()) def test_set_shuffle_to_false_deactivates_random_mode(self): self.core.playback.random = True self.assertTrue(self.core.playback.random.get()) - result = self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', False) + self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', False) self.assertFalse(self.core.playback.random.get()) def test_get_metadata_has_trackid_even_when_no_current_track(self): @@ -152,8 +152,8 @@ class PlayerInterfaceTest(unittest.TestCase): (cpid, track) = self.core.playback.current_cp_track.get() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('mpris:trackid', result.keys()) - self.assertEquals(result['mpris:trackid'], - '/com/mopidy/track/%d' % cpid) + self.assertEquals( + result['mpris:trackid'], '/com/mopidy/track/%d' % cpid) def test_get_metadata_has_track_length(self): self.core.current_playlist.append([Track(uri='a', length=40000)]) @@ -233,7 +233,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) self.assertEquals(self.core.playback.volume.get(), 100) - def test_set_volume_to_anything_above_one_should_set_mixer_volume_to_100(self): + def test_set_volume_to_anything_above_one_sets_mixer_volume_to_100(self): self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 2.0) self.assertEquals(self.core.playback.volume.get(), 100) @@ -246,12 +246,14 @@ class PlayerInterfaceTest(unittest.TestCase): self.core.current_playlist.append([Track(uri='a', length=40000)]) self.core.playback.play() self.core.playback.seek(10000) - result_in_microseconds = self.mpris.Get(objects.PLAYER_IFACE, 'Position') + result_in_microseconds = self.mpris.Get( + objects.PLAYER_IFACE, 'Position') result_in_milliseconds = result_in_microseconds // 1000 self.assertGreaterEqual(result_in_milliseconds, 10000) def test_get_position_when_no_current_track_should_be_zero(self): - result_in_microseconds = self.mpris.Get(objects.PLAYER_IFACE, 'Position') + result_in_microseconds = self.mpris.Get( + objects.PLAYER_IFACE, 'Position') result_in_milliseconds = result_in_microseconds // 1000 self.assertEquals(result_in_milliseconds, 0) @@ -285,7 +287,7 @@ class PlayerInterfaceTest(unittest.TestCase): result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') self.assertFalse(result) - def test_can_go_previous_is_true_if_can_control_and_other_previous_track(self): + def test_can_go_previous_is_true_if_can_control_and_previous_track(self): self.mpris.get_CanControl = lambda *_: True self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.core.playback.play() @@ -360,7 +362,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Next() self.assertEquals(self.core.playback.current_track.get().uri, 'a') - def test_next_when_playing_should_skip_to_next_track_and_keep_playing(self): + def test_next_when_playing_skips_to_next_track_and_keep_playing(self): self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.core.playback.play() self.assertEquals(self.core.playback.current_track.get().uri, 'a') @@ -388,7 +390,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEquals(self.core.playback.current_track.get().uri, 'b') self.assertEquals(self.core.playback.state.get(), PAUSED) - def test_next_when_stopped_should_skip_to_next_track_and_stay_stopped(self): + def test_next_when_stopped_skips_to_next_track_and_stay_stopped(self): self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.core.playback.play() self.core.playback.stop() @@ -407,7 +409,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Previous() self.assertEquals(self.core.playback.current_track.get().uri, 'b') - def test_previous_when_playing_should_skip_to_prev_track_and_keep_playing(self): + def test_previous_when_playing_skips_to_prev_track_and_keep_playing(self): self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.core.playback.play() self.core.playback.next() @@ -425,7 +427,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Previous() self.assertEquals(self.core.playback.state.get(), STOPPED) - def test_previous_when_paused_should_skip_to_previous_track_and_stay_paused(self): + def test_previous_when_paused_skips_to_previous_track_and_pause(self): self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.core.playback.play() self.core.playback.next() @@ -436,7 +438,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEquals(self.core.playback.current_track.get().uri, 'a') self.assertEquals(self.core.playback.state.get(), PAUSED) - def test_previous_when_stopped_should_skip_to_previous_track_and_stay_stopped(self): + def test_previous_when_stopped_skips_to_previous_track_and_stops(self): self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.core.playback.play() self.core.playback.next() @@ -638,8 +640,9 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertLess(after_seek, before_seek) self.assertGreaterEqual(after_seek, 0) - def test_seek_skips_to_next_track_if_new_position_larger_than_track_length(self): - self.core.current_playlist.append([Track(uri='a', length=40000), + def test_seek_skips_to_next_track_if_new_position_gt_track_length(self): + self.core.current_playlist.append([ + Track(uri='a', length=40000), Track(uri='b')]) self.core.playback.play() self.core.playback.seek(20000) @@ -671,14 +674,14 @@ class PlayerInterfaceTest(unittest.TestCase): track_id = 'a' - position_to_set_in_milliseconds = 20000 - position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + position_to_set_in_millisec = 20000 + position_to_set_in_microsec = position_to_set_in_millisec * 1000 - self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + self.mpris.SetPosition(track_id, position_to_set_in_microsec) after_set_position = self.core.playback.time_position.get() self.assertLessEqual(before_set_position, after_set_position) - self.assertLess(after_set_position, position_to_set_in_milliseconds) + self.assertLess(after_set_position, position_to_set_in_millisec) def test_set_position_sets_the_current_track_position_in_microsecs(self): self.core.current_playlist.append([Track(uri='a', length=40000)]) @@ -690,15 +693,16 @@ class PlayerInterfaceTest(unittest.TestCase): track_id = '/com/mopidy/track/0' - position_to_set_in_milliseconds = 20000 - position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + position_to_set_in_millisec = 20000 + position_to_set_in_microsec = position_to_set_in_millisec * 1000 - self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + self.mpris.SetPosition(track_id, position_to_set_in_microsec) self.assertEquals(self.core.playback.state.get(), PLAYING) after_set_position = self.core.playback.time_position.get() - self.assertGreaterEqual(after_set_position, position_to_set_in_milliseconds) + self.assertGreaterEqual( + after_set_position, position_to_set_in_millisec) def test_set_position_does_nothing_if_the_position_is_negative(self): self.core.current_playlist.append([Track(uri='a', length=40000)]) @@ -713,17 +717,17 @@ class PlayerInterfaceTest(unittest.TestCase): track_id = '/com/mopidy/track/0' - position_to_set_in_milliseconds = -1000 - position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + position_to_set_in_millisec = -1000 + position_to_set_in_microsec = position_to_set_in_millisec * 1000 - self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + self.mpris.SetPosition(track_id, position_to_set_in_microsec) after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(after_set_position, before_set_position) self.assertEquals(self.core.playback.state.get(), PLAYING) self.assertEquals(self.core.playback.current_track.get().uri, 'a') - def test_set_position_does_nothing_if_position_is_larger_than_track_length(self): + def test_set_position_does_nothing_if_position_is_gt_track_length(self): self.core.current_playlist.append([Track(uri='a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) @@ -736,17 +740,17 @@ class PlayerInterfaceTest(unittest.TestCase): track_id = 'a' - position_to_set_in_milliseconds = 50000 - position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + position_to_set_in_millisec = 50000 + position_to_set_in_microsec = position_to_set_in_millisec * 1000 - self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + self.mpris.SetPosition(track_id, position_to_set_in_microsec) after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(after_set_position, before_set_position) self.assertEquals(self.core.playback.state.get(), PLAYING) self.assertEquals(self.core.playback.current_track.get().uri, 'a') - def test_set_position_does_nothing_if_track_id_does_not_match_current_track(self): + def test_set_position_is_noop_if_track_id_isnt_current_track(self): self.core.current_playlist.append([Track(uri='a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) @@ -759,10 +763,10 @@ class PlayerInterfaceTest(unittest.TestCase): track_id = 'b' - position_to_set_in_milliseconds = 0 - position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + position_to_set_in_millisec = 0 + position_to_set_in_microsec = position_to_set_in_millisec * 1000 - self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + self.mpris.SetPosition(track_id, position_to_set_in_microsec) after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(after_set_position, before_set_position) @@ -789,8 +793,8 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(self.core.current_playlist.tracks.get()[0].uri, - 'dummy:/test/uri') + self.assertEquals( + self.core.current_playlist.tracks.get()[0].uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_stopped(self): self.mpris.get_CanPlay = lambda *_: True @@ -802,8 +806,8 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.OpenUri('dummy:/test/uri') self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, - 'dummy:/test/uri') + self.assertEquals( + self.core.playback.current_track.get().uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_paused(self): self.mpris.get_CanPlay = lambda *_: True @@ -818,8 +822,8 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.OpenUri('dummy:/test/uri') self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, - 'dummy:/test/uri') + self.assertEquals( + self.core.playback.current_track.get().uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_playing(self): self.mpris.get_CanPlay = lambda *_: True @@ -833,5 +837,5 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.OpenUri('dummy:/test/uri') self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, - 'dummy:/test/uri') + self.assertEquals( + self.core.playback.current_track.get().uri, 'dummy:/test/uri') diff --git a/tests/models_test.py b/tests/models_test.py index 779d1a4b..a3c9cc96 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -67,8 +67,8 @@ class ArtistTest(unittest.TestCase): mb_id = u'mb-id' artist = Artist(musicbrainz_id=mb_id) self.assertEqual(artist.musicbrainz_id, mb_id) - self.assertRaises(AttributeError, setattr, artist, - 'musicbrainz_id', None) + self.assertRaises( + AttributeError, setattr, artist, 'musicbrainz_id', None) def test_invalid_kwarg(self): test = lambda: Artist(foo='baz') @@ -168,8 +168,8 @@ class AlbumTest(unittest.TestCase): mb_id = u'mb-id' album = Album(musicbrainz_id=mb_id) self.assertEqual(album.musicbrainz_id, mb_id) - self.assertRaises(AttributeError, setattr, album, - 'musicbrainz_id', None) + self.assertRaises( + AttributeError, setattr, album, 'musicbrainz_id', None) def test_invalid_kwarg(self): test = lambda: Album(foo='baz') @@ -237,9 +237,11 @@ class AlbumTest(unittest.TestCase): def test_eq(self): artists = [Artist()] - album1 = Album(name=u'name', uri=u'uri', artists=artists, num_tracks=2, + album1 = Album( + name=u'name', uri=u'uri', artists=artists, num_tracks=2, musicbrainz_id='id') - album2 = Album(name=u'name', uri=u'uri', artists=artists, num_tracks=2, + album2 = Album( + name=u'name', uri=u'uri', artists=artists, num_tracks=2, musicbrainz_id='id') self.assertEqual(album1, album2) self.assertEqual(hash(album1), hash(album2)) @@ -281,12 +283,12 @@ class AlbumTest(unittest.TestCase): self.assertNotEqual(hash(album1), hash(album2)) def test_ne(self): - album1 = Album(name=u'name1', uri=u'uri1', - artists=[Artist(name=u'name1')], num_tracks=1, - musicbrainz_id='id1') - album2 = Album(name=u'name2', uri=u'uri2', - artists=[Artist(name=u'name2')], num_tracks=2, - musicbrainz_id='id2') + album1 = Album( + name=u'name1', uri=u'uri1', artists=[Artist(name=u'name1')], + num_tracks=1, musicbrainz_id='id1') + album2 = Album( + name=u'name2', uri=u'uri2', artists=[Artist(name=u'name2')], + num_tracks=2, musicbrainz_id='id2') self.assertNotEqual(album1, album2) self.assertNotEqual(hash(album1), hash(album2)) @@ -359,8 +361,8 @@ class TrackTest(unittest.TestCase): mb_id = u'mb-id' track = Track(musicbrainz_id=mb_id) self.assertEqual(track.musicbrainz_id, mb_id) - self.assertRaises(AttributeError, setattr, track, - 'musicbrainz_id', None) + self.assertRaises( + AttributeError, setattr, track, 'musicbrainz_id', None) def test_invalid_kwarg(self): test = lambda: Track(foo='baz') @@ -462,12 +464,12 @@ class TrackTest(unittest.TestCase): date = '1977-01-01' artists = [Artist()] album = Album() - track1 = Track(uri=u'uri', name=u'name', artists=artists, album=album, - track_no=1, date=date, length=100, bitrate=100, - musicbrainz_id='id') - track2 = Track(uri=u'uri', name=u'name', artists=artists, album=album, - track_no=1, date=date, length=100, bitrate=100, - musicbrainz_id='id') + track1 = Track( + uri=u'uri', name=u'name', artists=artists, album=album, track_no=1, + date=date, length=100, bitrate=100, musicbrainz_id='id') + track2 = Track( + uri=u'uri', name=u'name', artists=artists, album=album, track_no=1, + date=date, length=100, bitrate=100, musicbrainz_id='id') self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) @@ -532,14 +534,14 @@ class TrackTest(unittest.TestCase): self.assertNotEqual(hash(track1), hash(track2)) def test_ne(self): - track1 = Track(uri=u'uri1', name=u'name1', - artists=[Artist(name=u'name1')], album=Album(name=u'name1'), - track_no=1, date='1977-01-01', length=100, bitrate=100, - musicbrainz_id='id1') - track2 = Track(uri=u'uri2', name=u'name2', - artists=[Artist(name=u'name2')], album=Album(name=u'name2'), - track_no=2, date='1977-01-02', length=200, bitrate=200, - musicbrainz_id='id2') + track1 = Track( + uri=u'uri1', name=u'name1', artists=[Artist(name=u'name1')], + album=Album(name=u'name1'), track_no=1, date='1977-01-01', + length=100, bitrate=100, musicbrainz_id='id1') + track2 = Track( + uri=u'uri2', name=u'name2', artists=[Artist(name=u'name2')], + album=Album(name=u'name2'), track_no=2, date='1977-01-02', + length=200, bitrate=200, musicbrainz_id='id2') self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) @@ -572,13 +574,14 @@ class PlaylistTest(unittest.TestCase): last_modified = datetime.datetime.now() playlist = Playlist(last_modified=last_modified) self.assertEqual(playlist.last_modified, last_modified) - self.assertRaises(AttributeError, setattr, playlist, 'last_modified', - None) + self.assertRaises( + AttributeError, setattr, playlist, 'last_modified', None) def test_with_new_uri(self): tracks = [Track()] last_modified = datetime.datetime.now() - playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, + playlist = Playlist( + uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) new_playlist = playlist.copy(uri=u'another uri') self.assertEqual(new_playlist.uri, u'another uri') @@ -589,7 +592,8 @@ class PlaylistTest(unittest.TestCase): def test_with_new_name(self): tracks = [Track()] last_modified = datetime.datetime.now() - playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, + playlist = Playlist( + uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) new_playlist = playlist.copy(name=u'another name') self.assertEqual(new_playlist.uri, u'an uri') @@ -600,7 +604,8 @@ class PlaylistTest(unittest.TestCase): def test_with_new_tracks(self): tracks = [Track()] last_modified = datetime.datetime.now() - playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, + playlist = Playlist( + uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) new_tracks = [Track(), Track()] new_playlist = playlist.copy(tracks=new_tracks) @@ -613,7 +618,8 @@ class PlaylistTest(unittest.TestCase): tracks = [Track()] last_modified = datetime.datetime.now() new_last_modified = last_modified + datetime.timedelta(1) - playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, + playlist = Playlist( + uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) new_playlist = playlist.copy(last_modified=new_last_modified) self.assertEqual(new_playlist.uri, u'an uri') @@ -666,7 +672,7 @@ class PlaylistTest(unittest.TestCase): self.assertEqual(playlist1, playlist2) self.assertEqual(hash(playlist1), hash(playlist2)) - def test_eq_uri(self): + def test_eq_last_modified(self): playlist1 = Playlist(last_modified=1) playlist2 = Playlist(last_modified=1) self.assertEqual(playlist1, playlist2) @@ -674,10 +680,10 @@ class PlaylistTest(unittest.TestCase): def test_eq(self): tracks = [Track()] - playlist1 = Playlist(uri=u'uri', name=u'name', tracks=tracks, - last_modified=1) - playlist2 = Playlist(uri=u'uri', name=u'name', tracks=tracks, - last_modified=1) + playlist1 = Playlist( + uri=u'uri', name=u'name', tracks=tracks, last_modified=1) + playlist2 = Playlist( + uri=u'uri', name=u'name', tracks=tracks, last_modified=1) self.assertEqual(playlist1, playlist2) self.assertEqual(hash(playlist1), hash(playlist2)) @@ -705,17 +711,18 @@ class PlaylistTest(unittest.TestCase): self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) - def test_ne_uri(self): + def test_ne_last_modified(self): playlist1 = Playlist(last_modified=1) playlist2 = Playlist(last_modified=2) self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) def test_ne(self): - playlist1 = Playlist(uri=u'uri1', name=u'name2', - tracks=[Track(uri=u'uri1')], last_modified=1) - playlist2 = Playlist(uri=u'uri2', name=u'name2', - tracks=[Track(uri=u'uri2')], last_modified=2) + playlist1 = Playlist( + uri=u'uri1', name=u'name2', tracks=[Track(uri=u'uri1')], + last_modified=1) + playlist2 = Playlist( + uri=u'uri2', name=u'name2', tracks=[Track(uri=u'uri2')], + last_modified=2) self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) - diff --git a/tests/scanner_test.py b/tests/scanner_test.py index 91e67e11..6af48bb5 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -134,8 +134,8 @@ class ScannerTest(unittest.TestCase): self.data = {} def scan(self, path): - scanner = Scanner(path_to_data_dir(path), - self.data_callback, self.error_callback) + scanner = Scanner( + path_to_data_dir(path), self.data_callback, self.error_callback) scanner.start() def check(self, name, key, value): @@ -160,8 +160,9 @@ class ScannerTest(unittest.TestCase): def test_uri_is_set(self): self.scan('scanner/simple') - self.check('scanner/simple/song1.mp3', 'uri', 'file://' - + path_to_data_dir('scanner/simple/song1.mp3')) + self.check( + 'scanner/simple/song1.mp3', 'uri', + 'file://%s' % path_to_data_dir('scanner/simple/song1.mp3')) def test_duration_is_set(self): self.scan('scanner/simple') diff --git a/tests/utils/deps_test.py b/tests/utils/deps_test.py index f5aa0b1e..42c8b299 100644 --- a/tests/utils/deps_test.py +++ b/tests/utils/deps_test.py @@ -65,10 +65,12 @@ class DepsTest(unittest.TestCase): result = deps.gstreamer_info() self.assertEquals('GStreamer', result['name']) - self.assertEquals('.'.join(map(str, gst.get_gst_version())), result['version']) + self.assertEquals( + '.'.join(map(str, gst.get_gst_version())), result['version']) self.assertIn('gst', result['path']) self.assertIn('Python wrapper: gst-python', result['other']) - self.assertIn('.'.join(map(str, gst.get_pygst_version())), result['other']) + self.assertIn( + '.'.join(map(str, gst.get_pygst_version())), result['other']) self.assertIn('Relevant elements:', result['other']) def test_pykka_info(self): diff --git a/tests/utils/network/connection_test.py b/tests/utils/network/connection_test.py index 25ae1940..c51957f1 100644 --- a/tests/utils/network/connection_test.py +++ b/tests/utils/network/connection_test.py @@ -17,20 +17,23 @@ class ConnectionTest(unittest.TestCase): def test_init_ensure_nonblocking_io(self): sock = Mock(spec=socket.SocketType) - network.Connection.__init__(self.mock, Mock(), {}, sock, - (sentinel.host, sentinel.port), sentinel.timeout) + network.Connection.__init__( + self.mock, Mock(), {}, sock, (sentinel.host, sentinel.port), + sentinel.timeout) sock.setblocking.assert_called_once_with(False) def test_init_starts_actor(self): protocol = Mock(spec=network.LineProtocol) - network.Connection.__init__(self.mock, protocol, {}, Mock(), - (sentinel.host, sentinel.port), sentinel.timeout) + network.Connection.__init__( + self.mock, protocol, {}, Mock(), (sentinel.host, sentinel.port), + sentinel.timeout) protocol.start.assert_called_once_with(self.mock) def test_init_enables_recv_and_timeout(self): - network.Connection.__init__(self.mock, Mock(), {}, Mock(), - (sentinel.host, sentinel.port), sentinel.timeout) + network.Connection.__init__( + self.mock, Mock(), {}, Mock(), (sentinel.host, sentinel.port), + sentinel.timeout) self.mock.enable_recv.assert_called_once_with() self.mock.enable_timeout.assert_called_once_with() @@ -50,8 +53,8 @@ class ConnectionTest(unittest.TestCase): self.assertEqual(sentinel.port, self.mock.port) def test_init_handles_ipv6_addr(self): - addr = (sentinel.host, sentinel.port, - sentinel.flowinfo, sentinel.scopeid) + addr = ( + sentinel.host, sentinel.port, sentinel.flowinfo, sentinel.scopeid) protocol = Mock(spec=network.LineProtocol) protocol_kwargs = {} sock = Mock(spec=socket.SocketType) @@ -138,8 +141,8 @@ class ConnectionTest(unittest.TestCase): self.mock.actor_ref = Mock() self.mock.sock = Mock(spec=socket.SocketType) - network.Connection.stop(self.mock, sentinel.reason, - level=sentinel.level) + network.Connection.stop( + self.mock, sentinel.reason, level=sentinel.level) network.logger.log.assert_called_once_with( sentinel.level, sentinel.reason) @@ -160,7 +163,8 @@ class ConnectionTest(unittest.TestCase): gobject.io_add_watch.return_value = sentinel.tag network.Connection.enable_recv(self.mock) - gobject.io_add_watch.assert_called_once_with(sentinel.fileno, + gobject.io_add_watch.assert_called_once_with( + sentinel.fileno, gobject.IO_IN | gobject.IO_ERR | gobject.IO_HUP, self.mock.recv_callback) self.assertEqual(sentinel.tag, self.mock.recv_id) @@ -213,7 +217,8 @@ class ConnectionTest(unittest.TestCase): gobject.io_add_watch.return_value = sentinel.tag network.Connection.enable_send(self.mock) - gobject.io_add_watch.assert_called_once_with(sentinel.fileno, + gobject.io_add_watch.assert_called_once_with( + sentinel.fileno, gobject.IO_OUT | gobject.IO_ERR | gobject.IO_HUP, self.mock.send_callback) self.assertEqual(sentinel.tag, self.mock.send_id) @@ -270,8 +275,8 @@ class ConnectionTest(unittest.TestCase): gobject.timeout_add_seconds.return_value = sentinel.tag network.Connection.enable_timeout(self.mock) - gobject.timeout_add_seconds.assert_called_once_with(10, - self.mock.timeout_callback) + gobject.timeout_add_seconds.assert_called_once_with( + 10, self.mock.timeout_callback) self.assertEqual(sentinel.tag, self.mock.timeout_id) @patch.object(gobject, 'timeout_add_seconds', new=Mock()) @@ -359,24 +364,25 @@ class ConnectionTest(unittest.TestCase): self.mock.sock = Mock(spec=socket.SocketType) self.mock.actor_ref = Mock() - self.assertTrue(network.Connection.recv_callback(self.mock, - sentinel.fd, gobject.IO_IN | gobject.IO_ERR)) + self.assertTrue(network.Connection.recv_callback( + self.mock, sentinel.fd, gobject.IO_IN | gobject.IO_ERR)) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_respects_io_hup(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.actor_ref = Mock() - self.assertTrue(network.Connection.recv_callback(self.mock, - sentinel.fd, gobject.IO_IN | gobject.IO_HUP)) + self.assertTrue(network.Connection.recv_callback( + self.mock, sentinel.fd, gobject.IO_IN | gobject.IO_HUP)) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_respects_io_hup_and_io_err(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.actor_ref = Mock() - self.assertTrue(network.Connection.recv_callback(self.mock, - sentinel.fd, gobject.IO_IN | gobject.IO_HUP | gobject.IO_ERR)) + self.assertTrue(network.Connection.recv_callback( + self.mock, sentinel.fd, + gobject.IO_IN | gobject.IO_HUP | gobject.IO_ERR)) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_sends_data_to_actor(self): @@ -432,8 +438,8 @@ class ConnectionTest(unittest.TestCase): self.mock.actor_ref = Mock() self.mock.send_buffer = '' - self.assertTrue(network.Connection.send_callback(self.mock, - sentinel.fd, gobject.IO_IN | gobject.IO_ERR)) + self.assertTrue(network.Connection.send_callback( + self.mock, sentinel.fd, gobject.IO_IN | gobject.IO_ERR)) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_respects_io_hup(self): @@ -443,8 +449,8 @@ class ConnectionTest(unittest.TestCase): self.mock.actor_ref = Mock() self.mock.send_buffer = '' - self.assertTrue(network.Connection.send_callback(self.mock, - sentinel.fd, gobject.IO_IN | gobject.IO_HUP)) + self.assertTrue(network.Connection.send_callback( + self.mock, sentinel.fd, gobject.IO_IN | gobject.IO_HUP)) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_respects_io_hup_and_io_err(self): @@ -454,8 +460,9 @@ class ConnectionTest(unittest.TestCase): self.mock.actor_ref = Mock() self.mock.send_buffer = '' - self.assertTrue(network.Connection.send_callback(self.mock, - sentinel.fd, gobject.IO_IN | gobject.IO_HUP | gobject.IO_ERR)) + self.assertTrue(network.Connection.send_callback( + self.mock, sentinel.fd, + gobject.IO_IN | gobject.IO_HUP | gobject.IO_ERR)) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_acquires_and_releases_lock(self): diff --git a/tests/utils/network/lineprotocol_test.py b/tests/utils/network/lineprotocol_test.py index 4ba62b8f..9a19e12e 100644 --- a/tests/utils/network/lineprotocol_test.py +++ b/tests/utils/network/lineprotocol_test.py @@ -103,8 +103,8 @@ class LineProtocolTest(unittest.TestCase): self.mock.parse_lines.return_value = ['line1', 'line2'] self.mock.decode.return_value = sentinel.decoded - network.LineProtocol.on_receive(self.mock, - {'received': 'line1\nline2\n'}) + network.LineProtocol.on_receive( + self.mock, {'received': 'line1\nline2\n'}) self.assertEqual(2, self.mock.on_line_received.call_count) def test_parse_lines_emtpy_buffer(self): diff --git a/tests/utils/network/server_test.py b/tests/utils/network/server_test.py index 268b5dbd..6090077d 100644 --- a/tests/utils/network/server_test.py +++ b/tests/utils/network/server_test.py @@ -13,8 +13,8 @@ class ServerTest(unittest.TestCase): self.mock = Mock(spec=network.Server) def test_init_calls_create_server_socket(self): - network.Server.__init__(self.mock, sentinel.host, - sentinel.port, sentinel.protocol) + network.Server.__init__( + self.mock, sentinel.host, sentinel.port, sentinel.protocol) self.mock.create_server_socket.assert_called_once_with( sentinel.host, sentinel.port) @@ -23,8 +23,8 @@ class ServerTest(unittest.TestCase): sock.fileno.return_value = sentinel.fileno self.mock.create_server_socket.return_value = sock - network.Server.__init__(self.mock, sentinel.host, - sentinel.port, sentinel.protocol) + network.Server.__init__( + self.mock, sentinel.host, sentinel.port, sentinel.protocol) self.mock.register_server_socket.assert_called_once_with( sentinel.fileno) @@ -33,17 +33,18 @@ class ServerTest(unittest.TestCase): sock.fileno.side_effect = socket.error self.mock.create_server_socket.return_value = sock - self.assertRaises(socket.error, network.Server.__init__, - self.mock, sentinel.host, sentinel.port, sentinel.protocol) + self.assertRaises( + socket.error, network.Server.__init__, self.mock, sentinel.host, + sentinel.port, sentinel.protocol) def test_init_stores_values_in_attributes(self): # This need to be a mock and no a sentinel as fileno() is called on it sock = Mock(spec=socket.SocketType) self.mock.create_server_socket.return_value = sock - network.Server.__init__(self.mock, sentinel.host, sentinel.port, - sentinel.protocol, max_connections=sentinel.max_connections, - timeout=sentinel.timeout) + network.Server.__init__( + self.mock, sentinel.host, sentinel.port, sentinel.protocol, + max_connections=sentinel.max_connections, timeout=sentinel.timeout) self.assertEqual(sentinel.protocol, self.mock.protocol) self.assertEqual(sentinel.max_connections, self.mock.max_connections) self.assertEqual(sentinel.timeout, self.mock.timeout) @@ -53,8 +54,8 @@ class ServerTest(unittest.TestCase): def test_create_server_socket_sets_up_listener(self, create_socket): sock = create_socket.return_value - network.Server.create_server_socket(self.mock, - sentinel.host, sentinel.port) + network.Server.create_server_socket( + self.mock, sentinel.host, sentinel.port) sock.setblocking.assert_called_once_with(False) sock.bind.assert_called_once_with((sentinel.host, sentinel.port)) sock.listen.assert_called_once_with(any_int) @@ -62,30 +63,33 @@ class ServerTest(unittest.TestCase): @patch.object(network, 'create_socket', new=Mock()) def test_create_server_socket_fails(self): network.create_socket.side_effect = socket.error - self.assertRaises(socket.error, network.Server.create_server_socket, - self.mock, sentinel.host, sentinel.port) + self.assertRaises( + socket.error, network.Server.create_server_socket, self.mock, + sentinel.host, sentinel.port) @patch.object(network, 'create_socket', new=Mock()) def test_create_server_bind_fails(self): sock = network.create_socket.return_value sock.bind.side_effect = socket.error - self.assertRaises(socket.error, network.Server.create_server_socket, - self.mock, sentinel.host, sentinel.port) + self.assertRaises( + socket.error, network.Server.create_server_socket, self.mock, + sentinel.host, sentinel.port) @patch.object(network, 'create_socket', new=Mock()) def test_create_server_listen_fails(self): sock = network.create_socket.return_value sock.listen.side_effect = socket.error - self.assertRaises(socket.error, network.Server.create_server_socket, - self.mock, sentinel.host, sentinel.port) + self.assertRaises( + socket.error, network.Server.create_server_socket, self.mock, + sentinel.host, sentinel.port) @patch.object(gobject, 'io_add_watch', new=Mock()) def test_register_server_socket_sets_up_io_watch(self): network.Server.register_server_socket(self.mock, sentinel.fileno) - gobject.io_add_watch.assert_called_once_with(sentinel.fileno, - gobject.IO_IN, self.mock.handle_connection) + gobject.io_add_watch.assert_called_once_with( + sentinel.fileno, gobject.IO_IN, self.mock.handle_connection) def test_handle_connection(self): self.mock.accept_connection.return_value = ( @@ -128,7 +132,8 @@ class ServerTest(unittest.TestCase): for error in (errno.EAGAIN, errno.EINTR): sock.accept.side_effect = socket.error(error, '') - self.assertRaises(network.ShouldRetrySocketCall, + self.assertRaises( + network.ShouldRetrySocketCall, network.Server.accept_connection, self.mock) # FIXME decide if this should be allowed to propegate @@ -136,8 +141,8 @@ class ServerTest(unittest.TestCase): sock = Mock(spec=socket.SocketType) self.mock.server_socket = sock sock.accept.side_effect = socket.error - self.assertRaises(socket.error, - network.Server.accept_connection, self.mock) + self.assertRaises( + socket.error, network.Server.accept_connection, self.mock) def test_maximum_connections_exceeded(self): self.mock.max_connections = 10 @@ -149,7 +154,8 @@ class ServerTest(unittest.TestCase): self.assertTrue(network.Server.maximum_connections_exceeded(self.mock)) self.mock.number_of_connections.return_value = 9 - self.assertFalse(network.Server.maximum_connections_exceeded(self.mock)) + self.assertFalse( + network.Server.maximum_connections_exceeded(self.mock)) @patch('pykka.registry.ActorRegistry.get_by_class') def test_number_of_connections(self, get_by_class): @@ -168,20 +174,21 @@ class ServerTest(unittest.TestCase): self.mock.timeout = sentinel.timeout network.Server.init_connection(self.mock, sentinel.sock, sentinel.addr) - network.Connection.assert_called_once_with(sentinel.protocol, {}, - sentinel.sock, sentinel.addr, sentinel.timeout) + network.Connection.assert_called_once_with( + sentinel.protocol, {}, sentinel.sock, sentinel.addr, + sentinel.timeout) def test_reject_connection(self): sock = Mock(spec=socket.SocketType) - network.Server.reject_connection(self.mock, sock, - (sentinel.host, sentinel.port)) + network.Server.reject_connection( + self.mock, sock, (sentinel.host, sentinel.port)) sock.close.assert_called_once_with() def test_reject_connection_error(self): sock = Mock(spec=socket.SocketType) sock.close.side_effect = socket.error - network.Server.reject_connection(self.mock, sock, - (sentinel.host, sentinel.port)) + network.Server.reject_connection( + self.mock, sock, (sentinel.host, sentinel.port)) sock.close.assert_called_once_with() diff --git a/tests/utils/network/utils_test.py b/tests/utils/network/utils_test.py index 1e11673e..f28aeb4b 100644 --- a/tests/utils/network/utils_test.py +++ b/tests/utils/network/utils_test.py @@ -42,15 +42,15 @@ class CreateSocketTest(unittest.TestCase): @patch('socket.socket') def test_ipv4_socket(self, socket_mock): network.create_socket() - self.assertEqual(socket_mock.call_args[0], - (socket.AF_INET, socket.SOCK_STREAM)) + self.assertEqual( + socket_mock.call_args[0], (socket.AF_INET, socket.SOCK_STREAM)) @patch('mopidy.utils.network.has_ipv6', True) @patch('socket.socket') def test_ipv6_socket(self, socket_mock): network.create_socket() - self.assertEqual(socket_mock.call_args[0], - (socket.AF_INET6, socket.SOCK_STREAM)) + self.assertEqual( + socket_mock.call_args[0], (socket.AF_INET6, socket.SOCK_STREAM)) @unittest.SkipTest def test_ipv6_only_is_set(self): diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index d782aa15..91951ac7 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -108,7 +108,7 @@ class UriToPathTest(unittest.TestCase): def test_unicode_in_uri(self): if sys.platform == 'win32': - result = path.uri_to_path( 'file:///C://%C3%A6%C3%B8%C3%A5') + result = path.uri_to_path('file:///C://%C3%A6%C3%B8%C3%A5') self.assertEqual(result, u'C:/æøå') else: result = path.uri_to_path(u'file:///tmp/%C3%A6%C3%B8%C3%A5') @@ -125,11 +125,9 @@ class SplitPathTest(unittest.TestCase): def test_folders(self): self.assertEqual(['foo', 'bar', 'baz'], path.split_path('foo/bar/baz')) - def test_folders(self): - self.assertEqual(['foo', 'bar', 'baz'], path.split_path('foo/bar/baz')) - def test_initial_slash_is_ignored(self): - self.assertEqual(['foo', 'bar', 'baz'], path.split_path('/foo/bar/baz')) + self.assertEqual( + ['foo', 'bar', 'baz'], path.split_path('/foo/bar/baz')) def test_only_slash(self): self.assertEqual([], path.split_path('/')) @@ -145,17 +143,20 @@ class ExpandPathTest(unittest.TestCase): self.assertEqual('/tmp/foo', path.expand_path('/tmp/foo')) def test_home_dir_expansion(self): - self.assertEqual(os.path.expanduser('~/foo'), path.expand_path('~/foo')) + self.assertEqual( + os.path.expanduser('~/foo'), path.expand_path('~/foo')) def test_abspath(self): self.assertEqual(os.path.abspath('./foo'), path.expand_path('./foo')) def test_xdg_subsititution(self): - self.assertEqual(glib.get_user_data_dir() + '/foo', + self.assertEqual( + glib.get_user_data_dir() + '/foo', path.expand_path('$XDG_DATA_DIR/foo')) def test_xdg_subsititution_unknown(self): - self.assertEqual('/tmp/$XDG_INVALID_DIR/foo', + self.assertEqual( + '/tmp/$XDG_INVALID_DIR/foo', path.expand_path('/tmp/$XDG_INVALID_DIR/foo')) @@ -177,8 +178,8 @@ class FindFilesTest(unittest.TestCase): def test_names_are_unicode(self): is_unicode = lambda f: isinstance(f, unicode) for name in self.find(''): - self.assert_(is_unicode(name), - '%s is not unicode object' % repr(name)) + self.assert_( + is_unicode(name), '%s is not unicode object' % repr(name)) def test_ignores_hidden_folders(self): self.assertEqual(self.find('.hidden'), []) diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index cf476c24..bbeda20c 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -19,41 +19,47 @@ class ValidateSettingsTest(unittest.TestCase): self.assertEqual(result, {}) def test_unknown_setting_returns_error(self): - result = setting_utils.validate_settings(self.defaults, - {'MPD_SERVER_HOSTNMAE': '127.0.0.1'}) - self.assertEqual(result['MPD_SERVER_HOSTNMAE'], + result = setting_utils.validate_settings( + self.defaults, {'MPD_SERVER_HOSTNMAE': '127.0.0.1'}) + self.assertEqual( + result['MPD_SERVER_HOSTNMAE'], u'Unknown setting. Did you mean MPD_SERVER_HOSTNAME?') def test_not_renamed_setting_returns_error(self): - result = setting_utils.validate_settings(self.defaults, - {'SERVER_HOSTNAME': '127.0.0.1'}) - self.assertEqual(result['SERVER_HOSTNAME'], + result = setting_utils.validate_settings( + self.defaults, {'SERVER_HOSTNAME': '127.0.0.1'}) + self.assertEqual( + result['SERVER_HOSTNAME'], u'Deprecated setting. Use MPD_SERVER_HOSTNAME.') def test_unneeded_settings_returns_error(self): - result = setting_utils.validate_settings(self.defaults, - {'SPOTIFY_LIB_APPKEY': '/tmp/foo'}) - self.assertEqual(result['SPOTIFY_LIB_APPKEY'], + result = setting_utils.validate_settings( + self.defaults, {'SPOTIFY_LIB_APPKEY': '/tmp/foo'}) + self.assertEqual( + result['SPOTIFY_LIB_APPKEY'], u'Deprecated setting. It may be removed.') def test_deprecated_setting_value_returns_error(self): - result = setting_utils.validate_settings(self.defaults, + result = setting_utils.validate_settings( + self.defaults, {'BACKENDS': ('mopidy.backends.despotify.DespotifyBackend',)}) - self.assertEqual(result['BACKENDS'], - u'Deprecated setting value. ' + - '"mopidy.backends.despotify.DespotifyBackend" is no longer ' + - 'available.') + self.assertEqual( + result['BACKENDS'], + u'Deprecated setting value. ' + u'"mopidy.backends.despotify.DespotifyBackend" is no longer ' + u'available.') def test_unavailable_bitrate_setting_returns_error(self): - result = setting_utils.validate_settings(self.defaults, - {'SPOTIFY_BITRATE': 50}) - self.assertEqual(result['SPOTIFY_BITRATE'], - u'Unavailable Spotify bitrate. ' + + result = setting_utils.validate_settings( + self.defaults, {'SPOTIFY_BITRATE': 50}) + self.assertEqual( + result['SPOTIFY_BITRATE'], + u'Unavailable Spotify bitrate. ' u'Available bitrates are 96, 160, and 320.') def test_two_errors_are_both_reported(self): - result = setting_utils.validate_settings(self.defaults, - {'FOO': '', 'BAR': ''}) + result = setting_utils.validate_settings( + self.defaults, {'FOO': '', 'BAR': ''}) self.assertEqual(len(result), 2) def test_masks_value_if_secret(self): @@ -61,11 +67,13 @@ class ValidateSettingsTest(unittest.TestCase): self.assertEqual(u'********', secret) def test_does_not_mask_value_if_not_secret(self): - not_secret = setting_utils.mask_value_if_secret('SPOTIFY_USERNAME', 'foo') + not_secret = setting_utils.mask_value_if_secret( + 'SPOTIFY_USERNAME', 'foo') self.assertEqual('foo', not_secret) def test_does_not_mask_value_if_none(self): - not_secret = setting_utils.mask_value_if_secret('SPOTIFY_USERNAME', None) + not_secret = setting_utils.mask_value_if_secret( + 'SPOTIFY_USERNAME', None) self.assertEqual(None, not_secret) @@ -80,7 +88,7 @@ class SettingsProxyTest(unittest.TestCase): def test_getattr_raises_error_on_missing_setting(self): try: - _ = self.settings.TEST + self.settings.TEST self.fail(u'Should raise exception') except mopidy.SettingsError as e: self.assertEqual(u'Setting "TEST" is not set.', e.message) @@ -88,7 +96,7 @@ class SettingsProxyTest(unittest.TestCase): def test_getattr_raises_error_on_empty_setting(self): self.settings.TEST = u'' try: - _ = self.settings.TEST + self.settings.TEST self.fail(u'Should raise exception') except mopidy.SettingsError as e: self.assertEqual(u'Setting "TEST" is empty.', e.message) @@ -207,15 +215,19 @@ class FormatSettingListTest(unittest.TestCase): def test_short_values_are_not_pretty_printed(self): self.settings.FRONTEND = (u'mopidy.frontends.mpd.MpdFrontend',) result = setting_utils.format_settings_list(self.settings) - self.assertIn("FRONTEND: (u'mopidy.frontends.mpd.MpdFrontend',)", result) + self.assertIn( + "FRONTEND: (u'mopidy.frontends.mpd.MpdFrontend',)", result) def test_long_values_are_pretty_printed(self): - self.settings.FRONTEND = (u'mopidy.frontends.mpd.MpdFrontend', + self.settings.FRONTEND = ( + u'mopidy.frontends.mpd.MpdFrontend', u'mopidy.frontends.lastfm.LastfmFrontend') result = setting_utils.format_settings_list(self.settings) - self.assert_("""FRONTEND: - (u'mopidy.frontends.mpd.MpdFrontend', - u'mopidy.frontends.lastfm.LastfmFrontend')""" in result, result) + self.assertIn( + "FRONTEND: \n" + " (u'mopidy.frontends.mpd.MpdFrontend',\n" + " u'mopidy.frontends.lastfm.LastfmFrontend')", + result) class DidYouMeanTest(unittest.TestCase): From e65a612ac8972bedd811b6756f5535c6ce8cca0a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 14:48:58 +0200 Subject: [PATCH 073/233] Fix all flake8 warnings in tools (#211) --- tools/debug-proxy.py | 11 ++- tools/idle.py | 176 +++++++++++++++++++++---------------------- 2 files changed, 95 insertions(+), 92 deletions(-) diff --git a/tools/debug-proxy.py b/tools/debug-proxy.py index 2f54ea36..4fb39b5b 100755 --- a/tools/debug-proxy.py +++ b/tools/debug-proxy.py @@ -6,7 +6,7 @@ import sys from gevent import select, server, socket -COLORS = ['\033[1;%dm' % (30+i) for i in range(8)] +COLORS = ['\033[1;%dm' % (30 + i) for i in range(8)] BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = COLORS RESET = "\033[0m" BOLD = "\033[1m" @@ -53,7 +53,8 @@ def loop(client, address, reference, actual): # Consume banners from backends responses = dict() - disconnected = read([reference, actual], responses, find_response_end_token) + disconnected = read( + [reference, actual], responses, find_response_end_token) diff(address, '', responses[reference], responses[actual]) # We lost a backend, might as well give up. @@ -78,13 +79,15 @@ def loop(client, address, reference, actual): actual.sendall(responses[client]) # Get the entire resonse from both backends. - disconnected = read([reference, actual], responses, find_response_end_token) + disconnected = read( + [reference, actual], responses, find_response_end_token) # Send the client the complete reference response client.sendall(responses[reference]) # Compare our responses - diff(address, responses[client], responses[reference], responses[actual]) + diff(address, + responses[client], responses[reference], responses[actual]) # Give up if we lost a backend. if disconnected: diff --git a/tools/idle.py b/tools/idle.py index aa56dce2..fc9cb021 100644 --- a/tools/idle.py +++ b/tools/idle.py @@ -17,98 +17,98 @@ data = {'id': None, 'id2': None, 'url': url, 'artist': artist} # Commands to run before test requests to coerce MPD into right state setup_requests = [ - 'clear', - 'add "%(url)s"', - 'add "%(url)s"', - 'add "%(url)s"', - 'play', -# 'pause', # Uncomment to test paused idle behaviour -# 'stop', # Uncomment to test stopped idle behaviour + 'clear', + 'add "%(url)s"', + 'add "%(url)s"', + 'add "%(url)s"', + 'play', + #'pause', # Uncomment to test paused idle behaviour + #'stop', # Uncomment to test stopped idle behaviour ] # List of commands to test for idle behaviour. Ordering of list is important in # order to keep MPD state as intended. Commands that are obviously # informational only or "harmfull" have been excluded. test_requests = [ - 'add "%(url)s"', - 'addid "%(url)s" "1"', - 'clear', -# 'clearerror', -# 'close', -# 'commands', - 'consume "1"', - 'consume "0"', -# 'count', - 'crossfade "1"', - 'crossfade "0"', -# 'currentsong', -# 'delete "1:2"', - 'delete "0"', - 'deleteid "%(id)s"', - 'disableoutput "0"', - 'enableoutput "0"', -# 'find', -# 'findadd "artist" "%(artist)s"', -# 'idle', -# 'kill', -# 'list', -# 'listall', -# 'listallinfo', -# 'listplaylist', -# 'listplaylistinfo', -# 'listplaylists', -# 'lsinfo', - 'move "0:1" "2"', - 'move "0" "1"', - 'moveid "%(id)s" "1"', - 'next', -# 'notcommands', -# 'outputs', -# 'password', - 'pause', -# 'ping', - 'play', - 'playid "%(id)s"', -# 'playlist', - 'playlistadd "foo" "%(url)s"', - 'playlistclear "foo"', - 'playlistadd "foo" "%(url)s"', - 'playlistdelete "foo" "0"', -# 'playlistfind', -# 'playlistid', -# 'playlistinfo', - 'playlistadd "foo" "%(url)s"', - 'playlistadd "foo" "%(url)s"', - 'playlistmove "foo" "0" "1"', -# 'playlistsearch', -# 'plchanges', -# 'plchangesposid', - 'previous', - 'random "1"', - 'random "0"', - 'rm "bar"', - 'rename "foo" "bar"', - 'repeat "0"', - 'rm "bar"', - 'save "bar"', - 'load "bar"', -# 'search', - 'seek "1" "10"', - 'seekid "%(id)s" "10"', -# 'setvol "10"', - 'shuffle', - 'shuffle "0:1"', - 'single "1"', - 'single "0"', -# 'stats', -# 'status', - 'stop', - 'swap "1" "2"', - 'swapid "%(id)s" "%(id2)s"', -# 'tagtypes', -# 'update', -# 'urlhandlers', -# 'volume', + 'add "%(url)s"', + 'addid "%(url)s" "1"', + 'clear', + #'clearerror', + #'close', + #'commands', + 'consume "1"', + 'consume "0"', + # 'count', + 'crossfade "1"', + 'crossfade "0"', + #'currentsong', + #'delete "1:2"', + 'delete "0"', + 'deleteid "%(id)s"', + 'disableoutput "0"', + 'enableoutput "0"', + #'find', + #'findadd "artist" "%(artist)s"', + #'idle', + #'kill', + #'list', + #'listall', + #'listallinfo', + #'listplaylist', + #'listplaylistinfo', + #'listplaylists', + #'lsinfo', + 'move "0:1" "2"', + 'move "0" "1"', + 'moveid "%(id)s" "1"', + 'next', + #'notcommands', + #'outputs', + #'password', + 'pause', + #'ping', + 'play', + 'playid "%(id)s"', + #'playlist', + 'playlistadd "foo" "%(url)s"', + 'playlistclear "foo"', + 'playlistadd "foo" "%(url)s"', + 'playlistdelete "foo" "0"', + #'playlistfind', + #'playlistid', + #'playlistinfo', + 'playlistadd "foo" "%(url)s"', + 'playlistadd "foo" "%(url)s"', + 'playlistmove "foo" "0" "1"', + #'playlistsearch', + #'plchanges', + #'plchangesposid', + 'previous', + 'random "1"', + 'random "0"', + 'rm "bar"', + 'rename "foo" "bar"', + 'repeat "0"', + 'rm "bar"', + 'save "bar"', + 'load "bar"', + #'search', + 'seek "1" "10"', + 'seekid "%(id)s" "10"', + #'setvol "10"', + 'shuffle', + 'shuffle "0:1"', + 'single "1"', + 'single "0"', + #'stats', + #'status', + 'stop', + 'swap "1" "2"', + 'swapid "%(id)s" "%(id2)s"', + #'tagtypes', + #'update', + #'urlhandlers', + #'volume', ] @@ -116,8 +116,8 @@ def create_socketfile(): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((host, port)) sock.settimeout(0.5) - fd = sock.makefile('rw', 1) # 1 = line buffered - fd.readline() # Read banner + fd = sock.makefile('rw', 1) # 1 = line buffered + fd.readline() # Read banner return fd From 357a08b30e12ae5c60c6d320bc544845ec0f2832 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 14:49:53 +0200 Subject: [PATCH 074/233] Fix all flake8 warnings in setup.py (#211) --- setup.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index ae6cc699..99fb7f49 100644 --- a/setup.py +++ b/setup.py @@ -9,11 +9,13 @@ import os import re import sys + def get_version(): init_py = open('mopidy/__init__.py').read() metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", init_py)) return metadata['version'] + class osx_install_data(install_data): # On MacOS, the platform-specific lib dir is # /System/Library/Framework/Python/.../ which is wrong. Python 2.5 supplied @@ -28,11 +30,13 @@ class osx_install_data(install_data): self.set_undefined_options('install', ('install_lib', 'install_dir')) install_data.finalize_options(self) + if sys.platform == "darwin": cmdclasses = {'install_data': osx_install_data} else: cmdclasses = {'install_data': install_data} + def fullsplit(path, result=None): """ Split a pathname into components (the opposite of os.path.join) in a @@ -47,6 +51,7 @@ def fullsplit(path, result=None): return result return fullsplit(head, [tail] + result) + # Tell distutils to put the data_files in platform-specific installation # locations. See here for an explanation: # http://groups.google.com/group/comp.lang.python/browse_thread/ @@ -54,6 +59,7 @@ def fullsplit(path, result=None): for scheme in INSTALL_SCHEMES.values(): scheme['data'] = scheme['purelib'] + # Compile the list of packages available, because distutils doesn't have # an easy way to do this. packages, data_files = [], [] @@ -62,6 +68,7 @@ if root_dir != '': os.chdir(root_dir) project_dir = 'mopidy' + for dirpath, dirnames, filenames in os.walk(project_dir): # Ignore dirnames that start with '.' for i, dirname in enumerate(dirnames): @@ -70,8 +77,9 @@ for dirpath, dirnames, filenames in os.walk(project_dir): if '__init__.py' in filenames: packages.append('.'.join(fullsplit(dirpath))) elif filenames: - data_files.append([dirpath, - [os.path.join(dirpath, f) for f in filenames]]) + data_files.append([ + dirpath, [os.path.join(dirpath, f) for f in filenames]]) + setup( name='Mopidy', From f3d7f8f65f315dc5ffbe6b0c44a7cd0dfea89212 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 14:52:01 +0200 Subject: [PATCH 075/233] Fix all flake8 warnings in docs (#211) --- docs/conf.py | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 8129adec..e37f5713 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -3,7 +3,8 @@ # Mopidy documentation build configuration file, created by # sphinx-quickstart on Fri Feb 5 22:19:08 2010. # -# This file is execfile()d with the current directory set to its containing dir. +# This file is execfile()d with the current directory set to its containing +# dir. # # Note that not all possible configuration values are present in this # autogenerated file. @@ -12,9 +13,9 @@ # serve to show the default. import os -import re import sys + class Mock(object): def __init__(self, *args, **kwargs): pass @@ -34,6 +35,7 @@ class Mock(object): else: return Mock() + MOCK_MODULES = [ 'dbus', 'dbus.mainloop', @@ -63,12 +65,16 @@ sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + '/../')) # the string True. on_rtd = os.environ.get('READTHEDOCS', None) == 'True' -# -- General configuration ----------------------------------------------------- +# -- General configuration ---------------------------------------------------- -# Add any Sphinx extension module names here, as strings. They can be extensions -# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.graphviz', - 'sphinx.ext.extlinks', 'sphinx.ext.viewcode'] +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.extlinks', + 'sphinx.ext.graphviz', + 'sphinx.ext.viewcode', +] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -114,7 +120,8 @@ version = '.'.join(release.split('.')[:2]) # for source files. exclude_trees = ['_build'] -# The reST default role (used for this markup: `text`) to use for all documents. +# The reST default role (used for this markup: `text`) to use for all +# documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. @@ -135,7 +142,7 @@ pygments_style = 'sphinx' modindex_common_prefix = ['mopidy.'] -# -- Options for HTML output --------------------------------------------------- +# -- Options for HTML output -------------------------------------------------- # The theme to use for HTML and HTML Help pages. Major themes that come with # Sphinx are currently 'default' and 'sphinxdoc'. @@ -210,7 +217,7 @@ html_static_path = ['_static'] htmlhelp_basename = 'Mopidydoc' -# -- Options for LaTeX output -------------------------------------------------- +# -- Options for LaTeX output ------------------------------------------------- # The paper size ('letter' or 'a4'). #latex_paper_size = 'letter' @@ -218,11 +225,16 @@ htmlhelp_basename = 'Mopidydoc' # The font size ('10pt', '11pt' or '12pt'). #latex_font_size = '10pt' -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]). +# Grouping the document tree into LaTeX files. List of tuples (source start +# file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'Mopidy.tex', u'Mopidy Documentation', - u'Stein Magnus Jodal', 'manual'), + ( + 'index', + 'Mopidy.tex', + u'Mopidy Documentation', + u'Stein Magnus Jodal', + 'manual' + ), ] # The name of an image file (relative to this directory) to place at the top of From f69148c57224e46c3e7a23e366a652a8f195318b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 15:24:47 +0200 Subject: [PATCH 076/233] Move loading of MPD protocol modules into a function (#211) --- mopidy/frontends/mpd/dispatcher.py | 21 ++++++++------------- mopidy/frontends/mpd/protocol/__init__.py | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 24db6a7a..d7ba8cdf 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -4,19 +4,13 @@ import re from pykka import ActorDeadError from mopidy import settings -from mopidy.frontends.mpd import exceptions -from mopidy.frontends.mpd.protocol import mpd_commands, request_handlers -# Do not remove the following import. The protocol modules must be imported to -# get them registered as request handlers. -# pylint: disable = W0611 -from mopidy.frontends.mpd.protocol import ( - audio_output, command_list, connection, current_playlist, empty, music_db, - playback, reflection, status, stickers, stored_playlists) -# pylint: enable = W0611 +from mopidy.frontends.mpd import exceptions, protocol from mopidy.utils import flatten logger = logging.getLogger('mopidy.frontends.mpd.dispatcher') +protocol.load_protocol_modules() + class MpdDispatcher(object): """ @@ -92,7 +86,7 @@ class MpdDispatcher(object): else: command_name = request.split(' ')[0] command_names_not_requiring_auth = [ - command.name for command in mpd_commands + command.name for command in protocol.mpd_commands if not command.auth_required] if command_name in command_names_not_requiring_auth: return self._call_next_filter(request, response, filter_chain) @@ -172,12 +166,13 @@ class MpdDispatcher(object): return handler(self.context, **kwargs) def _find_handler(self, request): - for pattern in request_handlers: + for pattern in protocol.request_handlers: matches = re.match(pattern, request) if matches is not None: - return (request_handlers[pattern], matches.groupdict()) + return ( + protocol.request_handlers[pattern], matches.groupdict()) command_name = request.split(' ')[0] - if command_name in [command.name for command in mpd_commands]: + if command_name in [command.name for command in protocol.mpd_commands]: raise exceptions.MpdArgError( u'incorrect arguments', command=command_name) raise exceptions.MpdUnknownCommand(command=command_name) diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index 590a8ef4..66c8a84a 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -24,9 +24,10 @@ VERSION = u'0.16.0' MpdCommand = namedtuple('MpdCommand', ['name', 'auth_required']) -#: List of all available commands, represented as :class:`MpdCommand` objects. +#: Set of all available commands, represented as :class:`MpdCommand` objects. mpd_commands = set() +#: Map between request matchers and request handler functions. request_handlers = {} @@ -61,3 +62,15 @@ def handle_request(pattern, auth_required=True): pattern, func.__doc__ or '') return func return decorator + + +def load_protocol_modules(): + """ + The protocol modules must be imported to get them registered in + :attr:`request_handlers` and :attr:`mpd_commands`. + """ + # pylint: disable = W0611 + from . import ( # noqa + audio_output, command_list, connection, current_playlist, empty, + music_db, playback, reflection, status, stickers, stored_playlists) + # pylint: enable = W0611 From def361578733d9f26c64d0bf500dd3e15f73f263 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 15:33:26 +0200 Subject: [PATCH 077/233] Move registration of audio mixers into a function (#211) --- mopidy/audio/__init__.py | 3 ++- mopidy/audio/mixers/__init__.py | 10 ++++++++++ mopidy/audio/mixers/auto.py | 5 ----- mopidy/audio/mixers/fake.py | 4 ---- mopidy/audio/mixers/nad.py | 4 ---- 5 files changed, 12 insertions(+), 14 deletions(-) diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index a342799b..4a0b0000 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -10,12 +10,13 @@ from pykka.actor import ThreadingActor from mopidy import settings, utils from mopidy.utils import process -# Trigger install of gst mixer plugins from . import mixers from .listener import AudioListener logger = logging.getLogger('mopidy.audio') +mixers.register_mixers() + class Audio(ThreadingActor): """ diff --git a/mopidy/audio/mixers/__init__.py b/mopidy/audio/mixers/__init__.py index 08ecda0d..26faff02 100644 --- a/mopidy/audio/mixers/__init__.py +++ b/mopidy/audio/mixers/__init__.py @@ -42,3 +42,13 @@ def create_track(label, initial_volume, min_volume, max_volume, from .auto import AutoAudioMixer from .fake import FakeMixer from .nad import NadMixer + + +def register_mixer(mixer_class): + gobject.type_register(mixer_class) + gst.element_register( + mixer_class, mixer_class.__name__.lower(), gst.RANK_MARGINAL) + + +def register_mixers(): + map(register_mixer, [AutoAudioMixer, FakeMixer, NadMixer]) diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py index a4bd8bdb..45806040 100644 --- a/mopidy/audio/mixers/auto.py +++ b/mopidy/audio/mixers/auto.py @@ -1,6 +1,5 @@ import pygst pygst.require('0.10') -import gobject import gst import logging @@ -67,7 +66,3 @@ class AutoAudioMixer(gst.Bin): if track.flags & flags: return True return False - - -gobject.type_register(AutoAudioMixer) -gst.element_register(AutoAudioMixer, 'autoaudiomixer', gst.RANK_MARGINAL) diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py index 0e397e55..e0f1ae1f 100644 --- a/mopidy/audio/mixers/fake.py +++ b/mopidy/audio/mixers/fake.py @@ -42,7 +42,3 @@ class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): def set_record(self, track, record): pass - - -gobject.type_register(FakeMixer) -gst.element_register(FakeMixer, 'fakemixer', gst.RANK_MARGINAL) diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index 39a7b25e..df8c3ec9 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -76,10 +76,6 @@ class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): ).proxy() -gobject.type_register(NadMixer) -gst.element_register(NadMixer, 'nadmixer', gst.RANK_MARGINAL) - - class NadTalker(ThreadingActor): """ Independent thread which does the communication with the NAD amplifier From 0c3c9a9cce76fd578fe56a2516eb2841662bc7e3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 15:37:41 +0200 Subject: [PATCH 078/233] Fold base backend into a single file This removes three unused imports, which was only present to move the providers to the correct location in the module tree. (related to #211) --- mopidy/backends/base.py | 221 +++++++++++++++++++++++ mopidy/backends/base/__init__.py | 26 --- mopidy/backends/base/library.py | 42 ----- mopidy/backends/base/playback.py | 77 -------- mopidy/backends/base/stored_playlists.py | 75 -------- 5 files changed, 221 insertions(+), 220 deletions(-) create mode 100644 mopidy/backends/base.py delete mode 100644 mopidy/backends/base/__init__.py delete mode 100644 mopidy/backends/base/library.py delete mode 100644 mopidy/backends/base/playback.py delete mode 100644 mopidy/backends/base/stored_playlists.py diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py new file mode 100644 index 00000000..e8a7decd --- /dev/null +++ b/mopidy/backends/base.py @@ -0,0 +1,221 @@ +import copy + + +class Backend(object): + #: Actor proxy to an instance of :class:`mopidy.audio.Audio`. + #: + #: Should be passed to the backend constructor as the kwarg ``audio``, + #: which will then set this field. + audio = None + + #: The library provider. An instance of + # :class:`mopidy.backends.base.BaseLibraryProvider`. + library = None + + #: The playback provider. An instance of + #: :class:`mopidy.backends.base.BasePlaybackProvider`. + playback = None + + #: The stored playlists provider. An instance of + #: :class:`mopidy.backends.base.BaseStoredPlaylistsProvider`. + stored_playlists = None + + #: List of URI schemes this backend can handle. + uri_schemes = [] + + +class BaseLibraryProvider(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`mopidy.backends.base.Backend` + """ + + pykka_traversable = True + + def __init__(self, backend): + self.backend = backend + + def find_exact(self, **query): + """ + See :meth:`mopidy.backends.base.LibraryController.find_exact`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def lookup(self, uri): + """ + See :meth:`mopidy.backends.base.LibraryController.lookup`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def refresh(self, uri=None): + """ + See :meth:`mopidy.backends.base.LibraryController.refresh`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def search(self, **query): + """ + See :meth:`mopidy.backends.base.LibraryController.search`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + +class BasePlaybackProvider(object): + """ + :param backend: the backend + :type backend: :class:`mopidy.backends.base.Backend` + """ + + pykka_traversable = True + + def __init__(self, audio, backend): + self.audio = audio + self.backend = backend + + def pause(self): + """ + Pause playback. + + *MAY be reimplemented by subclass.* + + :rtype: :class:`True` if successful, else :class:`False` + """ + return self.audio.pause_playback().get() + + def play(self, track): + """ + Play given track. + + *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.audio.set_uri(track.uri).get() + return self.audio.start_playback().get() + + def resume(self): + """ + Resume playback at the same time position playback was paused. + + *MAY be reimplemented by subclass.* + + :rtype: :class:`True` if successful, else :class:`False` + """ + return self.audio.start_playback().get() + + def seek(self, time_position): + """ + Seek to a given time position. + + *MAY be reimplemented by subclass.* + + :param time_position: time position in milliseconds + :type time_position: int + :rtype: :class:`True` if successful, else :class:`False` + """ + return self.audio.set_position(time_position).get() + + def stop(self): + """ + Stop playback. + + *MAY be reimplemented by subclass.* + + :rtype: :class:`True` if successful, else :class:`False` + """ + return self.audio.stop_playback().get() + + def get_time_position(self): + """ + Get the current time position in milliseconds. + + *MAY be reimplemented by subclass.* + + :rtype: int + """ + return self.audio.get_position().get() + + +class BaseStoredPlaylistsProvider(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`mopidy.backends.base.Backend` + """ + + pykka_traversable = True + + def __init__(self, backend): + self.backend = backend + self._playlists = [] + + @property + def playlists(self): + """ + Currently stored playlists. + + Read/write. List of :class:`mopidy.models.Playlist`. + """ + return copy.copy(self._playlists) + + @playlists.setter # noqa + def playlists(self, playlists): + self._playlists = playlists + + def create(self, name): + """ + See :meth:`mopidy.backends.base.StoredPlaylistsController.create`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def delete(self, playlist): + """ + See :meth:`mopidy.backends.base.StoredPlaylistsController.delete`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def lookup(self, uri): + """ + See :meth:`mopidy.backends.base.StoredPlaylistsController.lookup`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def refresh(self): + """ + See :meth:`mopidy.backends.base.StoredPlaylistsController.refresh`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def rename(self, playlist, new_name): + """ + See :meth:`mopidy.backends.base.StoredPlaylistsController.rename`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def save(self, playlist): + """ + See :meth:`mopidy.backends.base.StoredPlaylistsController.save`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError diff --git a/mopidy/backends/base/__init__.py b/mopidy/backends/base/__init__.py deleted file mode 100644 index c27acae2..00000000 --- a/mopidy/backends/base/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -from .library import BaseLibraryProvider -from .playback import BasePlaybackProvider -from .stored_playlists import BaseStoredPlaylistsProvider - - -class Backend(object): - #: Actor proxy to an instance of :class:`mopidy.audio.Audio`. - #: - #: Should be passed to the backend constructor as the kwarg ``audio``, - #: which will then set this field. - audio = None - - #: The library provider. An instance of - # :class:`mopidy.backends.base.BaseLibraryProvider`. - library = None - - #: The playback provider. An instance of - #: :class:`mopidy.backends.base.BasePlaybackProvider`. - playback = None - - #: The stored playlists provider. An instance of - #: :class:`mopidy.backends.base.BaseStoredPlaylistsProvider`. - stored_playlists = None - - #: List of URI schemes this backend can handle. - uri_schemes = [] diff --git a/mopidy/backends/base/library.py b/mopidy/backends/base/library.py deleted file mode 100644 index 837eef49..00000000 --- a/mopidy/backends/base/library.py +++ /dev/null @@ -1,42 +0,0 @@ -class BaseLibraryProvider(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - """ - - pykka_traversable = True - - def __init__(self, backend): - self.backend = backend - - def find_exact(self, **query): - """ - See :meth:`mopidy.backends.base.LibraryController.find_exact`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def lookup(self, uri): - """ - See :meth:`mopidy.backends.base.LibraryController.lookup`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def refresh(self, uri=None): - """ - See :meth:`mopidy.backends.base.LibraryController.refresh`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def search(self, **query): - """ - See :meth:`mopidy.backends.base.LibraryController.search`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py deleted file mode 100644 index b21c30dc..00000000 --- a/mopidy/backends/base/playback.py +++ /dev/null @@ -1,77 +0,0 @@ -class BasePlaybackProvider(object): - """ - :param backend: the backend - :type backend: :class:`mopidy.backends.base.Backend` - """ - - pykka_traversable = True - - def __init__(self, audio, backend): - self.audio = audio - self.backend = backend - - def pause(self): - """ - Pause playback. - - *MAY be reimplemented by subclass.* - - :rtype: :class:`True` if successful, else :class:`False` - """ - return self.audio.pause_playback().get() - - def play(self, track): - """ - Play given track. - - *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.audio.set_uri(track.uri).get() - return self.audio.start_playback().get() - - def resume(self): - """ - Resume playback at the same time position playback was paused. - - *MAY be reimplemented by subclass.* - - :rtype: :class:`True` if successful, else :class:`False` - """ - return self.audio.start_playback().get() - - def seek(self, time_position): - """ - Seek to a given time position. - - *MAY be reimplemented by subclass.* - - :param time_position: time position in milliseconds - :type time_position: int - :rtype: :class:`True` if successful, else :class:`False` - """ - return self.audio.set_position(time_position).get() - - def stop(self): - """ - Stop playback. - - *MAY be reimplemented by subclass.* - - :rtype: :class:`True` if successful, else :class:`False` - """ - return self.audio.stop_playback().get() - - def get_time_position(self): - """ - Get the current time position in milliseconds. - - *MAY be reimplemented by subclass.* - - :rtype: int - """ - return self.audio.get_position().get() diff --git a/mopidy/backends/base/stored_playlists.py b/mopidy/backends/base/stored_playlists.py deleted file mode 100644 index d808798d..00000000 --- a/mopidy/backends/base/stored_playlists.py +++ /dev/null @@ -1,75 +0,0 @@ -from copy import copy - - -class BaseStoredPlaylistsProvider(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - """ - - pykka_traversable = True - - def __init__(self, backend): - self.backend = backend - self._playlists = [] - - @property - def playlists(self): - """ - Currently stored playlists. - - Read/write. List of :class:`mopidy.models.Playlist`. - """ - return copy(self._playlists) - - @playlists.setter # noqa - def playlists(self, playlists): - self._playlists = playlists - - def create(self, name): - """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.create`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def delete(self, playlist): - """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.delete`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def lookup(self, uri): - """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.lookup`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def refresh(self): - """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.refresh`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def rename(self, playlist, new_name): - """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.rename`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def save(self, playlist): - """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.save`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError From 3351d0e0d502cbf9d5530022f5430fc4aa1d2e66 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 15:40:27 +0200 Subject: [PATCH 079/233] Move dummy backend out of its own dir --- mopidy/backends/{dummy/__init__.py => dummy.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename mopidy/backends/{dummy/__init__.py => dummy.py} (100%) diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy.py similarity index 100% rename from mopidy/backends/dummy/__init__.py rename to mopidy/backends/dummy.py From 7c0d724df8b6bf8b5d7cf177705948262310dfef Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 15:42:41 +0200 Subject: [PATCH 080/233] Make flake8 ignore imports that flattens the module tree (#211) --- mopidy/core/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py index 28274fe3..7fecfd79 100644 --- a/mopidy/core/__init__.py +++ b/mopidy/core/__init__.py @@ -1,3 +1,4 @@ +# flake8: noqa from .actor import Core from .current_playlist import CurrentPlaylistController from .library import LibraryController From e22175ca98fd495b7915d67657d64d472f64f98c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 15:49:10 +0200 Subject: [PATCH 081/233] Move Audio actor from __init__.py to actor.py --- mopidy/audio/__init__.py | 382 +-------------------------------------- mopidy/audio/actor.py | 381 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 383 insertions(+), 380 deletions(-) create mode 100644 mopidy/audio/actor.py diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 4a0b0000..ba76bd84 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -1,381 +1,3 @@ -import pygst -pygst.require('0.10') -import gst -import gobject - -import logging - -from pykka.actor import ThreadingActor - -from mopidy import settings, utils -from mopidy.utils import process - -from . import mixers +# flake8: noqa +from .actor import Audio from .listener import AudioListener - -logger = logging.getLogger('mopidy.audio') - -mixers.register_mixers() - - -class Audio(ThreadingActor): - """ - Audio output through `GStreamer `_. - - **Settings:** - - - :attr:`mopidy.settings.OUTPUT` - - :attr:`mopidy.settings.MIXER` - - :attr:`mopidy.settings.MIXER_TRACK` - - """ - - def __init__(self): - super(Audio, self).__init__() - - self._playbin = None - self._mixer = None - self._mixer_track = None - self._software_mixing = False - - self._message_processor_set_up = False - - def on_start(self): - try: - self._setup_playbin() - self._setup_output() - self._setup_mixer() - self._setup_message_processor() - except gobject.GError as ex: - logger.exception(ex) - process.exit_process() - - def on_stop(self): - self._teardown_message_processor() - self._teardown_mixer() - self._teardown_playbin() - - def _setup_playbin(self): - self._playbin = gst.element_factory_make('playbin2') - - fakesink = gst.element_factory_make('fakesink') - self._playbin.set_property('video-sink', fakesink) - - def _teardown_playbin(self): - self._playbin.set_state(gst.STATE_NULL) - - def _setup_output(self): - try: - output = gst.parse_bin_from_description( - settings.OUTPUT, ghost_unconnected_pads=True) - self._playbin.set_property('audio-sink', output) - logger.info('Output set to %s', settings.OUTPUT) - except gobject.GError as ex: - logger.error( - 'Failed to create output "%s": %s', settings.OUTPUT, ex) - process.exit_process() - - def _setup_mixer(self): - if not settings.MIXER: - logger.info('Not setting up mixer.') - return - - if settings.MIXER == 'software': - self._software_mixing = True - logger.info('Mixer set to software mixing.') - return - - try: - mixerbin = gst.parse_bin_from_description( - settings.MIXER, ghost_unconnected_pads=False) - except gobject.GError as ex: - logger.warning( - 'Failed to create mixer "%s": %s', settings.MIXER, ex) - return - - # We assume that the bin will contain a single mixer. - mixer = mixerbin.get_by_interface('GstMixer') - if not mixer: - logger.warning('Did not find any mixers in %r', settings.MIXER) - return - - if mixerbin.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS: - logger.warning('Setting mixer %r to READY failed.', settings.MIXER) - return - - track = self._select_mixer_track(mixer, settings.MIXER_TRACK) - if not track: - logger.warning('Could not find usable mixer track.') - return - - self._mixer = mixer - self._mixer_track = track - logger.info('Mixer set to %s using track called %s', - mixer.get_factory().get_name(), track.label) - - def _select_mixer_track(self, mixer, track_label): - # Look for track with label == MIXER_TRACK, otherwise fallback to - # master track which is also an output. - for track in mixer.list_tracks(): - if track_label: - if track.label == track_label: - return track - elif track.flags & (gst.interfaces.MIXER_TRACK_MASTER | - gst.interfaces.MIXER_TRACK_OUTPUT): - return track - - def _teardown_mixer(self): - if self._mixer is not None: - self._mixer.set_state(gst.STATE_NULL) - - def _setup_message_processor(self): - bus = self._playbin.get_bus() - bus.add_signal_watch() - bus.connect('message', self._on_message) - self._message_processor_set_up = True - - def _teardown_message_processor(self): - if self._message_processor_set_up: - bus = self._playbin.get_bus() - bus.remove_signal_watch() - - def _on_message(self, bus, message): - if message.type == gst.MESSAGE_EOS: - self._trigger_reached_end_of_stream_event() - elif message.type == gst.MESSAGE_ERROR: - error, debug = message.parse_error() - logger.error(u'%s %s', error, debug) - self.stop_playback() - elif message.type == gst.MESSAGE_WARNING: - error, debug = message.parse_warning() - logger.warning(u'%s %s', error, debug) - - def _trigger_reached_end_of_stream_event(self): - logger.debug(u'Triggering reached end of stream event') - AudioListener.send('reached_end_of_stream') - - def set_uri(self, uri): - """ - Set URI of audio to be played. - - You *MUST* call :meth:`prepare_change` before calling this method. - - :param uri: the URI to play - :type uri: string - """ - self._playbin.set_property('uri', uri) - - def emit_data(self, capabilities, data): - """ - Call this to deliver raw audio data to be played. - - Note that the uri must be set to ``appsrc://`` for this to work. - - :param capabilities: a GStreamer capabilities string - :type capabilities: string - :param data: raw audio data to be played - """ - caps = gst.caps_from_string(capabilities) - buffer_ = gst.Buffer(buffer(data)) - buffer_.set_caps(caps) - - source = self._playbin.get_property('source') - source.set_property('caps', caps) - source.emit('push-buffer', buffer_) - - def emit_end_of_stream(self): - """ - Put an end-of-stream token on the playbin. This is typically used in - combination with :meth:`emit_data`. - - We will get a GStreamer message when the stream playback reaches the - token, and can then do any end-of-stream related tasks. - """ - self._playbin.get_property('source').emit('end-of-stream') - - def get_position(self): - """ - Get position in milliseconds. - - :rtype: int - """ - if self._playbin.get_state()[1] == gst.STATE_NULL: - return 0 - try: - position = self._playbin.query_position(gst.FORMAT_TIME)[0] - return position // gst.MSECOND - except gst.QueryError, e: - logger.error('time_position failed: %s', e) - return 0 - - def set_position(self, position): - """ - Set position in milliseconds. - - :param position: the position in milliseconds - :type volume: int - :rtype: :class:`True` if successful, else :class:`False` - """ - self._playbin.get_state() # block until state changes are done - handeled = self._playbin.seek_simple( - gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, - position * gst.MSECOND) - self._playbin.get_state() # block until seek is done - return handeled - - def start_playback(self): - """ - Notify GStreamer that it should start playback. - - :rtype: :class:`True` if successfull, else :class:`False` - """ - return self._set_state(gst.STATE_PLAYING) - - def pause_playback(self): - """ - Notify GStreamer that it should pause playback. - - :rtype: :class:`True` if successfull, else :class:`False` - """ - return self._set_state(gst.STATE_PAUSED) - - def prepare_change(self): - """ - Notify GStreamer that we are about to change state of playback. - - This function *MUST* be called before changing URIs or doing - changes like updating data that is being pushed. The reason for this - is that GStreamer will reset all its state when it changes to - :attr:`gst.STATE_READY`. - """ - return self._set_state(gst.STATE_READY) - - def stop_playback(self): - """ - Notify GStreamer that is should stop playback. - - :rtype: :class:`True` if successfull, else :class:`False` - """ - return self._set_state(gst.STATE_NULL) - - def _set_state(self, state): - """ - Internal method for setting the raw GStreamer state. - - .. digraph:: gst_state_transitions - - graph [rankdir="LR"]; - node [fontsize=10]; - - "NULL" -> "READY" - "PAUSED" -> "PLAYING" - "PAUSED" -> "READY" - "PLAYING" -> "PAUSED" - "READY" -> "NULL" - "READY" -> "PAUSED" - - :param state: State to set playbin to. One of: `gst.STATE_NULL`, - `gst.STATE_READY`, `gst.STATE_PAUSED` and `gst.STATE_PLAYING`. - :type state: :class:`gst.State` - :rtype: :class:`True` if successfull, else :class:`False` - """ - result = self._playbin.set_state(state) - if result == gst.STATE_CHANGE_FAILURE: - logger.warning( - 'Setting GStreamer state to %s: failed', state.value_name) - return False - elif result == gst.STATE_CHANGE_ASYNC: - logger.debug( - 'Setting GStreamer state to %s: async', state.value_name) - return True - else: - logger.debug( - 'Setting GStreamer state to %s: OK', state.value_name) - return True - - def get_volume(self): - """ - Get volume level of the installed mixer. - - Example values: - - 0: - Muted. - 100: - Max volume for given system. - :class:`None`: - No mixer present, so the volume is unknown. - - :rtype: int in range [0..100] or :class:`None` - """ - if self._software_mixing: - return round(self._playbin.get_property('volume') * 100) - - if self._mixer is None: - return None - - volumes = self._mixer.get_volume(self._mixer_track) - avg_volume = float(sum(volumes)) / len(volumes) - - new_scale = (0, 100) - old_scale = ( - self._mixer_track.min_volume, self._mixer_track.max_volume) - return utils.rescale(avg_volume, old=old_scale, new=new_scale) - - def set_volume(self, volume): - """ - Set volume level of the installed mixer. - - :param volume: the volume in the range [0..100] - :type volume: int - :rtype: :class:`True` if successful, else :class:`False` - """ - if self._software_mixing: - self._playbin.set_property('volume', volume / 100.0) - return True - - if self._mixer is None: - return False - - old_scale = (0, 100) - new_scale = ( - self._mixer_track.min_volume, self._mixer_track.max_volume) - - volume = utils.rescale(volume, old=old_scale, new=new_scale) - - volumes = (volume,) * self._mixer_track.num_channels - self._mixer.set_volume(self._mixer_track, volumes) - - return self._mixer.get_volume(self._mixer_track) == volumes - - def set_metadata(self, track): - """ - Set track metadata for currently playing song. - - Only needs to be called by sources such as `appsrc` which do not - already inject tags in playbin, e.g. when using :meth:`emit_data` to - deliver raw audio data to GStreamer. - - :param track: the current track - :type track: :class:`mopidy.models.Track` - """ - taglist = gst.TagList() - artists = [a for a in (track.artists or []) if a.name] - - # Default to blank data to trick shoutcast into clearing any previous - # values it might have. - taglist[gst.TAG_ARTIST] = u' ' - taglist[gst.TAG_TITLE] = u' ' - taglist[gst.TAG_ALBUM] = u' ' - - if artists: - taglist[gst.TAG_ARTIST] = u', '.join([a.name for a in artists]) - - if track.name: - taglist[gst.TAG_TITLE] = track.name - - if track.album and track.album.name: - taglist[gst.TAG_ALBUM] = track.album.name - - event = gst.event_new_tag(taglist) - self._playbin.send_event(event) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py new file mode 100644 index 00000000..4a0b0000 --- /dev/null +++ b/mopidy/audio/actor.py @@ -0,0 +1,381 @@ +import pygst +pygst.require('0.10') +import gst +import gobject + +import logging + +from pykka.actor import ThreadingActor + +from mopidy import settings, utils +from mopidy.utils import process + +from . import mixers +from .listener import AudioListener + +logger = logging.getLogger('mopidy.audio') + +mixers.register_mixers() + + +class Audio(ThreadingActor): + """ + Audio output through `GStreamer `_. + + **Settings:** + + - :attr:`mopidy.settings.OUTPUT` + - :attr:`mopidy.settings.MIXER` + - :attr:`mopidy.settings.MIXER_TRACK` + + """ + + def __init__(self): + super(Audio, self).__init__() + + self._playbin = None + self._mixer = None + self._mixer_track = None + self._software_mixing = False + + self._message_processor_set_up = False + + def on_start(self): + try: + self._setup_playbin() + self._setup_output() + self._setup_mixer() + self._setup_message_processor() + except gobject.GError as ex: + logger.exception(ex) + process.exit_process() + + def on_stop(self): + self._teardown_message_processor() + self._teardown_mixer() + self._teardown_playbin() + + def _setup_playbin(self): + self._playbin = gst.element_factory_make('playbin2') + + fakesink = gst.element_factory_make('fakesink') + self._playbin.set_property('video-sink', fakesink) + + def _teardown_playbin(self): + self._playbin.set_state(gst.STATE_NULL) + + def _setup_output(self): + try: + output = gst.parse_bin_from_description( + settings.OUTPUT, ghost_unconnected_pads=True) + self._playbin.set_property('audio-sink', output) + logger.info('Output set to %s', settings.OUTPUT) + except gobject.GError as ex: + logger.error( + 'Failed to create output "%s": %s', settings.OUTPUT, ex) + process.exit_process() + + def _setup_mixer(self): + if not settings.MIXER: + logger.info('Not setting up mixer.') + return + + if settings.MIXER == 'software': + self._software_mixing = True + logger.info('Mixer set to software mixing.') + return + + try: + mixerbin = gst.parse_bin_from_description( + settings.MIXER, ghost_unconnected_pads=False) + except gobject.GError as ex: + logger.warning( + 'Failed to create mixer "%s": %s', settings.MIXER, ex) + return + + # We assume that the bin will contain a single mixer. + mixer = mixerbin.get_by_interface('GstMixer') + if not mixer: + logger.warning('Did not find any mixers in %r', settings.MIXER) + return + + if mixerbin.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS: + logger.warning('Setting mixer %r to READY failed.', settings.MIXER) + return + + track = self._select_mixer_track(mixer, settings.MIXER_TRACK) + if not track: + logger.warning('Could not find usable mixer track.') + return + + self._mixer = mixer + self._mixer_track = track + logger.info('Mixer set to %s using track called %s', + mixer.get_factory().get_name(), track.label) + + def _select_mixer_track(self, mixer, track_label): + # Look for track with label == MIXER_TRACK, otherwise fallback to + # master track which is also an output. + for track in mixer.list_tracks(): + if track_label: + if track.label == track_label: + return track + elif track.flags & (gst.interfaces.MIXER_TRACK_MASTER | + gst.interfaces.MIXER_TRACK_OUTPUT): + return track + + def _teardown_mixer(self): + if self._mixer is not None: + self._mixer.set_state(gst.STATE_NULL) + + def _setup_message_processor(self): + bus = self._playbin.get_bus() + bus.add_signal_watch() + bus.connect('message', self._on_message) + self._message_processor_set_up = True + + def _teardown_message_processor(self): + if self._message_processor_set_up: + bus = self._playbin.get_bus() + bus.remove_signal_watch() + + def _on_message(self, bus, message): + if message.type == gst.MESSAGE_EOS: + self._trigger_reached_end_of_stream_event() + elif message.type == gst.MESSAGE_ERROR: + error, debug = message.parse_error() + logger.error(u'%s %s', error, debug) + self.stop_playback() + elif message.type == gst.MESSAGE_WARNING: + error, debug = message.parse_warning() + logger.warning(u'%s %s', error, debug) + + def _trigger_reached_end_of_stream_event(self): + logger.debug(u'Triggering reached end of stream event') + AudioListener.send('reached_end_of_stream') + + def set_uri(self, uri): + """ + Set URI of audio to be played. + + You *MUST* call :meth:`prepare_change` before calling this method. + + :param uri: the URI to play + :type uri: string + """ + self._playbin.set_property('uri', uri) + + def emit_data(self, capabilities, data): + """ + Call this to deliver raw audio data to be played. + + Note that the uri must be set to ``appsrc://`` for this to work. + + :param capabilities: a GStreamer capabilities string + :type capabilities: string + :param data: raw audio data to be played + """ + caps = gst.caps_from_string(capabilities) + buffer_ = gst.Buffer(buffer(data)) + buffer_.set_caps(caps) + + source = self._playbin.get_property('source') + source.set_property('caps', caps) + source.emit('push-buffer', buffer_) + + def emit_end_of_stream(self): + """ + Put an end-of-stream token on the playbin. This is typically used in + combination with :meth:`emit_data`. + + We will get a GStreamer message when the stream playback reaches the + token, and can then do any end-of-stream related tasks. + """ + self._playbin.get_property('source').emit('end-of-stream') + + def get_position(self): + """ + Get position in milliseconds. + + :rtype: int + """ + if self._playbin.get_state()[1] == gst.STATE_NULL: + return 0 + try: + position = self._playbin.query_position(gst.FORMAT_TIME)[0] + return position // gst.MSECOND + except gst.QueryError, e: + logger.error('time_position failed: %s', e) + return 0 + + def set_position(self, position): + """ + Set position in milliseconds. + + :param position: the position in milliseconds + :type volume: int + :rtype: :class:`True` if successful, else :class:`False` + """ + self._playbin.get_state() # block until state changes are done + handeled = self._playbin.seek_simple( + gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, + position * gst.MSECOND) + self._playbin.get_state() # block until seek is done + return handeled + + def start_playback(self): + """ + Notify GStreamer that it should start playback. + + :rtype: :class:`True` if successfull, else :class:`False` + """ + return self._set_state(gst.STATE_PLAYING) + + def pause_playback(self): + """ + Notify GStreamer that it should pause playback. + + :rtype: :class:`True` if successfull, else :class:`False` + """ + return self._set_state(gst.STATE_PAUSED) + + def prepare_change(self): + """ + Notify GStreamer that we are about to change state of playback. + + This function *MUST* be called before changing URIs or doing + changes like updating data that is being pushed. The reason for this + is that GStreamer will reset all its state when it changes to + :attr:`gst.STATE_READY`. + """ + return self._set_state(gst.STATE_READY) + + def stop_playback(self): + """ + Notify GStreamer that is should stop playback. + + :rtype: :class:`True` if successfull, else :class:`False` + """ + return self._set_state(gst.STATE_NULL) + + def _set_state(self, state): + """ + Internal method for setting the raw GStreamer state. + + .. digraph:: gst_state_transitions + + graph [rankdir="LR"]; + node [fontsize=10]; + + "NULL" -> "READY" + "PAUSED" -> "PLAYING" + "PAUSED" -> "READY" + "PLAYING" -> "PAUSED" + "READY" -> "NULL" + "READY" -> "PAUSED" + + :param state: State to set playbin to. One of: `gst.STATE_NULL`, + `gst.STATE_READY`, `gst.STATE_PAUSED` and `gst.STATE_PLAYING`. + :type state: :class:`gst.State` + :rtype: :class:`True` if successfull, else :class:`False` + """ + result = self._playbin.set_state(state) + if result == gst.STATE_CHANGE_FAILURE: + logger.warning( + 'Setting GStreamer state to %s: failed', state.value_name) + return False + elif result == gst.STATE_CHANGE_ASYNC: + logger.debug( + 'Setting GStreamer state to %s: async', state.value_name) + return True + else: + logger.debug( + 'Setting GStreamer state to %s: OK', state.value_name) + return True + + def get_volume(self): + """ + Get volume level of the installed mixer. + + Example values: + + 0: + Muted. + 100: + Max volume for given system. + :class:`None`: + No mixer present, so the volume is unknown. + + :rtype: int in range [0..100] or :class:`None` + """ + if self._software_mixing: + return round(self._playbin.get_property('volume') * 100) + + if self._mixer is None: + return None + + volumes = self._mixer.get_volume(self._mixer_track) + avg_volume = float(sum(volumes)) / len(volumes) + + new_scale = (0, 100) + old_scale = ( + self._mixer_track.min_volume, self._mixer_track.max_volume) + return utils.rescale(avg_volume, old=old_scale, new=new_scale) + + def set_volume(self, volume): + """ + Set volume level of the installed mixer. + + :param volume: the volume in the range [0..100] + :type volume: int + :rtype: :class:`True` if successful, else :class:`False` + """ + if self._software_mixing: + self._playbin.set_property('volume', volume / 100.0) + return True + + if self._mixer is None: + return False + + old_scale = (0, 100) + new_scale = ( + self._mixer_track.min_volume, self._mixer_track.max_volume) + + volume = utils.rescale(volume, old=old_scale, new=new_scale) + + volumes = (volume,) * self._mixer_track.num_channels + self._mixer.set_volume(self._mixer_track, volumes) + + return self._mixer.get_volume(self._mixer_track) == volumes + + def set_metadata(self, track): + """ + Set track metadata for currently playing song. + + Only needs to be called by sources such as `appsrc` which do not + already inject tags in playbin, e.g. when using :meth:`emit_data` to + deliver raw audio data to GStreamer. + + :param track: the current track + :type track: :class:`mopidy.models.Track` + """ + taglist = gst.TagList() + artists = [a for a in (track.artists or []) if a.name] + + # Default to blank data to trick shoutcast into clearing any previous + # values it might have. + taglist[gst.TAG_ARTIST] = u' ' + taglist[gst.TAG_TITLE] = u' ' + taglist[gst.TAG_ALBUM] = u' ' + + if artists: + taglist[gst.TAG_ARTIST] = u', '.join([a.name for a in artists]) + + if track.name: + taglist[gst.TAG_TITLE] = track.name + + if track.album and track.album.name: + taglist[gst.TAG_ALBUM] = track.album.name + + event = gst.event_new_tag(taglist) + self._playbin.send_event(event) From 7c997d9221063a8c4819f80ef4db8faf549dc473 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 15:53:44 +0200 Subject: [PATCH 082/233] Move create_track() helper to mopidy.audio.mixers.utils --- mopidy/audio/mixers/__init__.py | 36 --------------------------------- mopidy/audio/mixers/fake.py | 4 ++-- mopidy/audio/mixers/nad.py | 4 ++-- mopidy/audio/mixers/utils.py | 35 ++++++++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 40 deletions(-) create mode 100644 mopidy/audio/mixers/utils.py diff --git a/mopidy/audio/mixers/__init__.py b/mopidy/audio/mixers/__init__.py index 26faff02..fb04bd00 100644 --- a/mopidy/audio/mixers/__init__.py +++ b/mopidy/audio/mixers/__init__.py @@ -3,42 +3,6 @@ pygst.require('0.10') import gst import gobject - -def create_track(label, initial_volume, min_volume, max_volume, - num_channels, flags): - - class Track(gst.interfaces.MixerTrack): - def __init__(self): - super(Track, self).__init__() - self.volumes = (initial_volume,) * self.num_channels - - @gobject.property - def label(self): - return label - - @gobject.property - def min_volume(self): - return min_volume - - @gobject.property - def max_volume(self): - return max_volume - - @gobject.property - def num_channels(self): - return num_channels - - @gobject.property - def flags(self): - return flags - - return Track() - - -# Import all mixers so that they are registered with GStreamer. -# -# Keep these imports at the bottom of the file to avoid cyclic import problems -# when mixers use the above code. from .auto import AutoAudioMixer from .fake import FakeMixer from .nad import NadMixer diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py index e0f1ae1f..3c85cc34 100644 --- a/mopidy/audio/mixers/fake.py +++ b/mopidy/audio/mixers/fake.py @@ -3,7 +3,7 @@ pygst.require('0.10') import gobject import gst -from mopidy.audio.mixers import create_track +from . import utils class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): @@ -25,7 +25,7 @@ class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): gst.Element.__init__(self) def list_tracks(self): - track = create_track( + track = utils.create_track( self.track_label, self.track_initial_volume, self.track_min_volume, diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index df8c3ec9..fc456a2b 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -12,7 +12,7 @@ except ImportError: from pykka.actor import ThreadingActor -from mopidy.audio.mixers import create_track +from . import utils logger = logging.getLogger('mopidy.audio.mixers.nad') @@ -36,7 +36,7 @@ class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): self._nad_talker = None def list_tracks(self): - track = create_track( + track = utils.create_track( label='Master', initial_volume=0, min_volume=0, diff --git a/mopidy/audio/mixers/utils.py b/mopidy/audio/mixers/utils.py new file mode 100644 index 00000000..c257ffd7 --- /dev/null +++ b/mopidy/audio/mixers/utils.py @@ -0,0 +1,35 @@ +import pygst +pygst.require('0.10') +import gst +import gobject + + +def create_track(label, initial_volume, min_volume, max_volume, + num_channels, flags): + + class Track(gst.interfaces.MixerTrack): + def __init__(self): + super(Track, self).__init__() + self.volumes = (initial_volume,) * self.num_channels + + @gobject.property + def label(self): + return label + + @gobject.property + def min_volume(self): + return min_volume + + @gobject.property + def max_volume(self): + return max_volume + + @gobject.property + def num_channels(self): + return num_channels + + @gobject.property + def flags(self): + return flags + + return Track() From d9d6a3d5b69a1fd61b275eddfeed26330d37a3a8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 16:08:46 +0200 Subject: [PATCH 083/233] Move exceptions to mopidy.exceptions --- mopidy/__init__.py | 22 ------------------- mopidy/__main__.py | 10 ++++----- mopidy/exceptions.py | 21 ++++++++++++++++++ mopidy/frontends/lastfm.py | 14 +++++------- mopidy/frontends/mpd/exceptions.py | 2 +- mopidy/frontends/mpris/__init__.py | 10 ++++----- mopidy/frontends/mpris/objects.py | 2 +- mopidy/utils/process.py | 4 ++-- mopidy/utils/settings.py | 8 +++---- tests/frontends/mpris/events_test.py | 2 +- .../frontends/mpris/player_interface_test.py | 4 ++-- tests/frontends/mpris/root_interface_test.py | 4 ++-- tests/utils/settings_test.py | 10 ++++----- 13 files changed, 55 insertions(+), 58 deletions(-) create mode 100644 mopidy/exceptions.py diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 2a88666c..ec2f4147 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -48,28 +48,6 @@ def get_python(): return u' '.join([implementation, version]) -class MopidyException(Exception): - def __init__(self, message, *args, **kwargs): - super(MopidyException, self).__init__(message, *args, **kwargs) - self._message = message - - @property - def message(self): - """Reimplement message field that was deprecated in Python 2.6""" - return self._message - - @message.setter # noqa - def message(self, message): - self._message = message - - -class SettingsError(MopidyException): - pass - - -class OptionalDependencyError(MopidyException): - pass - from mopidy import settings as default_settings_module from mopidy.utils.settings import SettingsProxy settings = SettingsProxy(default_settings_module) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index bfc600f5..a4982362 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -29,7 +29,7 @@ sys.path.insert( import mopidy -from mopidy import audio, core, settings, utils +from mopidy import audio, core, exceptions, settings, utils from mopidy.utils import log, path, process from mopidy.utils.deps import list_deps_optparse_callback from mopidy.utils.settings import list_settings_optparse_callback @@ -51,7 +51,7 @@ def main(): core_ref = setup_core(audio_ref, backend_ref) setup_frontends(core_ref) loop.run() - except mopidy.SettingsError as ex: + except exceptions.SettingsError as ex: logger.error(ex.message) except KeyboardInterrupt: logger.info(u'Interrupted. Exiting...') @@ -117,7 +117,7 @@ def setup_settings(interactive): path.get_or_create_file(mopidy.SETTINGS_FILE) try: settings.validate(interactive) - except mopidy.SettingsError as ex: + except exceptions.SettingsError as ex: logger.error(ex.message) sys.exit(1) @@ -150,7 +150,7 @@ def setup_frontends(core): for frontend_class_name in settings.FRONTENDS: try: utils.get_class(frontend_class_name).start(core=core) - except mopidy.OptionalDependencyError as ex: + except exceptions.OptionalDependencyError as ex: logger.info(u'Disabled: %s (%s)', frontend_class_name, ex) @@ -158,7 +158,7 @@ def stop_frontends(): for frontend_class_name in settings.FRONTENDS: try: process.stop_actors_by_class(utils.get_class(frontend_class_name)) - except mopidy.OptionalDependencyError: + except exceptions.OptionalDependencyError: pass diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py new file mode 100644 index 00000000..6e0c575e --- /dev/null +++ b/mopidy/exceptions.py @@ -0,0 +1,21 @@ +class MopidyException(Exception): + def __init__(self, message, *args, **kwargs): + super(MopidyException, self).__init__(message, *args, **kwargs) + self._message = message + + @property + def message(self): + """Reimplement message field that was deprecated in Python 2.6""" + return self._message + + @message.setter # noqa + def message(self, message): + self._message = message + + +class SettingsError(MopidyException): + pass + + +class OptionalDependencyError(MopidyException): + pass diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 37fbafe2..70c6c8e4 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -1,16 +1,14 @@ import logging import time +from pykka.actor import ThreadingActor + +from mopidy import core, exceptions, settings + try: import pylast except ImportError as import_error: - from mopidy import OptionalDependencyError - raise OptionalDependencyError(import_error) - -from pykka.actor import ThreadingActor - -from mopidy import core, settings, SettingsError - + raise exceptions.OptionalDependencyError(import_error) logger = logging.getLogger('mopidy.frontends.lastfm') @@ -50,7 +48,7 @@ class LastfmFrontend(ThreadingActor, core.CoreListener): api_key=API_KEY, api_secret=API_SECRET, username=username, password_hash=password_hash) logger.info(u'Connected to Last.fm') - except SettingsError as e: + except exceptions.SettingsError as e: logger.info(u'Last.fm scrobbler not started') logger.debug(u'Last.fm settings error: %s', e) self.stop() diff --git a/mopidy/frontends/mpd/exceptions.py b/mopidy/frontends/mpd/exceptions.py index e5844b60..5925d6bc 100644 --- a/mopidy/frontends/mpd/exceptions.py +++ b/mopidy/frontends/mpd/exceptions.py @@ -1,4 +1,4 @@ -from mopidy import MopidyException +from mopidy.exceptions import MopidyException class MpdAckError(MopidyException): diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 80995adf..cbfb2cc9 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -1,5 +1,10 @@ import logging +from pykka.actor import ThreadingActor + +from mopidy import core, settings +from mopidy.frontends.mpris import objects + logger = logging.getLogger('mopidy.frontends.mpris') try: @@ -8,11 +13,6 @@ except ImportError as import_error: indicate = None # noqa logger.debug(u'Startup notification will not be sent (%s)', import_error) -from pykka.actor import ThreadingActor - -from mopidy import core, settings -from mopidy.frontends.mpris import objects - class MprisFrontend(ThreadingActor, core.CoreListener): """ diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index ee54f91c..74c85617 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -7,7 +7,7 @@ try: import dbus.service import gobject except ImportError as import_error: - from mopidy import OptionalDependencyError + from mopidy.exceptions import OptionalDependencyError raise OptionalDependencyError(import_error) from mopidy import settings diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index c45659bb..b3f90150 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -6,7 +6,7 @@ import threading from pykka import ActorDeadError from pykka.registry import ActorRegistry -from mopidy import SettingsError +from mopidy import exceptions logger = logging.getLogger('mopidy.utils.process') @@ -59,7 +59,7 @@ class BaseThread(threading.Thread): self.run_inside_try() except KeyboardInterrupt: logger.info(u'Interrupted by user') - except SettingsError as e: + except exceptions.SettingsError as e: logger.error(e.message) except ImportError as e: logger.error(e) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 0ecdd827..39d613b3 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -8,7 +8,7 @@ import os import pprint import sys -from mopidy import SettingsError, SETTINGS_PATH, SETTINGS_FILE +from mopidy import exceptions, SETTINGS_PATH, SETTINGS_FILE from mopidy.utils import log from mopidy.utils import path @@ -53,11 +53,11 @@ class SettingsProxy(object): current = self.current # bind locally to avoid copying+updates if attr not in current: - raise SettingsError(u'Setting "%s" is not set.' % attr) + raise exceptions.SettingsError(u'Setting "%s" is not set.' % attr) value = current[attr] if isinstance(value, basestring) and len(value) == 0: - raise SettingsError(u'Setting "%s" is empty.' % attr) + raise exceptions.SettingsError(u'Setting "%s" is empty.' % attr) if not value: return value if attr.endswith('_PATH') or attr.endswith('_FILE'): @@ -77,7 +77,7 @@ class SettingsProxy(object): logger.error( u'Settings validation errors: %s', log.indent(self.get_errors_as_string())) - raise SettingsError(u'Settings validation failed.') + raise exceptions.SettingsError(u'Settings validation failed.') def _read_missing_settings_from_stdin(self, current, runtime): for setting, value in sorted(current.iteritems()): diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py index 241b9365..a4efe344 100644 --- a/tests/frontends/mpris/events_test.py +++ b/tests/frontends/mpris/events_test.py @@ -2,7 +2,7 @@ import sys import mock -from mopidy import OptionalDependencyError +from mopidy.exceptions import OptionalDependencyError from mopidy.models import Track try: diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index 6088a94b..5c3d2cae 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -4,14 +4,14 @@ import mock from pykka.registry import ActorRegistry -from mopidy import core, OptionalDependencyError +from mopidy import core, exceptions from mopidy.backends import dummy from mopidy.core import PlaybackState from mopidy.models import Album, Artist, Track try: from mopidy.frontends.mpris import objects -except OptionalDependencyError: +except exceptions.OptionalDependencyError: pass from tests import unittest diff --git a/tests/frontends/mpris/root_interface_test.py b/tests/frontends/mpris/root_interface_test.py index 847ed2de..8f37cc47 100644 --- a/tests/frontends/mpris/root_interface_test.py +++ b/tests/frontends/mpris/root_interface_test.py @@ -4,12 +4,12 @@ import mock from pykka.registry import ActorRegistry -from mopidy import core, settings, OptionalDependencyError +from mopidy import core, exceptions, settings from mopidy.backends import dummy try: from mopidy.frontends.mpris import objects -except OptionalDependencyError: +except exceptions.OptionalDependencyError: pass from tests import unittest diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index bbeda20c..5ce643cb 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -1,6 +1,6 @@ import os -import mopidy +from mopidy import exceptions, settings from mopidy.utils import settings as setting_utils from tests import unittest @@ -79,7 +79,7 @@ class ValidateSettingsTest(unittest.TestCase): class SettingsProxyTest(unittest.TestCase): def setUp(self): - self.settings = setting_utils.SettingsProxy(mopidy.settings) + self.settings = setting_utils.SettingsProxy(settings) self.settings.local.clear() def test_set_and_get_attr(self): @@ -90,7 +90,7 @@ class SettingsProxyTest(unittest.TestCase): try: self.settings.TEST self.fail(u'Should raise exception') - except mopidy.SettingsError as e: + except exceptions.SettingsError as e: self.assertEqual(u'Setting "TEST" is not set.', e.message) def test_getattr_raises_error_on_empty_setting(self): @@ -98,7 +98,7 @@ class SettingsProxyTest(unittest.TestCase): try: self.settings.TEST self.fail(u'Should raise exception') - except mopidy.SettingsError as e: + except exceptions.SettingsError as e: self.assertEqual(u'Setting "TEST" is empty.', e.message) def test_getattr_does_not_raise_error_if_setting_is_false(self): @@ -184,7 +184,7 @@ class SettingsProxyTest(unittest.TestCase): class FormatSettingListTest(unittest.TestCase): def setUp(self): - self.settings = setting_utils.SettingsProxy(mopidy.settings) + self.settings = setting_utils.SettingsProxy(settings) def test_contains_the_setting_name(self): self.settings.TEST = u'test' From d4f5d02c72b68ad00149ca7b24bf30902c13a2a8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 16:13:20 +0200 Subject: [PATCH 084/233] Move MpdSession to a session module --- mopidy/frontends/mpd/__init__.py | 60 ++---------------------- mopidy/frontends/mpd/session.py | 56 ++++++++++++++++++++++ tests/frontends/mpd/protocol/__init__.py | 4 +- 3 files changed, 63 insertions(+), 57 deletions(-) create mode 100644 mopidy/frontends/mpd/session.py diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index e5bafcf1..b90f7c86 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -4,8 +4,8 @@ import sys from pykka import registry, actor from mopidy import core, settings -from mopidy.frontends.mpd import dispatcher, protocol -from mopidy.utils import locale_decode, log, network, process +from mopidy.frontends.mpd import session +from mopidy.utils import locale_decode, network, process logger = logging.getLogger('mopidy.frontends.mpd') @@ -33,7 +33,7 @@ class MpdFrontend(actor.ThreadingActor, core.CoreListener): try: network.Server( hostname, port, - protocol=MpdSession, protocol_kwargs={'core': core}, + protocol=session.MpdSession, protocol_kwargs={'core': core}, max_connections=settings.MPD_SERVER_MAX_CONNECTIONS) except IOError as error: logger.error( @@ -43,7 +43,7 @@ class MpdFrontend(actor.ThreadingActor, core.CoreListener): logger.info(u'MPD server running at [%s]:%s', hostname, port) def on_stop(self): - process.stop_actors_by_class(MpdSession) + process.stop_actors_by_class(session.MpdSession) def send_idle(self, subsystem): # FIXME this should be updated once pykka supports non-blocking calls @@ -53,7 +53,7 @@ class MpdFrontend(actor.ThreadingActor, core.CoreListener): 'attr_path': ('on_idle',), 'args': [subsystem], 'kwargs': {}, - }, target_class=MpdSession) + }, target_class=session.MpdSession) def playback_state_changed(self, old_state, new_state): self.send_idle('player') @@ -66,53 +66,3 @@ class MpdFrontend(actor.ThreadingActor, core.CoreListener): def volume_changed(self): self.send_idle('mixer') - - -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. - """ - - terminator = protocol.LINE_TERMINATOR - encoding = protocol.ENCODING - delimiter = r'\r?\n' - - def __init__(self, connection, core=None): - super(MpdSession, self).__init__(connection) - self.dispatcher = dispatcher.MpdDispatcher(session=self, core=core) - - def on_start(self): - logger.info(u'New MPD connection from [%s]:%s', self.host, self.port) - self.send_lines([u'OK MPD %s' % protocol.VERSION]) - - def on_line_received(self, line): - logger.debug( - u'Request from [%s]:%s to %s: %s', - self.host, self.port, self.actor_urn, line) - - response = self.dispatcher.handle_request(line) - if not response: - return - - logger.debug( - u'Response to [%s]:%s from %s: %s', - self.host, self.port, self.actor_urn, - log.indent(self.terminator.join(response))) - - self.send_lines(response) - - def on_idle(self, subsystem): - self.dispatcher.handle_idle(subsystem) - - def decode(self, line): - try: - return super(MpdSession, self).decode(line.decode('string_escape')) - except ValueError: - logger.warning( - u'Stopping actor due to unescaping error, data ' - u'supplied by client was not valid.') - self.stop() - - def close(self): - self.stop() diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py new file mode 100644 index 00000000..b4531c83 --- /dev/null +++ b/mopidy/frontends/mpd/session.py @@ -0,0 +1,56 @@ +import logging + +from mopidy.frontends.mpd import dispatcher, protocol +from mopidy.utils import log, network + +logger = logging.getLogger('mopidy.frontends.mpd') + + +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. + """ + + terminator = protocol.LINE_TERMINATOR + encoding = protocol.ENCODING + delimiter = r'\r?\n' + + def __init__(self, connection, core=None): + super(MpdSession, self).__init__(connection) + self.dispatcher = dispatcher.MpdDispatcher(session=self, core=core) + + def on_start(self): + logger.info(u'New MPD connection from [%s]:%s', self.host, self.port) + self.send_lines([u'OK MPD %s' % protocol.VERSION]) + + def on_line_received(self, line): + logger.debug( + u'Request from [%s]:%s to %s: %s', + self.host, self.port, self.actor_urn, line) + + response = self.dispatcher.handle_request(line) + if not response: + return + + logger.debug( + u'Response to [%s]:%s from %s: %s', + self.host, self.port, self.actor_urn, + log.indent(self.terminator.join(response))) + + self.send_lines(response) + + def on_idle(self, subsystem): + self.dispatcher.handle_idle(subsystem) + + def decode(self, line): + try: + return super(MpdSession, self).decode(line.decode('string_escape')) + except ValueError: + logger.warning( + u'Stopping actor due to unescaping error, data ' + u'supplied by client was not valid.') + self.stop() + + def close(self): + self.stop() diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index 63c253d9..34557513 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -4,7 +4,7 @@ from pykka.registry import ActorRegistry from mopidy import core, settings from mopidy.backends import dummy -from mopidy.frontends import mpd +from mopidy.frontends.mpd import session from tests import unittest @@ -27,7 +27,7 @@ class BaseTestCase(unittest.TestCase): self.core = core.Core.start(backend=self.backend).proxy() self.connection = MockConnection() - self.session = mpd.MpdSession(self.connection, core=self.core) + self.session = session.MpdSession(self.connection, core=self.core) self.dispatcher = self.session.dispatcher self.context = self.dispatcher.context From 95946caa08e8e9139fc309fefa9843b41708133d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 16:15:16 +0200 Subject: [PATCH 085/233] Move MpdFrontend to an actor module --- mopidy/frontends/mpd/__init__.py | 70 +------------------------------- mopidy/frontends/mpd/actor.py | 68 +++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 68 deletions(-) create mode 100644 mopidy/frontends/mpd/actor.py diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index b90f7c86..e2d2b9c7 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -1,68 +1,2 @@ -import logging -import sys - -from pykka import registry, actor - -from mopidy import core, settings -from mopidy.frontends.mpd import session -from mopidy.utils import locale_decode, network, process - -logger = logging.getLogger('mopidy.frontends.mpd') - - -class MpdFrontend(actor.ThreadingActor, core.CoreListener): - """ - The MPD frontend. - - **Dependencies:** - - - None - - **Settings:** - - - :attr:`mopidy.settings.MPD_SERVER_HOSTNAME` - - :attr:`mopidy.settings.MPD_SERVER_PORT` - - :attr:`mopidy.settings.MPD_SERVER_PASSWORD` - """ - - def __init__(self, core): - super(MpdFrontend, self).__init__() - hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME) - port = settings.MPD_SERVER_PORT - - try: - network.Server( - hostname, port, - protocol=session.MpdSession, protocol_kwargs={'core': core}, - max_connections=settings.MPD_SERVER_MAX_CONNECTIONS) - except IOError as error: - logger.error( - u'MPD server startup failed: %s', locale_decode(error)) - sys.exit(1) - - logger.info(u'MPD server running at [%s]:%s', hostname, port) - - def on_stop(self): - process.stop_actors_by_class(session.MpdSession) - - def send_idle(self, subsystem): - # FIXME this should be updated once pykka supports non-blocking calls - # on proxies or some similar solution - registry.ActorRegistry.broadcast({ - 'command': 'pykka_call', - 'attr_path': ('on_idle',), - 'args': [subsystem], - 'kwargs': {}, - }, target_class=session.MpdSession) - - def playback_state_changed(self, old_state, new_state): - self.send_idle('player') - - def playlist_changed(self): - self.send_idle('playlist') - - def options_changed(self): - self.send_idle('options') - - def volume_changed(self): - self.send_idle('mixer') +# flake8: noqa +from .actor import MpdFrontend diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py new file mode 100644 index 00000000..b90f7c86 --- /dev/null +++ b/mopidy/frontends/mpd/actor.py @@ -0,0 +1,68 @@ +import logging +import sys + +from pykka import registry, actor + +from mopidy import core, settings +from mopidy.frontends.mpd import session +from mopidy.utils import locale_decode, network, process + +logger = logging.getLogger('mopidy.frontends.mpd') + + +class MpdFrontend(actor.ThreadingActor, core.CoreListener): + """ + The MPD frontend. + + **Dependencies:** + + - None + + **Settings:** + + - :attr:`mopidy.settings.MPD_SERVER_HOSTNAME` + - :attr:`mopidy.settings.MPD_SERVER_PORT` + - :attr:`mopidy.settings.MPD_SERVER_PASSWORD` + """ + + def __init__(self, core): + super(MpdFrontend, self).__init__() + hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME) + port = settings.MPD_SERVER_PORT + + try: + network.Server( + hostname, port, + protocol=session.MpdSession, protocol_kwargs={'core': core}, + max_connections=settings.MPD_SERVER_MAX_CONNECTIONS) + except IOError as error: + logger.error( + u'MPD server startup failed: %s', locale_decode(error)) + sys.exit(1) + + logger.info(u'MPD server running at [%s]:%s', hostname, port) + + def on_stop(self): + process.stop_actors_by_class(session.MpdSession) + + def send_idle(self, subsystem): + # FIXME this should be updated once pykka supports non-blocking calls + # on proxies or some similar solution + registry.ActorRegistry.broadcast({ + 'command': 'pykka_call', + 'attr_path': ('on_idle',), + 'args': [subsystem], + 'kwargs': {}, + }, target_class=session.MpdSession) + + def playback_state_changed(self, old_state, new_state): + self.send_idle('player') + + def playlist_changed(self): + self.send_idle('playlist') + + def options_changed(self): + self.send_idle('options') + + def volume_changed(self): + self.send_idle('mixer') From 7c0495e6daed9f0015b18c6d3817d505835e06c7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 16:17:14 +0200 Subject: [PATCH 086/233] Move MprisFrontend to an actor module --- mopidy/frontends/mpris/__init__.py | 130 +---------------------------- mopidy/frontends/mpris/actor.py | 128 ++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 128 deletions(-) create mode 100644 mopidy/frontends/mpris/actor.py diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index cbfb2cc9..93ad0795 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -1,128 +1,2 @@ -import logging - -from pykka.actor import ThreadingActor - -from mopidy import core, settings -from mopidy.frontends.mpris import objects - -logger = logging.getLogger('mopidy.frontends.mpris') - -try: - import indicate -except ImportError as import_error: - indicate = None # noqa - logger.debug(u'Startup notification will not be sent (%s)', import_error) - - -class MprisFrontend(ThreadingActor, core.CoreListener): - """ - Frontend which lets you control Mopidy through the Media Player Remote - Interfacing Specification (`MPRIS `_) D-Bus - interface. - - An example of an MPRIS client is the `Ubuntu Sound Menu - `_. - - **Dependencies:** - - - D-Bus Python bindings. The package is named ``python-dbus`` in - Ubuntu/Debian. - - ``libindicate`` Python bindings is needed to expose Mopidy in e.g. the - Ubuntu Sound Menu. The package is named ``python-indicate`` in - Ubuntu/Debian. - - An ``.desktop`` file for Mopidy installed at the path set in - :attr:`mopidy.settings.DESKTOP_FILE`. See :ref:`install_desktop_file` for - details. - - **Testing the frontend** - - To test, start Mopidy, and then run the following in a Python shell:: - - import dbus - bus = dbus.SessionBus() - player = bus.get_object('org.mpris.MediaPlayer2.mopidy', - '/org/mpris/MediaPlayer2') - - Now you can control Mopidy through the player object. Examples: - - - To get some properties from Mopidy, run:: - - props = player.GetAll('org.mpris.MediaPlayer2', - dbus_interface='org.freedesktop.DBus.Properties') - - - To quit Mopidy through D-Bus, run:: - - player.Quit(dbus_interface='org.mpris.MediaPlayer2') - """ - - def __init__(self, core): - super(MprisFrontend, self).__init__() - self.core = core - self.indicate_server = None - self.mpris_object = None - - def on_start(self): - try: - self.mpris_object = objects.MprisObject(self.core) - self._send_startup_notification() - except Exception as e: - logger.error(u'MPRIS frontend setup failed (%s)', e) - self.stop() - - def on_stop(self): - logger.debug(u'Removing MPRIS object from D-Bus connection...') - if self.mpris_object: - self.mpris_object.remove_from_connection() - self.mpris_object = None - logger.debug(u'Removed MPRIS object from D-Bus connection') - - def _send_startup_notification(self): - """ - Send startup notification using libindicate to make Mopidy appear in - e.g. `Ubuntu's sound menu `_. - - A reference to the libindicate server is kept for as long as Mopidy is - running. When Mopidy exits, the server will be unreferenced and Mopidy - will automatically be unregistered from e.g. the sound menu. - """ - if not indicate: - return - logger.debug(u'Sending startup notification...') - self.indicate_server = indicate.Server() - self.indicate_server.set_type('music.mopidy') - self.indicate_server.set_desktop_file(settings.DESKTOP_FILE) - self.indicate_server.show() - logger.debug(u'Startup notification sent') - - def _emit_properties_changed(self, *changed_properties): - if self.mpris_object is None: - return - props_with_new_values = [ - (p, self.mpris_object.Get(objects.PLAYER_IFACE, p)) - for p in changed_properties] - self.mpris_object.PropertiesChanged( - objects.PLAYER_IFACE, dict(props_with_new_values), []) - - def track_playback_paused(self, track, time_position): - logger.debug(u'Received track playback paused event') - self._emit_properties_changed('PlaybackStatus') - - def track_playback_resumed(self, track, time_position): - logger.debug(u'Received track playback resumed event') - self._emit_properties_changed('PlaybackStatus') - - def track_playback_started(self, track): - logger.debug(u'Received track playback started event') - self._emit_properties_changed('PlaybackStatus', 'Metadata') - - def track_playback_ended(self, track, time_position): - logger.debug(u'Received track playback ended event') - self._emit_properties_changed('PlaybackStatus', 'Metadata') - - def volume_changed(self): - logger.debug(u'Received volume changed event') - self._emit_properties_changed('Volume') - - def seeked(self, time_position_in_ms): - logger.debug(u'Received seeked event') - self.mpris_object.Seeked(time_position_in_ms * 1000) +# flake8: noqa +from .actor import MprisFrontend diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py new file mode 100644 index 00000000..cbfb2cc9 --- /dev/null +++ b/mopidy/frontends/mpris/actor.py @@ -0,0 +1,128 @@ +import logging + +from pykka.actor import ThreadingActor + +from mopidy import core, settings +from mopidy.frontends.mpris import objects + +logger = logging.getLogger('mopidy.frontends.mpris') + +try: + import indicate +except ImportError as import_error: + indicate = None # noqa + logger.debug(u'Startup notification will not be sent (%s)', import_error) + + +class MprisFrontend(ThreadingActor, core.CoreListener): + """ + Frontend which lets you control Mopidy through the Media Player Remote + Interfacing Specification (`MPRIS `_) D-Bus + interface. + + An example of an MPRIS client is the `Ubuntu Sound Menu + `_. + + **Dependencies:** + + - D-Bus Python bindings. The package is named ``python-dbus`` in + Ubuntu/Debian. + - ``libindicate`` Python bindings is needed to expose Mopidy in e.g. the + Ubuntu Sound Menu. The package is named ``python-indicate`` in + Ubuntu/Debian. + - An ``.desktop`` file for Mopidy installed at the path set in + :attr:`mopidy.settings.DESKTOP_FILE`. See :ref:`install_desktop_file` for + details. + + **Testing the frontend** + + To test, start Mopidy, and then run the following in a Python shell:: + + import dbus + bus = dbus.SessionBus() + player = bus.get_object('org.mpris.MediaPlayer2.mopidy', + '/org/mpris/MediaPlayer2') + + Now you can control Mopidy through the player object. Examples: + + - To get some properties from Mopidy, run:: + + props = player.GetAll('org.mpris.MediaPlayer2', + dbus_interface='org.freedesktop.DBus.Properties') + + - To quit Mopidy through D-Bus, run:: + + player.Quit(dbus_interface='org.mpris.MediaPlayer2') + """ + + def __init__(self, core): + super(MprisFrontend, self).__init__() + self.core = core + self.indicate_server = None + self.mpris_object = None + + def on_start(self): + try: + self.mpris_object = objects.MprisObject(self.core) + self._send_startup_notification() + except Exception as e: + logger.error(u'MPRIS frontend setup failed (%s)', e) + self.stop() + + def on_stop(self): + logger.debug(u'Removing MPRIS object from D-Bus connection...') + if self.mpris_object: + self.mpris_object.remove_from_connection() + self.mpris_object = None + logger.debug(u'Removed MPRIS object from D-Bus connection') + + def _send_startup_notification(self): + """ + Send startup notification using libindicate to make Mopidy appear in + e.g. `Ubuntu's sound menu `_. + + A reference to the libindicate server is kept for as long as Mopidy is + running. When Mopidy exits, the server will be unreferenced and Mopidy + will automatically be unregistered from e.g. the sound menu. + """ + if not indicate: + return + logger.debug(u'Sending startup notification...') + self.indicate_server = indicate.Server() + self.indicate_server.set_type('music.mopidy') + self.indicate_server.set_desktop_file(settings.DESKTOP_FILE) + self.indicate_server.show() + logger.debug(u'Startup notification sent') + + def _emit_properties_changed(self, *changed_properties): + if self.mpris_object is None: + return + props_with_new_values = [ + (p, self.mpris_object.Get(objects.PLAYER_IFACE, p)) + for p in changed_properties] + self.mpris_object.PropertiesChanged( + objects.PLAYER_IFACE, dict(props_with_new_values), []) + + def track_playback_paused(self, track, time_position): + logger.debug(u'Received track playback paused event') + self._emit_properties_changed('PlaybackStatus') + + def track_playback_resumed(self, track, time_position): + logger.debug(u'Received track playback resumed event') + self._emit_properties_changed('PlaybackStatus') + + def track_playback_started(self, track): + logger.debug(u'Received track playback started event') + self._emit_properties_changed('PlaybackStatus', 'Metadata') + + def track_playback_ended(self, track, time_position): + logger.debug(u'Received track playback ended event') + self._emit_properties_changed('PlaybackStatus', 'Metadata') + + def volume_changed(self): + logger.debug(u'Received volume changed event') + self._emit_properties_changed('Volume') + + def seeked(self, time_position_in_ms): + logger.debug(u'Received seeked event') + self.mpris_object.Seeked(time_position_in_ms * 1000) From bc531d987effc5bf1245e0ed9bb3f1a81191b898 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 19:36:54 +0200 Subject: [PATCH 087/233] Unroll registration of mixers --- mopidy/audio/mixers/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/audio/mixers/__init__.py b/mopidy/audio/mixers/__init__.py index fb04bd00..034b0fa9 100644 --- a/mopidy/audio/mixers/__init__.py +++ b/mopidy/audio/mixers/__init__.py @@ -15,4 +15,6 @@ def register_mixer(mixer_class): def register_mixers(): - map(register_mixer, [AutoAudioMixer, FakeMixer, NadMixer]) + register_mixer(AutoAudioMixer) + register_mixer(FakeMixer) + register_mixer(NadMixer) From 5a0529b142ce022110e0ce6d92b8ce890b47c65f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 21:36:22 +0200 Subject: [PATCH 088/233] Empty utils/__init__.py --- mopidy/__main__.py | 21 ++++---- mopidy/audio/actor.py | 13 +++-- mopidy/backends/local/translator.py | 2 +- mopidy/frontends/mpd/actor.py | 5 +- mopidy/frontends/mpd/dispatcher.py | 14 +++-- mopidy/utils/__init__.py | 52 ------------------- mopidy/utils/encoding.py | 8 +++ mopidy/utils/importing.py | 23 ++++++++ mopidy/utils/network.py | 4 +- .../{decode_test.py => encoding_test.py} | 4 +- .../utils/{init_test.py => importing_test.py} | 12 ++--- 11 files changed, 77 insertions(+), 81 deletions(-) create mode 100644 mopidy/utils/encoding.py create mode 100644 mopidy/utils/importing.py rename tests/utils/{decode_test.py => encoding_test.py} (90%) rename tests/utils/{init_test.py => importing_test.py} (68%) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index a4982362..aa108f2c 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -29,10 +29,9 @@ sys.path.insert( import mopidy -from mopidy import audio, core, exceptions, settings, utils -from mopidy.utils import log, path, process -from mopidy.utils.deps import list_deps_optparse_callback -from mopidy.utils.settings import list_settings_optparse_callback +from mopidy import audio, core, exceptions, settings +from mopidy.utils import ( + deps, importing, log, path, process, settings as settings_utils) logger = logging.getLogger('mopidy.main') @@ -90,11 +89,12 @@ def parse_options(): help='save debug log to "./mopidy.log"') parser.add_option( '--list-settings', - action='callback', callback=list_settings_optparse_callback, + action='callback', + callback=settings_utils.list_settings_optparse_callback, help='list current settings') parser.add_option( '--list-deps', - action='callback', callback=list_deps_optparse_callback, + action='callback', callback=deps.list_deps_optparse_callback, help='list dependencies and their versions') return parser.parse_args(args=mopidy_args)[0] @@ -131,11 +131,11 @@ def stop_audio(): def setup_backend(audio): - return utils.get_class(settings.BACKENDS[0]).start(audio=audio).proxy() + return importing.get_class(settings.BACKENDS[0]).start(audio=audio).proxy() def stop_backend(): - process.stop_actors_by_class(utils.get_class(settings.BACKENDS[0])) + process.stop_actors_by_class(importing.get_class(settings.BACKENDS[0])) def setup_core(audio, backend): @@ -149,7 +149,7 @@ def stop_core(): def setup_frontends(core): for frontend_class_name in settings.FRONTENDS: try: - utils.get_class(frontend_class_name).start(core=core) + importing.get_class(frontend_class_name).start(core=core) except exceptions.OptionalDependencyError as ex: logger.info(u'Disabled: %s (%s)', frontend_class_name, ex) @@ -157,7 +157,8 @@ def setup_frontends(core): def stop_frontends(): for frontend_class_name in settings.FRONTENDS: try: - process.stop_actors_by_class(utils.get_class(frontend_class_name)) + frontend_class = importing.get_class(frontend_class_name) + process.stop_actors_by_class(frontend_class) except exceptions.OptionalDependencyError: pass diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 4a0b0000..77b451d7 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -7,7 +7,7 @@ import logging from pykka.actor import ThreadingActor -from mopidy import settings, utils +from mopidy import settings from mopidy.utils import process from . import mixers @@ -320,7 +320,7 @@ class Audio(ThreadingActor): new_scale = (0, 100) old_scale = ( self._mixer_track.min_volume, self._mixer_track.max_volume) - return utils.rescale(avg_volume, old=old_scale, new=new_scale) + return self._rescale(avg_volume, old=old_scale, new=new_scale) def set_volume(self, volume): """ @@ -341,13 +341,20 @@ class Audio(ThreadingActor): new_scale = ( self._mixer_track.min_volume, self._mixer_track.max_volume) - volume = utils.rescale(volume, old=old_scale, new=new_scale) + volume = self._rescale(volume, old=old_scale, new=new_scale) volumes = (volume,) * self._mixer_track.num_channels self._mixer.set_volume(self._mixer_track, volumes) return self._mixer.get_volume(self._mixer_track) == volumes + def _rescale(self, value, old=None, new=None): + """Convert value between scales.""" + new_min, new_max = new + old_min, old_max = old + scaling = float(new_max - new_min) / (old_max - old_min) + return round(scaling * (value - old_min) + new_min) + def set_metadata(self, track): """ Set track metadata for currently playing song. diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index fbdace15..73b97989 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -3,7 +3,7 @@ import logging logger = logging.getLogger('mopidy.backends.local.translator') from mopidy.models import Track, Artist, Album -from mopidy.utils import locale_decode +from mopidy.utils.encoding import locale_decode from mopidy.utils.path import path_to_uri diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index b90f7c86..167fb1d6 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -5,7 +5,7 @@ from pykka import registry, actor from mopidy import core, settings from mopidy.frontends.mpd import session -from mopidy.utils import locale_decode, network, process +from mopidy.utils import encoding, network, process logger = logging.getLogger('mopidy.frontends.mpd') @@ -37,7 +37,8 @@ class MpdFrontend(actor.ThreadingActor, core.CoreListener): max_connections=settings.MPD_SERVER_MAX_CONNECTIONS) except IOError as error: logger.error( - u'MPD server startup failed: %s', locale_decode(error)) + u'MPD server startup failed: %s', + encoding.locale_decode(error)) sys.exit(1) logger.info(u'MPD server running at [%s]:%s', hostname, port) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index d7ba8cdf..ae51d270 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -5,7 +5,6 @@ from pykka import ActorDeadError from mopidy import settings from mopidy.frontends.mpd import exceptions, protocol -from mopidy.utils import flatten logger = logging.getLogger('mopidy.frontends.mpd.dispatcher') @@ -187,10 +186,19 @@ class MpdDispatcher(object): if result is None: return [] if isinstance(result, set): - return flatten(list(result)) + return self._flatten(list(result)) if not isinstance(result, list): return [result] - return flatten(result) + return self._flatten(result) + + def _flatten(self, the_list): + result = [] + for element in the_list: + if isinstance(element, list): + result.extend(self._flatten(element)) + else: + result.append(element) + return result def _format_lines(self, line): if isinstance(line, dict): diff --git a/mopidy/utils/__init__.py b/mopidy/utils/__init__.py index 839e4f79..e69de29b 100644 --- a/mopidy/utils/__init__.py +++ b/mopidy/utils/__init__.py @@ -1,52 +0,0 @@ -from __future__ import division - -import locale -import logging -import sys - -logger = logging.getLogger('mopidy.utils') - - -# TODO: use itertools.chain.from_iterable(the_list)? -def flatten(the_list): - result = [] - for element in the_list: - if isinstance(element, list): - result.extend(flatten(element)) - else: - result.append(element) - return result - - -def rescale(v, old=None, new=None): - """Convert value between scales.""" - new_min, new_max = new - old_min, old_max = old - scaling = float(new_max - new_min) / (old_max - old_min) - return round(scaling * (v - old_min) + new_min) - - -def import_module(name): - __import__(name) - return sys.modules[name] - - -def get_class(name): - logger.debug('Loading: %s', name) - if '.' not in name: - raise ImportError("Couldn't load: %s" % name) - module_name = name[:name.rindex('.')] - cls_name = name[name.rindex('.') + 1:] - try: - module = import_module(module_name) - cls = getattr(module, cls_name) - except (ImportError, AttributeError): - raise ImportError("Couldn't load: %s" % name) - return cls - - -def locale_decode(bytestr): - try: - return unicode(bytestr) - except UnicodeError: - return str(bytestr).decode(locale.getpreferredencoding()) diff --git a/mopidy/utils/encoding.py b/mopidy/utils/encoding.py new file mode 100644 index 00000000..888896c5 --- /dev/null +++ b/mopidy/utils/encoding.py @@ -0,0 +1,8 @@ +import locale + + +def locale_decode(bytestr): + try: + return unicode(bytestr) + except UnicodeError: + return str(bytestr).decode(locale.getpreferredencoding()) diff --git a/mopidy/utils/importing.py b/mopidy/utils/importing.py new file mode 100644 index 00000000..3df6abe4 --- /dev/null +++ b/mopidy/utils/importing.py @@ -0,0 +1,23 @@ +import logging +import sys + +logger = logging.getLogger('mopidy.utils') + + +def import_module(name): + __import__(name) + return sys.modules[name] + + +def get_class(name): + logger.debug('Loading: %s', name) + if '.' not in name: + raise ImportError("Couldn't load: %s" % name) + module_name = name[:name.rindex('.')] + cls_name = name[name.rindex('.') + 1:] + try: + module = import_module(module_name) + cls = getattr(module, cls_name) + except (ImportError, AttributeError): + raise ImportError("Couldn't load: %s" % name) + return cls diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 2a637c9b..dc303399 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -9,7 +9,7 @@ from pykka import ActorDeadError from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry -from mopidy.utils import locale_decode +from mopidy.utils import encoding logger = logging.getLogger('mopidy.utils.server') @@ -30,7 +30,7 @@ def try_ipv6_socket(): logger.debug( u'Platform supports IPv6, but socket creation failed, ' u'disabling: %s', - locale_decode(error)) + encoding.locale_decode(error)) return False diff --git a/tests/utils/decode_test.py b/tests/utils/encoding_test.py similarity index 90% rename from tests/utils/decode_test.py rename to tests/utils/encoding_test.py index edbfe651..da50d9be 100644 --- a/tests/utils/decode_test.py +++ b/tests/utils/encoding_test.py @@ -1,11 +1,11 @@ import mock -from mopidy.utils import locale_decode +from mopidy.utils.encoding import locale_decode from tests import unittest -@mock.patch('mopidy.utils.locale.getpreferredencoding') +@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/init_test.py b/tests/utils/importing_test.py similarity index 68% rename from tests/utils/init_test.py rename to tests/utils/importing_test.py index bdd0adc5..271f9dbe 100644 --- a/tests/utils/init_test.py +++ b/tests/utils/importing_test.py @@ -1,4 +1,4 @@ -from mopidy import utils +from mopidy.utils import importing from tests import unittest @@ -6,22 +6,22 @@ from tests import unittest class GetClassTest(unittest.TestCase): def test_loading_module_that_does_not_exist(self): with self.assertRaises(ImportError): - utils.get_class('foo.bar.Baz') + importing.get_class('foo.bar.Baz') def test_loading_class_that_does_not_exist(self): with self.assertRaises(ImportError): - utils.get_class('unittest.FooBarBaz') + importing.get_class('unittest.FooBarBaz') def test_loading_incorrect_class_path(self): with self.assertRaises(ImportError): - utils.get_class('foobarbaz') + importing.get_class('foobarbaz') def test_import_error_message_contains_complete_class_path(self): try: - utils.get_class('foo.bar.Baz') + importing.get_class('foo.bar.Baz') except ImportError as e: self.assertIn('foo.bar.Baz', str(e)) def test_loading_existing_class(self): - cls = utils.get_class('unittest.TestCase') + cls = importing.get_class('unittest.TestCase') self.assertEqual(cls.__name__, 'TestCase') From 986c0a9ad37b1249bb7cd38447d3ddda831a98d9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 21:45:36 +0200 Subject: [PATCH 089/233] Move get_version() helper to mopidy.utils.versioning --- docs/conf.py | 2 +- mopidy/__init__.py | 18 ------------------ mopidy/__main__.py | 6 ++++-- mopidy/backends/spotify/session_manager.py | 10 +++++----- mopidy/utils/log.py | 5 +++-- mopidy/utils/versioning.py | 20 ++++++++++++++++++++ 6 files changed, 33 insertions(+), 28 deletions(-) create mode 100644 mopidy/utils/versioning.py diff --git a/docs/conf.py b/docs/conf.py index e37f5713..d02303df 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -97,7 +97,7 @@ copyright = u'2010-2012, Stein Magnus Jodal and contributors' # built documents. # # The full version, including alpha/beta/rc tags. -from mopidy import get_version +from mopidy.utils.versioning import get_version release = get_version() # The short X.Y version. version = '.'.join(release.split('.')[:2]) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index ec2f4147..dc782db9 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -5,7 +5,6 @@ if not (2, 6) <= sys.version_info < (3,): from distutils.version import StrictVersion import os import platform -from subprocess import PIPE, Popen import glib @@ -21,23 +20,6 @@ SETTINGS_PATH = os.path.join(str(glib.get_user_config_dir()), 'mopidy') SETTINGS_FILE = os.path.join(SETTINGS_PATH, 'settings.py') -def get_version(): - try: - return get_git_version() - except EnvironmentError: - return __version__ - - -def get_git_version(): - process = Popen(['git', 'describe'], stdout=PIPE, stderr=PIPE) - if process.wait() != 0: - raise EnvironmentError('Execution of "git describe" failed') - version = process.stdout.read().strip() - if version.startswith('v'): - version = version[1:] - return version - - def get_platform(): return platform.platform() diff --git a/mopidy/__main__.py b/mopidy/__main__.py index aa108f2c..e712fc63 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -31,7 +31,8 @@ sys.path.insert( import mopidy from mopidy import audio, core, exceptions, settings from mopidy.utils import ( - deps, importing, log, path, process, settings as settings_utils) + deps, importing, log, path, process, settings as settings_utils, + versioning) logger = logging.getLogger('mopidy.main') @@ -66,7 +67,8 @@ def main(): def parse_options(): - parser = optparse.OptionParser(version=u'Mopidy %s' % mopidy.get_version()) + parser = optparse.OptionParser( + version=u'Mopidy %s' % versioning.get_version()) parser.add_option( '--help-gst', action='store_true', dest='help_gst', diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 99859abd..2ca7d673 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -4,13 +4,13 @@ import threading from spotify.manager import SpotifySessionManager as PyspotifySessionManager -from mopidy import get_version, settings +from mopidy import settings from mopidy.backends.spotify import BITRATES from mopidy.backends.spotify.container_manager import SpotifyContainerManager from mopidy.backends.spotify.playlist_manager import SpotifyPlaylistManager from mopidy.backends.spotify.translator import SpotifyTranslator from mopidy.models import Playlist -from mopidy.utils.process import BaseThread +from mopidy.utils import process, versioning logger = logging.getLogger('mopidy.backends.spotify.session_manager') @@ -18,15 +18,15 @@ logger = logging.getLogger('mopidy.backends.spotify.session_manager') # SpotifySessionManager: Too many ancestors (9/7) -class SpotifySessionManager(BaseThread, PyspotifySessionManager): +class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): cache_location = settings.SPOTIFY_CACHE_PATH settings_location = cache_location appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key') - user_agent = 'Mopidy %s' % get_version() + user_agent = 'Mopidy %s' % versioning.get_version() def __init__(self, username, password, audio, backend_ref): PyspotifySessionManager.__init__(self, username, password) - BaseThread.__init__(self) + process.BaseThread.__init__(self) self.name = 'SpotifyThread' self.audio = audio diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 9b9495d5..d5c9a14d 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -1,7 +1,8 @@ import logging import logging.handlers -from mopidy import get_version, get_platform, get_python, settings +from mopidy import get_platform, get_python, settings +from . import versioning def setup_logging(verbosity_level, save_debug_log): @@ -10,7 +11,7 @@ def setup_logging(verbosity_level, save_debug_log): if save_debug_log: setup_debug_logging_to_file() logger = logging.getLogger('mopidy.utils.log') - logger.info(u'Starting Mopidy %s', get_version()) + logger.info(u'Starting Mopidy %s', versioning.get_version()) logger.info(u'Platform: %s', get_platform()) logger.info(u'Python: %s', get_python()) diff --git a/mopidy/utils/versioning.py b/mopidy/utils/versioning.py new file mode 100644 index 00000000..b25761e9 --- /dev/null +++ b/mopidy/utils/versioning.py @@ -0,0 +1,20 @@ +from subprocess import PIPE, Popen + +from mopidy import __version__ + + +def get_version(): + try: + return get_git_version() + except EnvironmentError: + return __version__ + + +def get_git_version(): + process = Popen(['git', 'describe'], stdout=PIPE, stderr=PIPE) + if process.wait() != 0: + raise EnvironmentError('Execution of "git describe" failed') + version = process.stdout.read().strip() + if version.startswith('v'): + version = version[1:] + return version From 074fb431bf4f687f05c1877fc4ebf148a13d3b6a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 22:09:40 +0200 Subject: [PATCH 090/233] Move Pykka version check to startup, to unbreak docs building --- mopidy/__init__.py | 11 +++-------- mopidy/__main__.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index dc782db9..83607be6 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,17 +1,12 @@ +import os +import platform import sys + if not (2, 6) <= sys.version_info < (3,): sys.exit(u'Mopidy requires Python >= 2.6, < 3') -from distutils.version import StrictVersion -import os -import platform - import glib -import pykka -if StrictVersion(pykka.__version__) < StrictVersion('0.16'): - sys.exit(u'Mopidy requires Pykka >= 0.16') - __version__ = '0.8.0' DATA_PATH = os.path.join(str(glib.get_user_data_dir()), 'mopidy') diff --git a/mopidy/__main__.py b/mopidy/__main__.py index e712fc63..ba175ceb 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,3 +1,4 @@ +from distutils.version import StrictVersion import logging import optparse import os @@ -7,6 +8,8 @@ import sys import gobject gobject.threads_init() +import pykka + # Extract any non-GStreamer arguments, and leave the GStreamer arguments for # processing by GStreamer. This needs to be done before GStreamer is imported, @@ -39,6 +42,7 @@ logger = logging.getLogger('mopidy.main') def main(): + check_dependencies() signal.signal(signal.SIGTERM, process.exit_handler) loop = gobject.MainLoop() options = parse_options() @@ -66,6 +70,14 @@ def main(): process.stop_remaining_actors() +def check_dependencies(): + pykka_required = '0.16' + if StrictVersion(pykka.__version__) < StrictVersion(pykka_required): + sys.exit( + u'Mopidy requires Pykka >= %s, but found %s' % + (pykka_required, pykka.__version__)) + + def parse_options(): parser = optparse.OptionParser( version=u'Mopidy %s' % versioning.get_version()) From e9e5330a14a8a6b37c04c31f5cf35005dcdee256 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 22:14:04 +0200 Subject: [PATCH 091/233] Turn off creation of nosetests.xml report by default This is only needed by the Jenkins CI server, and our builds there have been updated to pass --with-xunit explicitly. --- setup.cfg | 2 -- 1 file changed, 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index e09a7b15..bce0a6e2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,8 +1,6 @@ [nosetests] verbosity = 1 -#with-doctest = 1 #with-coverage = 1 cover-package = mopidy cover-inclusive = 1 cover-html = 1 -with-xunit = 1 From 479ab249bb46a087dc4df844d7ee66a40aca9be2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 22:24:11 +0200 Subject: [PATCH 092/233] Move mopidy.utils.{log => formatting}.indent to break import cycle --- mopidy/frontends/mpd/session.py | 4 ++-- mopidy/utils/deps.py | 4 ++-- mopidy/utils/formatting.py | 8 ++++++++ mopidy/utils/log.py | 10 ---------- mopidy/utils/settings.py | 9 ++++----- 5 files changed, 16 insertions(+), 19 deletions(-) create mode 100644 mopidy/utils/formatting.py diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py index b4531c83..b5368a08 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/frontends/mpd/session.py @@ -1,7 +1,7 @@ import logging from mopidy.frontends.mpd import dispatcher, protocol -from mopidy.utils import log, network +from mopidy.utils import formatting, network logger = logging.getLogger('mopidy.frontends.mpd') @@ -36,7 +36,7 @@ class MpdSession(network.LineProtocol): logger.debug( u'Response to [%s]:%s from %s: %s', self.host, self.port, self.actor_urn, - log.indent(self.terminator.join(response))) + formatting.indent(self.terminator.join(response))) self.send_lines(response) diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index d72f1392..32949f55 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -8,7 +8,7 @@ import gst import pykka -from mopidy.utils.log import indent +from . import formatting def list_deps_optparse_callback(*args): @@ -47,7 +47,7 @@ def format_dependency_list(adapters=None): os.path.dirname(dep_info['path']))) if 'other' in dep_info: lines.append(' Other: %s' % ( - indent(dep_info['other'])),) + formatting.indent(dep_info['other'])),) return '\n'.join(lines) diff --git a/mopidy/utils/formatting.py b/mopidy/utils/formatting.py new file mode 100644 index 00000000..46459959 --- /dev/null +++ b/mopidy/utils/formatting.py @@ -0,0 +1,8 @@ +def indent(string, places=4, linebreak='\n'): + lines = string.split(linebreak) + if len(lines) == 1: + return string + result = u'' + for line in lines: + result += linebreak + ' ' * places + line + return result diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index d5c9a14d..93f17c92 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -50,13 +50,3 @@ def setup_debug_logging_to_file(): handler.setLevel(logging.DEBUG) root = logging.getLogger('') root.addHandler(handler) - - -def indent(string, places=4, linebreak='\n'): - lines = string.split(linebreak) - if len(lines) == 1: - return string - result = u'' - for line in lines: - result += linebreak + ' ' * places + line - return result diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 39d613b3..6d868d39 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -9,8 +9,7 @@ import pprint import sys from mopidy import exceptions, SETTINGS_PATH, SETTINGS_FILE -from mopidy.utils import log -from mopidy.utils import path +from mopidy.utils import formatting, path logger = logging.getLogger('mopidy.utils.settings') @@ -76,7 +75,7 @@ class SettingsProxy(object): if self.get_errors(): logger.error( u'Settings validation errors: %s', - log.indent(self.get_errors_as_string())) + formatting.indent(self.get_errors_as_string())) raise exceptions.SettingsError(u'Settings validation failed.') def _read_missing_settings_from_stdin(self, current, runtime): @@ -203,11 +202,11 @@ def format_settings_list(settings): default_value = settings.default.get(key) masked_value = mask_value_if_secret(key, value) lines.append(u'%s: %s' % ( - key, log.indent(pprint.pformat(masked_value), places=2))) + key, formatting.indent(pprint.pformat(masked_value), places=2))) if value != default_value and default_value is not None: lines.append( u' Default: %s' % - log.indent(pprint.pformat(default_value), places=4)) + formatting.indent(pprint.pformat(default_value), places=4)) if errors.get(key) is not None: lines.append(u' Error: %s' % errors[key]) return '\n'.join(lines) From 5fc77be76ea26f4552b2c58e36487a1e4cb27895 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 22:25:37 +0200 Subject: [PATCH 093/233] Update path in comment --- mopidy/utils/settings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 6d868d39..a886a90c 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -1,4 +1,5 @@ -# Absolute import needed to import ~/.mopidy/settings.py and not ourselves +# Absolute import needed to import ~/.config/mopidy/settings.py and not +# ourselves from __future__ import absolute_import import copy From afdc665ac089a53c803324fadf5802568a1d1dcb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 22:26:28 +0200 Subject: [PATCH 094/233] Use deps.{platform_info,python_info} in log --- mopidy/__init__.py | 12 ------------ mopidy/utils/log.py | 8 ++++---- tests/version_test.py | 12 +----------- 3 files changed, 5 insertions(+), 27 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 83607be6..76aca226 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,5 +1,4 @@ import os -import platform import sys if not (2, 6) <= sys.version_info < (3,): @@ -14,17 +13,6 @@ CACHE_PATH = os.path.join(str(glib.get_user_cache_dir()), 'mopidy') SETTINGS_PATH = os.path.join(str(glib.get_user_config_dir()), 'mopidy') SETTINGS_FILE = os.path.join(SETTINGS_PATH, 'settings.py') - -def get_platform(): - return platform.platform() - - -def get_python(): - implementation = platform.python_implementation() - version = platform.python_version() - return u' '.join([implementation, version]) - - from mopidy import settings as default_settings_module from mopidy.utils.settings import SettingsProxy settings = SettingsProxy(default_settings_module) diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 93f17c92..3421746d 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -1,8 +1,8 @@ import logging import logging.handlers -from mopidy import get_platform, get_python, settings -from . import versioning +from mopidy import settings +from . import deps, versioning def setup_logging(verbosity_level, save_debug_log): @@ -12,8 +12,8 @@ def setup_logging(verbosity_level, save_debug_log): setup_debug_logging_to_file() logger = logging.getLogger('mopidy.utils.log') logger.info(u'Starting Mopidy %s', versioning.get_version()) - logger.info(u'Platform: %s', get_platform()) - logger.info(u'Python: %s', get_python()) + logger.info(u'%(name)s: %(version)s', deps.platform_info()) + logger.info(u'%(name)s: %(version)s', deps.python_info()) def setup_root_logger(): diff --git a/tests/version_test.py b/tests/version_test.py index 678dc221..004abab7 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -1,7 +1,6 @@ from distutils.version import StrictVersion as SV -import platform -from mopidy import __version__, get_platform, get_python +from mopidy import __version__ from tests import unittest @@ -30,12 +29,3 @@ class VersionTest(unittest.TestCase): self.assertLess(SV('0.7.2'), SV('0.7.3')) self.assertLess(SV('0.7.3'), SV(__version__)) self.assertLess(SV(__version__), SV('0.8.1')) - - def test_get_platform_contains_platform(self): - self.assertIn(platform.platform(), get_platform()) - - def test_get_python_contains_python_implementation(self): - self.assertIn(platform.python_implementation(), get_python()) - - def test_get_python_contains_python_version(self): - self.assertIn(platform.python_version(), get_python()) From b8d637e1f53c2e40c23be85e5a602bf03e61d77b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 00:21:24 +0200 Subject: [PATCH 095/233] Move DATA_PATH, SETTINGS_PATH, and SETTINGS_FILE to mopidy.utils.path --- mopidy/__init__.py | 9 --------- mopidy/__main__.py | 9 ++++----- mopidy/utils/path.py | 6 +++++- mopidy/utils/settings.py | 6 +++--- 4 files changed, 12 insertions(+), 18 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 76aca226..0b0be1a6 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,18 +1,9 @@ -import os import sys - if not (2, 6) <= sys.version_info < (3,): sys.exit(u'Mopidy requires Python >= 2.6, < 3') -import glib - __version__ = '0.8.0' -DATA_PATH = os.path.join(str(glib.get_user_data_dir()), 'mopidy') -CACHE_PATH = os.path.join(str(glib.get_user_cache_dir()), 'mopidy') -SETTINGS_PATH = os.path.join(str(glib.get_user_config_dir()), 'mopidy') -SETTINGS_FILE = os.path.join(SETTINGS_PATH, 'settings.py') - from mopidy import settings as default_settings_module from mopidy.utils.settings import SettingsProxy settings = SettingsProxy(default_settings_module) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index ba175ceb..719e8e24 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -31,7 +31,6 @@ sys.path.insert( 0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) -import mopidy from mopidy import audio, core, exceptions, settings from mopidy.utils import ( deps, importing, log, path, process, settings as settings_utils, @@ -122,13 +121,13 @@ def check_old_folders(): logger.warning( u'Old settings folder found at %s, settings.py should be moved ' u'to %s, any cache data should be deleted. See release notes for ' - u'further instructions.', old_settings_folder, mopidy.SETTINGS_PATH) + u'further instructions.', old_settings_folder, path.SETTINGS_PATH) def setup_settings(interactive): - path.get_or_create_folder(mopidy.SETTINGS_PATH) - path.get_or_create_folder(mopidy.DATA_PATH) - path.get_or_create_file(mopidy.SETTINGS_FILE) + path.get_or_create_folder(path.SETTINGS_PATH) + path.get_or_create_folder(path.DATA_PATH) + path.get_or_create_file(path.SETTINGS_FILE) try: settings.validate(interactive) except exceptions.SettingsError as ex: diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 0cf02a4a..220d6775 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -1,4 +1,3 @@ -import glib import logging import os import re @@ -6,8 +5,13 @@ import string import sys import urllib +import glib + logger = logging.getLogger('mopidy.utils.path') +DATA_PATH = os.path.join(str(glib.get_user_data_dir()), 'mopidy') +SETTINGS_PATH = os.path.join(str(glib.get_user_config_dir()), 'mopidy') +SETTINGS_FILE = os.path.join(SETTINGS_PATH, 'settings.py') XDG_DIRS = { 'XDG_CACHE_DIR': glib.get_user_cache_dir(), 'XDG_DATA_DIR': glib.get_user_data_dir(), diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index a886a90c..be0e4420 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -9,7 +9,7 @@ import os import pprint import sys -from mopidy import exceptions, SETTINGS_PATH, SETTINGS_FILE +from mopidy import exceptions from mopidy.utils import formatting, path logger = logging.getLogger('mopidy.utils.settings') @@ -23,9 +23,9 @@ class SettingsProxy(object): self.runtime = {} def _get_local_settings(self): - if not os.path.isfile(SETTINGS_FILE): + if not os.path.isfile(path.SETTINGS_FILE): return {} - sys.path.insert(0, SETTINGS_PATH) + sys.path.insert(0, path.SETTINGS_PATH) # pylint: disable = F0401 import settings as local_settings_module # pylint: enable = F0401 From ad78aba3fd1902506e21ac0bf036a9a42c44baa1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 00:50:29 +0200 Subject: [PATCH 096/233] Fix shadowing of imports --- mopidy/__main__.py | 20 +++++++++++--------- mopidy/core/actor.py | 4 ++-- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 719e8e24..30e71b60 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -31,7 +31,9 @@ sys.path.insert( 0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) -from mopidy import audio, core, exceptions, settings +from mopidy import exceptions, settings +from mopidy.audio import Audio +from mopidy.core import Core from mopidy.utils import ( deps, importing, log, path, process, settings as settings_utils, versioning) @@ -49,10 +51,10 @@ def main(): log.setup_logging(options.verbosity_level, options.save_debug_log) check_old_folders() setup_settings(options.interactive) - audio_ref = setup_audio() - backend_ref = setup_backend(audio_ref) - core_ref = setup_core(audio_ref, backend_ref) - setup_frontends(core_ref) + audio = setup_audio() + backend = setup_backend(audio) + core = setup_core(audio, backend) + setup_frontends(core) loop.run() except exceptions.SettingsError as ex: logger.error(ex.message) @@ -136,11 +138,11 @@ def setup_settings(interactive): def setup_audio(): - return audio.Audio.start().proxy() + return Audio.start().proxy() def stop_audio(): - process.stop_actors_by_class(audio.Audio) + process.stop_actors_by_class(Audio) def setup_backend(audio): @@ -152,11 +154,11 @@ def stop_backend(): def setup_core(audio, backend): - return core.Core.start(audio=audio, backend=backend).proxy() + return Core.start(audio=audio, backend=backend).proxy() def stop_core(): - process.stop_actors_by_class(core.Core) + process.stop_actors_by_class(Core) def setup_frontends(core): diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 4ec86e8b..aded0774 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -1,6 +1,6 @@ from pykka.actor import ThreadingActor -from mopidy import audio +from mopidy.audio import AudioListener from .current_playlist import CurrentPlaylistController from .library import LibraryController @@ -8,7 +8,7 @@ from .playback import PlaybackController from .stored_playlists import StoredPlaylistsController -class Core(ThreadingActor, audio.AudioListener): +class Core(ThreadingActor, AudioListener): #: The current playlist controller. An instance of #: :class:`mopidy.core.CurrentPlaylistController`. current_playlist = None From 01d7e3bd31bc296188bb0f45c3eb1fcc9e04712c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 01:03:16 +0200 Subject: [PATCH 097/233] Remove pylint ignores not needed with pylint 0.26 --- pylintrc | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pylintrc b/pylintrc index 98e10416..db821a5b 100644 --- a/pylintrc +++ b/pylintrc @@ -5,12 +5,6 @@ # # C0103 - Invalid name "%s" (should match %s) # C0111 - Missing docstring -# E0102 - %s already defined line %s -# Does not understand @property getters and setters -# E0202 - An attribute inherited from %s hide this method -# Does not understand @property getters and setters -# E1101 - %s %r has no %r member -# Does not understand @property getters and setters # R0201 - Method could be a function # R0801 - Similar lines in %s files # R0903 - Too few public methods (%s/%s) @@ -21,4 +15,4 @@ # W0511 - TODO, FIXME and XXX in the code # W0613 - Unused argument %r # -disable = C0103,C0111,E0102,E0202,E1101,R0201,R0801,R0903,R0904,R0921,W0141,W0142,W0511,W0613 +disable = C0103,C0111,R0201,R0801,R0903,R0904,R0921,W0141,W0142,W0511,W0613 From dbea615a105b1d7e86a5bed41f1f0280b50b44a7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 00:56:49 +0200 Subject: [PATCH 098/233] Fix shadowing of imports (#211) --- mopidy/frontends/lastfm.py | 5 +++-- mopidy/frontends/mpd/actor.py | 5 +++-- mopidy/frontends/mpris/actor.py | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 70c6c8e4..45c2db16 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -3,7 +3,8 @@ import time from pykka.actor import ThreadingActor -from mopidy import core, exceptions, settings +from mopidy import exceptions, settings +from mopidy.core import CoreListener try: import pylast @@ -16,7 +17,7 @@ API_KEY = '2236babefa8ebb3d93ea467560d00d04' API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd' -class LastfmFrontend(ThreadingActor, core.CoreListener): +class LastfmFrontend(ThreadingActor, CoreListener): """ Frontend which scrobbles the music you play to your `Last.fm `_ profile. diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index 167fb1d6..d7a20158 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -3,14 +3,15 @@ import sys from pykka import registry, actor -from mopidy import core, settings +from mopidy import settings +from mopidy.core import CoreListener from mopidy.frontends.mpd import session from mopidy.utils import encoding, network, process logger = logging.getLogger('mopidy.frontends.mpd') -class MpdFrontend(actor.ThreadingActor, core.CoreListener): +class MpdFrontend(actor.ThreadingActor, CoreListener): """ The MPD frontend. diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py index cbfb2cc9..e3199ac3 100644 --- a/mopidy/frontends/mpris/actor.py +++ b/mopidy/frontends/mpris/actor.py @@ -2,7 +2,8 @@ import logging from pykka.actor import ThreadingActor -from mopidy import core, settings +from mopidy import settings +from mopidy.core import CoreListener from mopidy.frontends.mpris import objects logger = logging.getLogger('mopidy.frontends.mpris') @@ -14,7 +15,7 @@ except ImportError as import_error: logger.debug(u'Startup notification will not be sent (%s)', import_error) -class MprisFrontend(ThreadingActor, core.CoreListener): +class MprisFrontend(ThreadingActor, CoreListener): """ Frontend which lets you control Mopidy through the Media Player Remote Interfacing Specification (`MPRIS `_) D-Bus From 7d76f1d214a672c8734a2e491755f5125ca59548 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 01:01:57 +0200 Subject: [PATCH 099/233] Fix unused variables (#211) --- mopidy/frontends/mpris/objects.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 74c85617..4d4efe1e 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -39,7 +39,7 @@ class MprisObject(dbus.service.Object): PLAYER_IFACE: self._get_player_iface_properties(), } bus_name = self._connect_to_dbus() - super(MprisObject, self).__init__(bus_name, OBJECT_PATH) + dbus.service.Object.__init__(self, bus_name, OBJECT_PATH) def _get_root_iface_properties(self): return { @@ -97,7 +97,7 @@ class MprisObject(dbus.service.Object): logger.debug( u'%s.Get(%s, %s) called', dbus.PROPERTIES_IFACE, repr(interface), repr(prop)) - (getter, setter) = self.properties[interface][prop] + (getter, _) = self.properties[interface][prop] if callable(getter): return getter() else: @@ -109,7 +109,7 @@ class MprisObject(dbus.service.Object): logger.debug( u'%s.GetAll(%s) called', dbus.PROPERTIES_IFACE, repr(interface)) getters = {} - for key, (getter, setter) in self.properties[interface].iteritems(): + for key, (getter, _) in self.properties[interface].iteritems(): getters[key] = getter() if callable(getter) else getter return getters @@ -119,7 +119,7 @@ class MprisObject(dbus.service.Object): logger.debug( u'%s.Set(%s, %s, %s) called', dbus.PROPERTIES_IFACE, repr(interface), repr(prop), repr(value)) - getter, setter = self.properties[interface][prop] + _, setter = self.properties[interface][prop] if setter is not None: setter(value) self.PropertiesChanged( @@ -332,7 +332,7 @@ class MprisObject(dbus.service.Object): if current_cp_track is None: return {'mpris:trackid': ''} else: - (cpid, track) = current_cp_track + (_, track) = current_cp_track metadata = {'mpris:trackid': self._get_track_id(current_cp_track)} if track.length: metadata['mpris:length'] = track.length * 1000 From 8042f9961ae4e0c56688e3e800589d0d926778e3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 01:06:18 +0200 Subject: [PATCH 100/233] Mark strings with backslashes as raw strings (#211) --- mopidy/backends/local/translator.py | 2 +- mopidy/utils/network.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 73b97989..5a4a238b 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -8,7 +8,7 @@ from mopidy.utils.path import path_to_uri def parse_m3u(file_path, music_folder): - """ + r""" Convert M3U file list of uris Example M3U data:: diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index dc303399..b8914614 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -52,7 +52,7 @@ def create_socket(): def format_hostname(hostname): """Format hostname for display.""" - if (has_ipv6 and re.match('\d+.\d+.\d+.\d+', hostname) is not None): + if (has_ipv6 and re.match(r'\d+.\d+.\d+.\d+', hostname) is not None): hostname = '::ffff:%s' % hostname return hostname From 928851764a316a798862ed699c6a46808fd87878 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 01:13:34 +0200 Subject: [PATCH 101/233] Ignore select pylint refactoring recommendations --- pylintrc | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pylintrc b/pylintrc index db821a5b..41e1ab5d 100644 --- a/pylintrc +++ b/pylintrc @@ -7,12 +7,15 @@ # C0111 - Missing docstring # R0201 - Method could be a function # R0801 - Similar lines in %s files +# R0902 - Too many instance attributes (%s/%s) # R0903 - Too few public methods (%s/%s) # R0904 - Too many public methods (%s/%s) +# R0912 - Too many branches (%s/%s) +# R0913 - Too many arguments (%s/%s) # R0921 - Abstract class not referenced # W0141 - Used builtin function '%s' # W0142 - Used * or ** magic # W0511 - TODO, FIXME and XXX in the code # W0613 - Unused argument %r # -disable = C0103,C0111,R0201,R0801,R0903,R0904,R0921,W0141,W0142,W0511,W0613 +disable = C0103,C0111,R0201,R0801,R0902,R0903,R0904,R0912,R0913,R0921,W0141,W0142,W0511,W0613 From 39d0bfa1247a208a2c374f8408eaa813d23c5f16 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 01:18:50 +0200 Subject: [PATCH 102/233] Ensure that superclasses' __init__ are called (#211) --- mopidy/audio/mixers/fake.py | 3 --- mopidy/audio/mixers/nad.py | 6 ++---- mopidy/backends/spotify/library.py | 1 + 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py index 3c85cc34..b22e731e 100644 --- a/mopidy/audio/mixers/fake.py +++ b/mopidy/audio/mixers/fake.py @@ -21,9 +21,6 @@ class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): track_flags = gobject.property(type=int, default=( gst.interfaces.MIXER_TRACK_MASTER | gst.interfaces.MIXER_TRACK_OUTPUT)) - def __init__(self): - gst.Element.__init__(self) - def list_tracks(self): track = utils.create_track( self.track_label, diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index fc456a2b..72bede82 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -30,10 +30,8 @@ class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): speakers_a = gobject.property(type=str) speakers_b = gobject.property(type=str) - def __init__(self): - gst.Element.__init__(self) - self._volume_cache = 0 - self._nad_talker = None + _volume_cache = 0 + _nad_talker = None def list_tracks(self): track = utils.create_track( diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 8519a650..b254519e 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -13,6 +13,7 @@ logger = logging.getLogger('mopidy.backends.spotify.library') class SpotifyTrack(Track): """Proxy object for unloaded Spotify tracks.""" def __init__(self, uri): + super(SpotifyTrack, self).__init__() self._spotify_track = Link.from_string(uri).as_track() self._unloaded_track = Track(uri=uri, name=u'[loading...]') self._track = None From 8f1f0bc82abe3bdaf91873aab5df0090f308ef5f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 01:31:20 +0200 Subject: [PATCH 103/233] Create attribute in __init__ (#211) --- mopidy/backends/spotify/session_manager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 2ca7d673..caa777e1 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -30,6 +30,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): self.name = 'SpotifyThread' self.audio = audio + self.backend = None self.backend_ref = backend_ref self.connected = threading.Event() From 0c9452d9d3a5a1d6d5060da16ba25491c26f00f8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 01:31:41 +0200 Subject: [PATCH 104/233] Remove unused argument shadowing builtin (#211) --- mopidy/utils/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index be0e4420..d6c5d644 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -234,7 +234,7 @@ def did_you_mean(setting, defaults): return None -def levenshtein(a, b, max=3): +def levenshtein(a, b): """Calculates the Levenshtein distance between a and b.""" n, m = len(a), len(b) if n > m: From 65b550eb4413361952197776af4d718d604b2ce8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 01:42:58 +0200 Subject: [PATCH 105/233] Ignore invalid pylint warnings (#211) --- mopidy/__main__.py | 2 ++ mopidy/audio/mixers/auto.py | 2 ++ mopidy/frontends/mpd/protocol/__init__.py | 4 ++-- mopidy/utils/path.py | 2 ++ mopidy/utils/versioning.py | 2 ++ 5 files changed, 10 insertions(+), 2 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 30e71b60..97c2a010 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,4 +1,6 @@ +# pylint: disable = E0611,F0401 from distutils.version import StrictVersion +# pylint: enable = E0611,F0401 import logging import optparse import os diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py index 45806040..f3806eef 100644 --- a/mopidy/audio/mixers/auto.py +++ b/mopidy/audio/mixers/auto.py @@ -19,7 +19,9 @@ class AutoAudioMixer(gst.Bin): gst.Bin.__init__(self) mixer = self._find_mixer() if mixer: + # pylint: disable=E1101 self.add(mixer) + # pylint: enable=E1101 logger.debug('AutoAudioMixer chose: %s', mixer.get_name()) else: logger.debug('AutoAudioMixer did not find any usable mixers') diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index 66c8a84a..968a7dac 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -69,8 +69,8 @@ def load_protocol_modules(): The protocol modules must be imported to get them registered in :attr:`request_handlers` and :attr:`mpd_commands`. """ - # pylint: disable = W0611 + # pylint: disable = W0612 from . import ( # noqa audio_output, command_list, connection, current_playlist, empty, music_db, playback, reflection, status, stickers, stored_playlists) - # pylint: enable = W0611 + # pylint: enable = W0612 diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 220d6775..eef0c2db 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -1,7 +1,9 @@ import logging import os import re +# pylint: disable = W0402 import string +# pylint: enable = W0402 import sys import urllib diff --git a/mopidy/utils/versioning.py b/mopidy/utils/versioning.py index b25761e9..8e7d55bd 100644 --- a/mopidy/utils/versioning.py +++ b/mopidy/utils/versioning.py @@ -12,9 +12,11 @@ def get_version(): def get_git_version(): process = Popen(['git', 'describe'], stdout=PIPE, stderr=PIPE) + # pylint: disable = E1101 if process.wait() != 0: raise EnvironmentError('Execution of "git describe" failed') version = process.stdout.read().strip() + # pylint: enable = E1101 if version.startswith('v'): version = version[1:] return version From 8683537816f380234f105e8da8f5c6fe6aac5da5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 01:43:22 +0200 Subject: [PATCH 106/233] Don't use command_list as both bool and list (#211) --- mopidy/frontends/mpd/dispatcher.py | 6 +++--- mopidy/frontends/mpd/protocol/command_list.py | 12 +++++++----- .../mpd/protocol/command_list_test.py | 19 ++++++++++++++----- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index ae51d270..6f91c491 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -22,8 +22,9 @@ class MpdDispatcher(object): def __init__(self, session=None, core=None): self.authenticated = False - self.command_list = False + self.command_list_receiving = False self.command_list_ok = False + self.command_list = [] self.command_list_index = None self.context = MpdContext(self, session=session, core=core) @@ -108,8 +109,7 @@ class MpdDispatcher(object): def _is_receiving_command_list(self, request): return ( - self.command_list is not False and - request != u'command_list_end') + self.command_list_receiving and request != u'command_list_end') def _is_processing_command_list(self, request): return ( diff --git a/mopidy/frontends/mpd/protocol/command_list.py b/mopidy/frontends/mpd/protocol/command_list.py index a58c11e2..d422f97e 100644 --- a/mopidy/frontends/mpd/protocol/command_list.py +++ b/mopidy/frontends/mpd/protocol/command_list.py @@ -19,18 +19,19 @@ def command_list_begin(context): returned. If ``command_list_ok_begin`` is used, ``list_OK`` is returned for each successful command executed in the command list. """ - context.dispatcher.command_list = [] + context.dispatcher.command_list_receiving = True context.dispatcher.command_list_ok = False + context.dispatcher.command_list = [] @handle_request(r'^command_list_end$') def command_list_end(context): """See :meth:`command_list_begin()`.""" - if context.dispatcher.command_list is False: - # Test for False exactly, and not e.g. empty list + if not context.dispatcher.command_list_receiving: raise MpdUnknownCommand(command='command_list_end') + context.dispatcher.command_list_receiving = False (command_list, context.dispatcher.command_list) = ( - context.dispatcher.command_list, False) + context.dispatcher.command_list, []) (command_list_ok, context.dispatcher.command_list_ok) = ( context.dispatcher.command_list_ok, False) command_list_response = [] @@ -49,5 +50,6 @@ def command_list_end(context): @handle_request(r'^command_list_ok_begin$') def command_list_ok_begin(context): """See :meth:`command_list_begin()`.""" - context.dispatcher.command_list = [] + context.dispatcher.command_list_receiving = True context.dispatcher.command_list_ok = True + context.dispatcher.command_list = [] diff --git a/tests/frontends/mpd/protocol/command_list_test.py b/tests/frontends/mpd/protocol/command_list_test.py index 64ef8688..dbd7f9c9 100644 --- a/tests/frontends/mpd/protocol/command_list_test.py +++ b/tests/frontends/mpd/protocol/command_list_test.py @@ -18,13 +18,18 @@ class CommandListsTest(protocol.BaseTestCase): def test_command_list_with_ping(self): self.sendRequest(u'command_list_begin') + self.assertTrue(self.dispatcher.command_list_receiving) + self.assertFalse(self.dispatcher.command_list_ok) self.assertEqual([], self.dispatcher.command_list) - self.assertEqual(False, self.dispatcher.command_list_ok) + self.sendRequest(u'ping') self.assertIn(u'ping', self.dispatcher.command_list) + self.sendRequest(u'command_list_end') self.assertInResponse(u'OK') - self.assertEqual(False, self.dispatcher.command_list) + self.assertFalse(self.dispatcher.command_list_receiving) + self.assertFalse(self.dispatcher.command_list_ok) + self.assertEqual([], self.dispatcher.command_list) def test_command_list_with_error_returns_ack_with_correct_index(self): self.sendRequest(u'command_list_begin') @@ -39,15 +44,19 @@ class CommandListsTest(protocol.BaseTestCase): def test_command_list_ok_with_ping(self): self.sendRequest(u'command_list_ok_begin') + self.assertTrue(self.dispatcher.command_list_receiving) + self.assertTrue(self.dispatcher.command_list_ok) self.assertEqual([], self.dispatcher.command_list) - self.assertEqual(True, self.dispatcher.command_list_ok) + self.sendRequest(u'ping') self.assertIn(u'ping', self.dispatcher.command_list) + self.sendRequest(u'command_list_end') self.assertInResponse(u'list_OK') self.assertInResponse(u'OK') - self.assertEqual(False, self.dispatcher.command_list) - self.assertEqual(False, self.dispatcher.command_list_ok) + self.assertFalse(self.dispatcher.command_list_receiving) + self.assertFalse(self.dispatcher.command_list_ok) + self.assertEqual([], self.dispatcher.command_list) # FIXME this should also include the special handling of idle within a # command list. That is that once a idle/noidle command is found inside a From 893efe426f16fa5ff5147e4752c87f4da524032d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 18 Oct 2012 12:11:42 +0200 Subject: [PATCH 107/233] Ignore pylint warning (#211) Caused by helper function given access to class internals --- mopidy/core/playback.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 90e7e639..d2411738 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -13,7 +13,9 @@ def option_wrapper(name, default): def set_option(self, value): if getattr(self, name, default) != value: + # pylint: disable = W0212 self._trigger_options_changed() + # pylint: enable = W0212 return setattr(self, name, value) return property(get_option, set_option) From 9144c28483a434d298f232dccbdda0160444bd86 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 24 Oct 2012 22:35:36 +0200 Subject: [PATCH 108/233] Fix 'not-negotiated' errors on some Spotify tracks (fixes #213) --- docs/changes.rst | 6 ++++++ mopidy/audio/actor.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 17d50072..854c90d3 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -12,6 +12,12 @@ v0.9.0 (in development) - Pykka >= 0.16 is now required. +**Bug fixes** + +- :issue:`213`: Fix "streaming task paused, reason not-negotiated" errors + observed by some users on some Spotify tracks due to a change introduced in + 0.8.0. See the issue for a patch that applies to 0.8.0. + v0.8.0 (2012-09-20) =================== diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 77b451d7..fee5f094 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -61,6 +61,21 @@ class Audio(ThreadingActor): fakesink = gst.element_factory_make('fakesink') self._playbin.set_property('video-sink', fakesink) + self._playbin.connect('notify::source', self._on_new_source) + + def _on_new_source(self, element, pad): + uri = element.get_property('uri') + if not uri or not uri.startswith('appsrc://'): + return + + # These caps matches the audio data provided by libspotify + default_caps = gst.Caps( + 'audio/x-raw-int, endianness=(int)1234, channels=(int)2, ' + 'width=(int)16, depth=(int)16, signed=(boolean)true, ' + 'rate=(int)44100') + source = element.get_property('source') + source.set_property('caps', default_caps) + def _teardown_playbin(self): self._playbin.set_state(gst.STATE_NULL) From 2fd86cb16e86c1c8131acfc649638df47105588c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 25 Oct 2012 09:30:59 +0200 Subject: [PATCH 109/233] Recommend flake8 for style checking --- docs/development.rst | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index 49d8add5..eae211b9 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -41,9 +41,10 @@ Code style ========== - Follow :pep:`8` unless otherwise noted. `pep8.py - `_ can be used to check your code against - the guidelines, however remember that matching the style of the surrounding - code is also important. + `_ or `flake8 + `_ can be used to check your code + against the guidelines, however remember that matching the style of the + surrounding code is also important. - Use four spaces for indentation, *never* tabs. From 4588dd2ec230f0d45d8777b019ec5e5b7d8be2f7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 25 Oct 2012 22:42:24 +0200 Subject: [PATCH 110/233] Empty Spotify backend's __init__ and flatten logger hierarchy --- mopidy/backends/spotify/__init__.py | 72 +------------------- mopidy/backends/spotify/actor.py | 68 ++++++++++++++++++ mopidy/backends/spotify/container_manager.py | 2 +- mopidy/backends/spotify/library.py | 9 +-- mopidy/backends/spotify/playback.py | 6 +- mopidy/backends/spotify/playlist_manager.py | 2 +- mopidy/backends/spotify/session_manager.py | 12 ++-- mopidy/backends/spotify/stored_playlists.py | 4 +- mopidy/backends/spotify/translator.py | 2 +- 9 files changed, 90 insertions(+), 87 deletions(-) create mode 100644 mopidy/backends/spotify/actor.py diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 749a43c0..87d76c46 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -1,70 +1,2 @@ -import logging - -from pykka.actor import ThreadingActor - -from mopidy import settings -from mopidy.backends import base - -logger = logging.getLogger('mopidy.backends.spotify') - -BITRATES = {96: 2, 160: 0, 320: 1} - - -class SpotifyBackend(ThreadingActor, base.Backend): - """ - A backend for playing music from the `Spotify `_ - music streaming service. The backend uses the official `libspotify - `_ library and the - `pyspotify `_ Python bindings for - libspotify. - - .. note:: - - This product uses SPOTIFY(R) CORE but is not endorsed, certified or - otherwise approved in any way by Spotify. Spotify is the registered - trade mark of the Spotify Group. - - **Issues:** - https://github.com/mopidy/mopidy/issues?labels=backend-spotify - - **Dependencies:** - - - libspotify >= 10, < 11 (libspotify10 package from apt.mopidy.com) - - pyspotify >= 1.5 (python-spotify package from apt.mopidy.com) - - **Settings:** - - - :attr:`mopidy.settings.SPOTIFY_CACHE_PATH` - - :attr:`mopidy.settings.SPOTIFY_USERNAME` - - :attr:`mopidy.settings.SPOTIFY_PASSWORD` - """ - - # Imports inside methods are to prevent loading of __init__.py to fail on - # missing spotify dependencies. - - def __init__(self, audio): - from .library import SpotifyLibraryProvider - from .playback import SpotifyPlaybackProvider - from .session_manager import SpotifySessionManager - from .stored_playlists import SpotifyStoredPlaylistsProvider - - self.library = SpotifyLibraryProvider(backend=self) - self.playback = SpotifyPlaybackProvider(audio=audio, backend=self) - self.stored_playlists = SpotifyStoredPlaylistsProvider(backend=self) - - self.uri_schemes = [u'spotify'] - - # Fail early if settings are not present - username = settings.SPOTIFY_USERNAME - password = settings.SPOTIFY_PASSWORD - - self.spotify = SpotifySessionManager( - username, password, audio=audio, backend_ref=self.actor_ref) - - def on_start(self): - logger.info(u'Mopidy uses SPOTIFY(R) CORE') - logger.debug(u'Connecting to Spotify') - self.spotify.start() - - def on_stop(self): - self.spotify.logout() +# flake8: noqa +from .actor import SpotifyBackend diff --git a/mopidy/backends/spotify/actor.py b/mopidy/backends/spotify/actor.py new file mode 100644 index 00000000..186f5729 --- /dev/null +++ b/mopidy/backends/spotify/actor.py @@ -0,0 +1,68 @@ +import logging + +from pykka.actor import ThreadingActor + +from mopidy import settings +from mopidy.backends import base + +logger = logging.getLogger('mopidy.backends.spotify') + + +class SpotifyBackend(ThreadingActor, base.Backend): + """ + A backend for playing music from the `Spotify `_ + music streaming service. The backend uses the official `libspotify + `_ library and the + `pyspotify `_ Python bindings for + libspotify. + + .. note:: + + This product uses SPOTIFY(R) CORE but is not endorsed, certified or + otherwise approved in any way by Spotify. Spotify is the registered + trade mark of the Spotify Group. + + **Issues:** + https://github.com/mopidy/mopidy/issues?labels=backend-spotify + + **Dependencies:** + + - libspotify >= 10, < 11 (libspotify10 package from apt.mopidy.com) + - pyspotify >= 1.5 (python-spotify package from apt.mopidy.com) + + **Settings:** + + - :attr:`mopidy.settings.SPOTIFY_CACHE_PATH` + - :attr:`mopidy.settings.SPOTIFY_USERNAME` + - :attr:`mopidy.settings.SPOTIFY_PASSWORD` + """ + + # Imports inside methods are to prevent loading of __init__.py to fail on + # missing spotify dependencies. + + def __init__(self, audio): + from .library import SpotifyLibraryProvider + from .playback import SpotifyPlaybackProvider + from .session_manager import SpotifySessionManager + from .stored_playlists import SpotifyStoredPlaylistsProvider + + self.library = SpotifyLibraryProvider(backend=self) + self.playback = SpotifyPlaybackProvider(audio=audio, backend=self) + self.stored_playlists = SpotifyStoredPlaylistsProvider(backend=self) + + self.uri_schemes = [u'spotify'] + + # Fail early if settings are not present + username = settings.SPOTIFY_USERNAME + password = settings.SPOTIFY_PASSWORD + + self.spotify = SpotifySessionManager( + username, password, audio=audio, backend_ref=self.actor_ref) + + def on_start(self): + logger.info(u'Mopidy uses SPOTIFY(R) CORE') + logger.debug(u'Connecting to Spotify') + self.spotify.start() + + def on_stop(self): + self.spotify.logout() diff --git a/mopidy/backends/spotify/container_manager.py b/mopidy/backends/spotify/container_manager.py index a45b1adc..e3388e0b 100644 --- a/mopidy/backends/spotify/container_manager.py +++ b/mopidy/backends/spotify/container_manager.py @@ -3,7 +3,7 @@ import logging from spotify.manager import SpotifyContainerManager as \ PyspotifyContainerManager -logger = logging.getLogger('mopidy.backends.spotify.container_manager') +logger = logging.getLogger('mopidy.backends.spotify') class SpotifyContainerManager(PyspotifyContainerManager): diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index b254519e..e237a04a 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -3,11 +3,12 @@ import Queue from spotify import Link, SpotifyError -from mopidy.backends.base import BaseLibraryProvider -from mopidy.backends.spotify.translator import SpotifyTranslator +from mopidy.backends import base from mopidy.models import Track, Playlist -logger = logging.getLogger('mopidy.backends.spotify.library') +from .translator import SpotifyTranslator + +logger = logging.getLogger('mopidy.backends.spotify') class SpotifyTrack(Track): @@ -49,7 +50,7 @@ class SpotifyTrack(Track): return self._proxy.copy(**values) -class SpotifyLibraryProvider(BaseLibraryProvider): +class SpotifyLibraryProvider(base.BaseLibraryProvider): def find_exact(self, **query): return self.search(**query) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index d3d0cfa9..40868745 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -3,14 +3,14 @@ import time from spotify import Link, SpotifyError -from mopidy.backends.base import BasePlaybackProvider +from mopidy.backends import base from mopidy.core import PlaybackState -logger = logging.getLogger('mopidy.backends.spotify.playback') +logger = logging.getLogger('mopidy.backends.spotify') -class SpotifyPlaybackProvider(BasePlaybackProvider): +class SpotifyPlaybackProvider(base.BasePlaybackProvider): def __init__(self, *args, **kwargs): super(SpotifyPlaybackProvider, self).__init__(*args, **kwargs) diff --git a/mopidy/backends/spotify/playlist_manager.py b/mopidy/backends/spotify/playlist_manager.py index e1308a49..645a574c 100644 --- a/mopidy/backends/spotify/playlist_manager.py +++ b/mopidy/backends/spotify/playlist_manager.py @@ -3,7 +3,7 @@ import logging from spotify.manager import SpotifyPlaylistManager as PyspotifyPlaylistManager -logger = logging.getLogger('mopidy.backends.spotify.playlist_manager') +logger = logging.getLogger('mopidy.backends.spotify') class SpotifyPlaylistManager(PyspotifyPlaylistManager): diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index caa777e1..983f3861 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -5,14 +5,16 @@ import threading from spotify.manager import SpotifySessionManager as PyspotifySessionManager from mopidy import settings -from mopidy.backends.spotify import BITRATES -from mopidy.backends.spotify.container_manager import SpotifyContainerManager -from mopidy.backends.spotify.playlist_manager import SpotifyPlaylistManager -from mopidy.backends.spotify.translator import SpotifyTranslator from mopidy.models import Playlist from mopidy.utils import process, versioning -logger = logging.getLogger('mopidy.backends.spotify.session_manager') +from .container_manager import SpotifyContainerManager +from .playlist_manager import SpotifyPlaylistManager +from .translator import SpotifyTranslator + +logger = logging.getLogger('mopidy.backends.spotify') + +BITRATES = {96: 2, 160: 0, 320: 1} # pylint: disable = R0901 # SpotifySessionManager: Too many ancestors (9/7) diff --git a/mopidy/backends/spotify/stored_playlists.py b/mopidy/backends/spotify/stored_playlists.py index 85695c40..9a2328c4 100644 --- a/mopidy/backends/spotify/stored_playlists.py +++ b/mopidy/backends/spotify/stored_playlists.py @@ -1,7 +1,7 @@ -from mopidy.backends.base import BaseStoredPlaylistsProvider +from mopidy.backends import base -class SpotifyStoredPlaylistsProvider(BaseStoredPlaylistsProvider): +class SpotifyStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def create(self, name): pass # TODO diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 82c11ef7..104029f5 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -5,7 +5,7 @@ from spotify import Link, SpotifyError from mopidy import settings from mopidy.models import Artist, Album, Track, Playlist -logger = logging.getLogger('mopidy.backends.spotify.translator') +logger = logging.getLogger('mopidy.backends.spotify') class SpotifyTranslator(object): From 45a79df0a88ec07d6a79c6391c4ffb1dae2f2e97 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 25 Oct 2012 22:47:20 +0200 Subject: [PATCH 111/233] Split local backend into multiple files and flatten logging hierarchy --- mopidy/backends/local/__init__.py | 213 +--------------------- mopidy/backends/local/actor.py | 33 ++++ mopidy/backends/local/library.py | 110 +++++++++++ mopidy/backends/local/stored_playlists.py | 85 +++++++++ mopidy/backends/local/translator.py | 4 +- 5 files changed, 232 insertions(+), 213 deletions(-) create mode 100644 mopidy/backends/local/actor.py create mode 100644 mopidy/backends/local/library.py create mode 100644 mopidy/backends/local/stored_playlists.py diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index b34c3da5..6f0f3770 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -1,211 +1,2 @@ -import glob -import logging -import os -import shutil - -from pykka.actor import ThreadingActor - -from mopidy import settings -from mopidy.backends import base -from mopidy.models import Playlist, Album - -from .translator import parse_m3u, parse_mpd_tag_cache - -logger = logging.getLogger(u'mopidy.backends.local') - - -class LocalBackend(ThreadingActor, base.Backend): - """ - A backend for playing music from a local music archive. - - **Dependencies:** - - - None - - **Settings:** - - - :attr:`mopidy.settings.LOCAL_MUSIC_PATH` - - :attr:`mopidy.settings.LOCAL_PLAYLIST_PATH` - - :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` - """ - - def __init__(self, audio): - self.library = LocalLibraryProvider(backend=self) - self.playback = base.BasePlaybackProvider(audio=audio, backend=self) - self.stored_playlists = LocalStoredPlaylistsProvider(backend=self) - - self.uri_schemes = [u'file'] - - -class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): - def __init__(self, *args, **kwargs): - super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs) - self._folder = settings.LOCAL_PLAYLIST_PATH - self.refresh() - - def lookup(self, uri): - pass # TODO - - def refresh(self): - playlists = [] - - logger.info('Loading playlists from %s', self._folder) - - for m3u in glob.glob(os.path.join(self._folder, '*.m3u')): - name = os.path.basename(m3u)[:-len('.m3u')] - tracks = [] - for uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): - try: - tracks.append(self.backend.library.lookup(uri)) - except LookupError, e: - logger.error('Playlist item could not be added: %s', e) - playlist = Playlist(tracks=tracks, name=name) - - # FIXME playlist name needs better handling - # FIXME tracks should come from lib. lookup - - playlists.append(playlist) - - self.playlists = playlists - - def create(self, name): - playlist = Playlist(name=name) - self.save(playlist) - return playlist - - def delete(self, playlist): - if playlist not in self._playlists: - return - - self._playlists.remove(playlist) - filename = os.path.join(self._folder, playlist.name + '.m3u') - - if os.path.exists(filename): - os.remove(filename) - - def rename(self, playlist, name): - if playlist not in self._playlists: - return - - src = os.path.join(self._folder, playlist.name + '.m3u') - dst = os.path.join(self._folder, name + '.m3u') - - renamed = playlist.copy(name=name) - index = self._playlists.index(playlist) - self._playlists[index] = renamed - - shutil.move(src, dst) - - def save(self, playlist): - file_path = os.path.join(self._folder, playlist.name + '.m3u') - - # FIXME this should be a save_m3u function, not inside save - with open(file_path, 'w') as file_handle: - for track in playlist.tracks: - if track.uri.startswith('file://'): - file_handle.write(track.uri[len('file://'):] + '\n') - else: - file_handle.write(track.uri + '\n') - - self._playlists.append(playlist) - - -class LocalLibraryProvider(base.BaseLibraryProvider): - def __init__(self, *args, **kwargs): - super(LocalLibraryProvider, self).__init__(*args, **kwargs) - self._uri_mapping = {} - self.refresh() - - def refresh(self, uri=None): - tracks = parse_mpd_tag_cache( - settings.LOCAL_TAG_CACHE_FILE, settings.LOCAL_MUSIC_PATH) - - logger.info( - 'Loading tracks in %s from %s', - settings.LOCAL_MUSIC_PATH, settings.LOCAL_TAG_CACHE_FILE) - - for track in tracks: - self._uri_mapping[track.uri] = track - - def lookup(self, uri): - try: - return self._uri_mapping[uri] - except KeyError: - logger.debug(u'Failed to lookup "%s"', uri) - return None - - def find_exact(self, **query): - self._validate_query(query) - result_tracks = self._uri_mapping.values() - - for (field, values) in query.iteritems(): - if not hasattr(values, '__iter__'): - values = [values] - # FIXME this is bound to be slow for large libraries - for value in values: - q = value.strip() - - track_filter = lambda t: q == t.name - album_filter = lambda t: q == getattr(t, 'album', Album()).name - artist_filter = lambda t: filter( - lambda a: q == a.name, t.artists) - uri_filter = lambda t: q == t.uri - any_filter = lambda t: ( - track_filter(t) or album_filter(t) or - artist_filter(t) or uri_filter(t)) - - if field == 'track': - result_tracks = filter(track_filter, result_tracks) - elif field == 'album': - result_tracks = filter(album_filter, result_tracks) - elif field == 'artist': - result_tracks = filter(artist_filter, result_tracks) - elif field == 'uri': - result_tracks = filter(uri_filter, result_tracks) - elif field == 'any': - result_tracks = filter(any_filter, result_tracks) - else: - raise LookupError('Invalid lookup field: %s' % field) - return Playlist(tracks=result_tracks) - - def search(self, **query): - self._validate_query(query) - result_tracks = self._uri_mapping.values() - - for (field, values) in query.iteritems(): - if not hasattr(values, '__iter__'): - values = [values] - # FIXME this is bound to be slow for large libraries - for value in values: - q = value.strip().lower() - - track_filter = lambda t: q in t.name.lower() - album_filter = lambda t: q in getattr( - t, 'album', Album()).name.lower() - artist_filter = lambda t: filter( - lambda a: q in a.name.lower(), t.artists) - uri_filter = lambda t: q in t.uri.lower() - any_filter = lambda t: track_filter(t) or album_filter(t) or \ - artist_filter(t) or uri_filter(t) - - if field == 'track': - result_tracks = filter(track_filter, result_tracks) - elif field == 'album': - result_tracks = filter(album_filter, result_tracks) - elif field == 'artist': - result_tracks = filter(artist_filter, result_tracks) - elif field == 'uri': - result_tracks = filter(uri_filter, result_tracks) - elif field == 'any': - result_tracks = filter(any_filter, result_tracks) - else: - raise LookupError('Invalid lookup field: %s' % field) - return Playlist(tracks=result_tracks) - - def _validate_query(self, query): - for (_, values) in query.iteritems(): - if not values: - raise LookupError('Missing query') - for value in values: - if not value: - raise LookupError('Missing query') +# flake8: noqa +from .actor import LocalBackend diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py new file mode 100644 index 00000000..fe31a5fc --- /dev/null +++ b/mopidy/backends/local/actor.py @@ -0,0 +1,33 @@ +import logging + +from pykka.actor import ThreadingActor + +from mopidy.backends import base + +from .library import LocalLibraryProvider +from .stored_playlists import LocalStoredPlaylistsProvider + +logger = logging.getLogger(u'mopidy.backends.local') + + +class LocalBackend(ThreadingActor, base.Backend): + """ + A backend for playing music from a local music archive. + + **Dependencies:** + + - None + + **Settings:** + + - :attr:`mopidy.settings.LOCAL_MUSIC_PATH` + - :attr:`mopidy.settings.LOCAL_PLAYLIST_PATH` + - :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` + """ + + def __init__(self, audio): + self.library = LocalLibraryProvider(backend=self) + self.playback = base.BasePlaybackProvider(audio=audio, backend=self) + self.stored_playlists = LocalStoredPlaylistsProvider(backend=self) + + self.uri_schemes = [u'file'] diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py new file mode 100644 index 00000000..78178196 --- /dev/null +++ b/mopidy/backends/local/library.py @@ -0,0 +1,110 @@ +import logging + +from mopidy import settings +from mopidy.backends import base +from mopidy.models import Playlist, Album + +from .translator import parse_mpd_tag_cache + +logger = logging.getLogger(u'mopidy.backends.local') + + +class LocalLibraryProvider(base.BaseLibraryProvider): + def __init__(self, *args, **kwargs): + super(LocalLibraryProvider, self).__init__(*args, **kwargs) + self._uri_mapping = {} + self.refresh() + + def refresh(self, uri=None): + tracks = parse_mpd_tag_cache( + settings.LOCAL_TAG_CACHE_FILE, settings.LOCAL_MUSIC_PATH) + + logger.info( + 'Loading tracks in %s from %s', + settings.LOCAL_MUSIC_PATH, settings.LOCAL_TAG_CACHE_FILE) + + for track in tracks: + self._uri_mapping[track.uri] = track + + def lookup(self, uri): + try: + return self._uri_mapping[uri] + except KeyError: + logger.debug(u'Failed to lookup "%s"', uri) + return None + + def find_exact(self, **query): + self._validate_query(query) + result_tracks = self._uri_mapping.values() + + for (field, values) in query.iteritems(): + if not hasattr(values, '__iter__'): + values = [values] + # FIXME this is bound to be slow for large libraries + for value in values: + q = value.strip() + + track_filter = lambda t: q == t.name + album_filter = lambda t: q == getattr(t, 'album', Album()).name + artist_filter = lambda t: filter( + lambda a: q == a.name, t.artists) + uri_filter = lambda t: q == t.uri + any_filter = lambda t: ( + track_filter(t) or album_filter(t) or + artist_filter(t) or uri_filter(t)) + + if field == 'track': + result_tracks = filter(track_filter, result_tracks) + elif field == 'album': + result_tracks = filter(album_filter, result_tracks) + elif field == 'artist': + result_tracks = filter(artist_filter, result_tracks) + elif field == 'uri': + result_tracks = filter(uri_filter, result_tracks) + elif field == 'any': + result_tracks = filter(any_filter, result_tracks) + else: + raise LookupError('Invalid lookup field: %s' % field) + return Playlist(tracks=result_tracks) + + def search(self, **query): + self._validate_query(query) + result_tracks = self._uri_mapping.values() + + for (field, values) in query.iteritems(): + if not hasattr(values, '__iter__'): + values = [values] + # FIXME this is bound to be slow for large libraries + for value in values: + q = value.strip().lower() + + track_filter = lambda t: q in t.name.lower() + album_filter = lambda t: q in getattr( + t, 'album', Album()).name.lower() + artist_filter = lambda t: filter( + lambda a: q in a.name.lower(), t.artists) + uri_filter = lambda t: q in t.uri.lower() + any_filter = lambda t: track_filter(t) or album_filter(t) or \ + artist_filter(t) or uri_filter(t) + + if field == 'track': + result_tracks = filter(track_filter, result_tracks) + elif field == 'album': + result_tracks = filter(album_filter, result_tracks) + elif field == 'artist': + result_tracks = filter(artist_filter, result_tracks) + elif field == 'uri': + result_tracks = filter(uri_filter, result_tracks) + elif field == 'any': + result_tracks = filter(any_filter, result_tracks) + else: + raise LookupError('Invalid lookup field: %s' % field) + return Playlist(tracks=result_tracks) + + def _validate_query(self, query): + for (_, values) in query.iteritems(): + if not values: + raise LookupError('Missing query') + for value in values: + if not value: + raise LookupError('Missing query') diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py new file mode 100644 index 00000000..1cb03425 --- /dev/null +++ b/mopidy/backends/local/stored_playlists.py @@ -0,0 +1,85 @@ +import glob +import logging +import os +import shutil + +from mopidy import settings +from mopidy.backends import base +from mopidy.models import Playlist + +from .translator import parse_m3u + +logger = logging.getLogger(u'mopidy.backends.local') + + +class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): + def __init__(self, *args, **kwargs): + super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs) + self._folder = settings.LOCAL_PLAYLIST_PATH + self.refresh() + + def lookup(self, uri): + pass # TODO + + def refresh(self): + playlists = [] + + logger.info('Loading playlists from %s', self._folder) + + for m3u in glob.glob(os.path.join(self._folder, '*.m3u')): + name = os.path.basename(m3u)[:-len('.m3u')] + tracks = [] + for uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): + try: + tracks.append(self.backend.library.lookup(uri)) + except LookupError, e: + logger.error('Playlist item could not be added: %s', e) + playlist = Playlist(tracks=tracks, name=name) + + # FIXME playlist name needs better handling + # FIXME tracks should come from lib. lookup + + playlists.append(playlist) + + self.playlists = playlists + + def create(self, name): + playlist = Playlist(name=name) + self.save(playlist) + return playlist + + def delete(self, playlist): + if playlist not in self._playlists: + return + + self._playlists.remove(playlist) + filename = os.path.join(self._folder, playlist.name + '.m3u') + + if os.path.exists(filename): + os.remove(filename) + + def rename(self, playlist, name): + if playlist not in self._playlists: + return + + src = os.path.join(self._folder, playlist.name + '.m3u') + dst = os.path.join(self._folder, name + '.m3u') + + renamed = playlist.copy(name=name) + index = self._playlists.index(playlist) + self._playlists[index] = renamed + + shutil.move(src, dst) + + def save(self, playlist): + file_path = os.path.join(self._folder, playlist.name + '.m3u') + + # FIXME this should be a save_m3u function, not inside save + with open(file_path, 'w') as file_handle: + for track in playlist.tracks: + if track.uri.startswith('file://'): + file_handle.write(track.uri[len('file://'):] + '\n') + else: + file_handle.write(track.uri + '\n') + + self._playlists.append(playlist) diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 5a4a238b..01aad440 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -1,11 +1,11 @@ import logging -logger = logging.getLogger('mopidy.backends.local.translator') - from mopidy.models import Track, Artist, Album from mopidy.utils.encoding import locale_decode from mopidy.utils.path import path_to_uri +logger = logging.getLogger('mopidy.backends.local') + def parse_m3u(file_path, music_folder): r""" From c915c197dd3f91ae86695fbcaf033348fdbdb2df Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 25 Oct 2012 22:49:47 +0200 Subject: [PATCH 112/233] Formatting --- tests/backends/local/stored_playlists_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index 437152fe..4dc5ecdb 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -1,4 +1,5 @@ import os + from mopidy import settings from mopidy.backends.local import LocalBackend from mopidy.models import Playlist, Track From f309e7ec2313f9ca096521e96920e889a8fba51f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 26 Oct 2012 10:40:47 +0200 Subject: [PATCH 113/233] Revise audio logging messages --- mopidy/audio/actor.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index fee5f094..f151f487 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -84,20 +84,20 @@ class Audio(ThreadingActor): output = gst.parse_bin_from_description( settings.OUTPUT, ghost_unconnected_pads=True) self._playbin.set_property('audio-sink', output) - logger.info('Output set to %s', settings.OUTPUT) + logger.info('Audio output set to "%s"', settings.OUTPUT) except gobject.GError as ex: logger.error( - 'Failed to create output "%s": %s', settings.OUTPUT, ex) + 'Failed to create audio output "%s": %s', settings.OUTPUT, ex) process.exit_process() def _setup_mixer(self): if not settings.MIXER: - logger.info('Not setting up mixer.') + logger.info('Not setting up audio mixer') return if settings.MIXER == 'software': self._software_mixing = True - logger.info('Mixer set to software mixing.') + logger.info('Audio mixer is using software mixing') return try: @@ -105,28 +105,31 @@ class Audio(ThreadingActor): settings.MIXER, ghost_unconnected_pads=False) except gobject.GError as ex: logger.warning( - 'Failed to create mixer "%s": %s', settings.MIXER, ex) + 'Failed to create audio mixer "%s": %s', settings.MIXER, ex) return # We assume that the bin will contain a single mixer. mixer = mixerbin.get_by_interface('GstMixer') if not mixer: - logger.warning('Did not find any mixers in %r', settings.MIXER) + logger.warning( + 'Did not find any audio mixers in "%s"', settings.MIXER) return if mixerbin.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS: - logger.warning('Setting mixer %r to READY failed.', settings.MIXER) + logger.warning( + 'Setting audio mixer "%s" to READY failed', settings.MIXER) return track = self._select_mixer_track(mixer, settings.MIXER_TRACK) if not track: - logger.warning('Could not find usable mixer track.') + logger.warning('Could not find usable audio mixer track') return self._mixer = mixer self._mixer_track = track - logger.info('Mixer set to %s using track called %s', - mixer.get_factory().get_name(), track.label) + logger.info( + 'Audio mixer set to "%s" using track "%s"', + mixer.get_factory().get_name(), track.label) def _select_mixer_track(self, mixer, track_label): # Look for track with label == MIXER_TRACK, otherwise fallback to @@ -297,15 +300,15 @@ class Audio(ThreadingActor): result = self._playbin.set_state(state) if result == gst.STATE_CHANGE_FAILURE: logger.warning( - 'Setting GStreamer state to %s: failed', state.value_name) + 'Setting GStreamer state to %s failed', state.value_name) return False elif result == gst.STATE_CHANGE_ASYNC: logger.debug( - 'Setting GStreamer state to %s: async', state.value_name) + 'Setting GStreamer state to %s is async', state.value_name) return True else: logger.debug( - 'Setting GStreamer state to %s: OK', state.value_name) + 'Setting GStreamer state to %s is OK', state.value_name) return True def get_volume(self): From a78492a65b0ee14a7b18b2b55647a72fc470e333 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 26 Oct 2012 10:45:11 +0200 Subject: [PATCH 114/233] Revise local backend logging messages --- mopidy/backends/local/library.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 78178196..600bfaaa 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -20,7 +20,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): settings.LOCAL_TAG_CACHE_FILE, settings.LOCAL_MUSIC_PATH) logger.info( - 'Loading tracks in %s from %s', + 'Loading tracks from %s using %s', settings.LOCAL_MUSIC_PATH, settings.LOCAL_TAG_CACHE_FILE) for track in tracks: @@ -30,7 +30,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): try: return self._uri_mapping[uri] except KeyError: - logger.debug(u'Failed to lookup "%s"', uri) + logger.debug(u'Failed to lookup %r', uri) return None def find_exact(self, **query): From 587dde287faa44c681b11fa86bf4a4fa8cb5c17d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 26 Oct 2012 22:32:06 +0200 Subject: [PATCH 115/233] Update to work with Pykka 1.0 --- docs/changes.rst | 2 +- mopidy/__main__.py | 2 +- mopidy/backends/dummy.py | 2 ++ mopidy/backends/local/actor.py | 2 ++ mopidy/backends/spotify/actor.py | 2 ++ mopidy/core/actor.py | 2 ++ mopidy/utils/network.py | 2 +- tests/utils/network/connection_test.py | 4 ++-- 8 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 854c90d3..c68db685 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -10,7 +10,7 @@ v0.9.0 (in development) **Dependencies** -- Pykka >= 0.16 is now required. +- Pykka >= 1.0 is now required. **Bug fixes** diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 97c2a010..67fefde6 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -74,7 +74,7 @@ def main(): def check_dependencies(): - pykka_required = '0.16' + pykka_required = '1.0' if StrictVersion(pykka.__version__) < StrictVersion(pykka_required): sys.exit( u'Mopidy requires Pykka >= %s, but found %s' % diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 6c3e1437..bb5aaf73 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -13,6 +13,8 @@ class DummyBackend(ThreadingActor, base.Backend): """ def __init__(self, audio): + super(DummyBackend, self).__init__() + self.library = DummyLibraryProvider(backend=self) self.playback = DummyPlaybackProvider(audio=audio, backend=self) self.stored_playlists = DummyStoredPlaylistsProvider(backend=self) diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index fe31a5fc..1046aaf4 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -26,6 +26,8 @@ class LocalBackend(ThreadingActor, base.Backend): """ def __init__(self, audio): + super(LocalBackend, self).__init__() + self.library = LocalLibraryProvider(backend=self) self.playback = base.BasePlaybackProvider(audio=audio, backend=self) self.stored_playlists = LocalStoredPlaylistsProvider(backend=self) diff --git a/mopidy/backends/spotify/actor.py b/mopidy/backends/spotify/actor.py index 186f5729..3c897380 100644 --- a/mopidy/backends/spotify/actor.py +++ b/mopidy/backends/spotify/actor.py @@ -41,6 +41,8 @@ class SpotifyBackend(ThreadingActor, base.Backend): # missing spotify dependencies. def __init__(self, audio): + super(SpotifyBackend, self).__init__() + from .library import SpotifyLibraryProvider from .playback import SpotifyPlaybackProvider from .session_manager import SpotifySessionManager diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index aded0774..806caca2 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -26,6 +26,8 @@ class Core(ThreadingActor, AudioListener): stored_playlists = None def __init__(self, audio=None, backend=None): + super(Core, self).__init__() + self._backend = backend self.current_playlist = CurrentPlaylistController(core=self) diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index b8914614..a6032f37 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -265,7 +265,7 @@ class Connection(object): return True try: - self.actor_ref.send_one_way({'received': data}) + self.actor_ref.tell({'received': data}) except ActorDeadError: self.stop(u'Actor is dead.') diff --git a/tests/utils/network/connection_test.py b/tests/utils/network/connection_test.py index c51957f1..c9fe9a05 100644 --- a/tests/utils/network/connection_test.py +++ b/tests/utils/network/connection_test.py @@ -392,14 +392,14 @@ class ConnectionTest(unittest.TestCase): self.assertTrue(network.Connection.recv_callback( self.mock, sentinel.fd, gobject.IO_IN)) - self.mock.actor_ref.send_one_way.assert_called_once_with( + self.mock.actor_ref.tell.assert_called_once_with( {'received': 'data'}) def test_recv_callback_handles_dead_actors(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.recv.return_value = 'data' self.mock.actor_ref = Mock() - self.mock.actor_ref.send_one_way.side_effect = pykka.ActorDeadError() + self.mock.actor_ref.tell.side_effect = pykka.ActorDeadError() self.assertTrue(network.Connection.recv_callback( self.mock, sentinel.fd, gobject.IO_IN)) From 70c72365b821a720e0086450eb6faac86dcdfded Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 26 Oct 2012 22:47:41 +0200 Subject: [PATCH 116/233] Update Pykka version in install docs --- docs/installation/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index d5728c00..c58ba9dd 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -26,7 +26,7 @@ dependencies installed. - Python >= 2.6, < 3 - - Pykka >= 0.16:: + - Pykka >= 1.0:: sudo pip install -U pykka From d685fe554c37287fc56604581e8d19d00a3a3aee Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 26 Oct 2012 22:57:41 +0200 Subject: [PATCH 117/233] Simplify pykka imports --- mopidy/audio/actor.py | 4 ++-- mopidy/audio/listener.py | 4 ++-- mopidy/audio/mixers/nad.py | 4 ++-- mopidy/backends/dummy.py | 4 ++-- mopidy/backends/local/actor.py | 4 ++-- mopidy/backends/spotify/actor.py | 4 ++-- mopidy/core/actor.py | 4 ++-- mopidy/core/listener.py | 4 ++-- mopidy/frontends/lastfm.py | 4 ++-- mopidy/frontends/mpd/actor.py | 6 +++--- mopidy/frontends/mpd/dispatcher.py | 4 ++-- mopidy/frontends/mpd/protocol/status.py | 4 ++-- mopidy/frontends/mpris/actor.py | 4 ++-- mopidy/utils/network.py | 12 +++++------- tests/backends/base/current_playlist.py | 4 ++-- tests/backends/base/library.py | 4 ++-- tests/backends/events_test.py | 5 ++--- tests/frontends/mpd/dispatcher_test.py | 4 ++-- tests/frontends/mpd/protocol/__init__.py | 5 ++--- tests/frontends/mpd/status_test.py | 4 ++-- tests/frontends/mpris/player_interface_test.py | 5 ++--- tests/frontends/mpris/root_interface_test.py | 5 ++--- 22 files changed, 48 insertions(+), 54 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index f151f487..53e8f723 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -5,7 +5,7 @@ import gobject import logging -from pykka.actor import ThreadingActor +import pykka from mopidy import settings from mopidy.utils import process @@ -18,7 +18,7 @@ logger = logging.getLogger('mopidy.audio') mixers.register_mixers() -class Audio(ThreadingActor): +class Audio(pykka.ThreadingActor): """ Audio output through `GStreamer `_. diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py index 757cd5f4..54fe058d 100644 --- a/mopidy/audio/listener.py +++ b/mopidy/audio/listener.py @@ -1,4 +1,4 @@ -from pykka.registry import ActorRegistry +import pykka class AudioListener(object): @@ -15,7 +15,7 @@ class AudioListener(object): @staticmethod def send(event, **kwargs): """Helper to allow calling of audio listener events""" - listeners = ActorRegistry.get_by_class(AudioListener) + listeners = pykka.ActorRegistry.get_by_class(AudioListener) for listener in listeners: getattr(listener.proxy(), event)(**kwargs) diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index 72bede82..1d65ead9 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -10,7 +10,7 @@ try: except ImportError: serial = None # noqa -from pykka.actor import ThreadingActor +import pykka from . import utils @@ -74,7 +74,7 @@ class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): ).proxy() -class NadTalker(ThreadingActor): +class NadTalker(pykka.ThreadingActor): """ Independent thread which does the communication with the NAD amplifier diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index bb5aaf73..3a1d65b7 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -1,10 +1,10 @@ -from pykka.actor import ThreadingActor +import pykka from mopidy.backends import base from mopidy.models import Playlist -class DummyBackend(ThreadingActor, base.Backend): +class DummyBackend(pykka.ThreadingActor, base.Backend): """ A backend which implements the backend API in the simplest way possible. Used in tests of the frontends. diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index 1046aaf4..10802722 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -1,6 +1,6 @@ import logging -from pykka.actor import ThreadingActor +import pykka from mopidy.backends import base @@ -10,7 +10,7 @@ from .stored_playlists import LocalStoredPlaylistsProvider logger = logging.getLogger(u'mopidy.backends.local') -class LocalBackend(ThreadingActor, base.Backend): +class LocalBackend(pykka.ThreadingActor, base.Backend): """ A backend for playing music from a local music archive. diff --git a/mopidy/backends/spotify/actor.py b/mopidy/backends/spotify/actor.py index 3c897380..948636a2 100644 --- a/mopidy/backends/spotify/actor.py +++ b/mopidy/backends/spotify/actor.py @@ -1,6 +1,6 @@ import logging -from pykka.actor import ThreadingActor +import pykka from mopidy import settings from mopidy.backends import base @@ -8,7 +8,7 @@ from mopidy.backends import base logger = logging.getLogger('mopidy.backends.spotify') -class SpotifyBackend(ThreadingActor, base.Backend): +class SpotifyBackend(pykka.ThreadingActor, base.Backend): """ A backend for playing music from the `Spotify `_ music streaming service. The backend uses the official `libspotify diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 806caca2..a3766fff 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -1,4 +1,4 @@ -from pykka.actor import ThreadingActor +import pykka from mopidy.audio import AudioListener @@ -8,7 +8,7 @@ from .playback import PlaybackController from .stored_playlists import StoredPlaylistsController -class Core(ThreadingActor, AudioListener): +class Core(pykka.ThreadingActor, AudioListener): #: The current playlist controller. An instance of #: :class:`mopidy.core.CurrentPlaylistController`. current_playlist = None diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index 9476ac4f..ed7dae2f 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -1,4 +1,4 @@ -from pykka.registry import ActorRegistry +import pykka class CoreListener(object): @@ -15,7 +15,7 @@ class CoreListener(object): @staticmethod def send(event, **kwargs): """Helper to allow calling of core listener events""" - listeners = ActorRegistry.get_by_class(CoreListener) + listeners = pykka.ActorRegistry.get_by_class(CoreListener) for listener in listeners: getattr(listener.proxy(), event)(**kwargs) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 45c2db16..e7c2afdb 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -1,7 +1,7 @@ import logging import time -from pykka.actor import ThreadingActor +import pykka from mopidy import exceptions, settings from mopidy.core import CoreListener @@ -17,7 +17,7 @@ API_KEY = '2236babefa8ebb3d93ea467560d00d04' API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd' -class LastfmFrontend(ThreadingActor, CoreListener): +class LastfmFrontend(pykka.ThreadingActor, CoreListener): """ Frontend which scrobbles the music you play to your `Last.fm `_ profile. diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index d7a20158..0c73bc2b 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -1,7 +1,7 @@ import logging import sys -from pykka import registry, actor +import pykka from mopidy import settings from mopidy.core import CoreListener @@ -11,7 +11,7 @@ from mopidy.utils import encoding, network, process logger = logging.getLogger('mopidy.frontends.mpd') -class MpdFrontend(actor.ThreadingActor, CoreListener): +class MpdFrontend(pykka.ThreadingActor, CoreListener): """ The MPD frontend. @@ -50,7 +50,7 @@ class MpdFrontend(actor.ThreadingActor, CoreListener): def send_idle(self, subsystem): # FIXME this should be updated once pykka supports non-blocking calls # on proxies or some similar solution - registry.ActorRegistry.broadcast({ + pykka.ActorRegistry.broadcast({ 'command': 'pykka_call', 'attr_path': ('on_idle',), 'args': [subsystem], diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 6f91c491..148fe443 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -1,7 +1,7 @@ import logging import re -from pykka import ActorDeadError +import pykka from mopidy import settings from mopidy.frontends.mpd import exceptions, protocol @@ -156,7 +156,7 @@ class MpdDispatcher(object): try: response = self._format_response(self._call_handler(request)) return self._call_next_filter(request, response, filter_chain) - except ActorDeadError as e: + except pykka.ActorDeadError as e: logger.warning(u'Tried to communicate with dead actor.') raise exceptions.MpdSystemError(e) diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index deda4986..b8e207d1 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -1,4 +1,4 @@ -import pykka.future +import pykka from mopidy.core import PlaybackState from mopidy.frontends.mpd.exceptions import MpdNotImplemented @@ -186,7 +186,7 @@ def status(context): context.core.playback.current_playlist_position), 'playback.time_position': context.core.playback.time_position, } - pykka.future.get_all(futures.values()) + pykka.get_all(futures.values()) result = [ ('volume', _status_volume(futures)), ('repeat', _status_repeat(futures)), diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py index e3199ac3..acca3ab7 100644 --- a/mopidy/frontends/mpris/actor.py +++ b/mopidy/frontends/mpris/actor.py @@ -1,6 +1,6 @@ import logging -from pykka.actor import ThreadingActor +import pykka from mopidy import settings from mopidy.core import CoreListener @@ -15,7 +15,7 @@ except ImportError as import_error: logger.debug(u'Startup notification will not be sent (%s)', import_error) -class MprisFrontend(ThreadingActor, CoreListener): +class MprisFrontend(pykka.ThreadingActor, CoreListener): """ Frontend which lets you control Mopidy through the Media Player Remote Interfacing Specification (`MPRIS `_) D-Bus diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index a6032f37..e56f6a81 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -5,9 +5,7 @@ import re import socket import threading -from pykka import ActorDeadError -from pykka.actor import ThreadingActor -from pykka.registry import ActorRegistry +import pykka from mopidy.utils import encoding @@ -105,7 +103,7 @@ class Server(object): self.number_of_connections() >= self.max_connections) def number_of_connections(self): - return len(ActorRegistry.get_by_class(self.protocol)) + return len(pykka.ActorRegistry.get_by_class(self.protocol)) def reject_connection(self, sock, addr): # FIXME provide more context in logging? @@ -164,7 +162,7 @@ class Connection(object): try: self.actor_ref.stop(block=False) - except ActorDeadError: + except pykka.ActorDeadError: pass self.disable_timeout() @@ -266,7 +264,7 @@ class Connection(object): try: self.actor_ref.tell({'received': data}) - except ActorDeadError: + except pykka.ActorDeadError: self.stop(u'Actor is dead.') return True @@ -295,7 +293,7 @@ class Connection(object): return False -class LineProtocol(ThreadingActor): +class LineProtocol(pykka.ThreadingActor): """ Base class for handling line based protocols. diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index 00ffaea8..9d86027e 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -1,7 +1,7 @@ import mock import random -from pykka.registry import ActorRegistry +import pykka from mopidy import audio, core from mopidy.core import PlaybackState @@ -23,7 +23,7 @@ class CurrentPlaylistControllerTest(object): assert len(self.tracks) == 3, 'Need three tracks to run tests.' def tearDown(self): - ActorRegistry.stop_all() + pykka.ActorRegistry.stop_all() def test_length(self): self.assertEqual(0, len(self.controller.cp_tracks)) diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index 85ba54bb..edaa704d 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -1,4 +1,4 @@ -from pykka.registry import ActorRegistry +import pykka from mopidy import core from mopidy.models import Playlist, Track, Album, Artist @@ -27,7 +27,7 @@ class LibraryControllerTest(object): self.library = self.core.library def tearDown(self): - ActorRegistry.stop_all() + pykka.ActorRegistry.stop_all() def test_refresh(self): self.library.refresh() diff --git a/tests/backends/events_test.py b/tests/backends/events_test.py index 200e0ca2..9c552f39 100644 --- a/tests/backends/events_test.py +++ b/tests/backends/events_test.py @@ -1,6 +1,5 @@ import mock - -from pykka.registry import ActorRegistry +import pykka from mopidy import audio, core from mopidy.backends import dummy @@ -17,7 +16,7 @@ class BackendEventsTest(unittest.TestCase): self.core = core.Core.start(backend=self.backend).proxy() def tearDown(self): - ActorRegistry.stop_all() + pykka.ActorRegistry.stop_all() def test_pause_sends_track_playback_paused_event(self, send): self.core.current_playlist.add(Track(uri='a')) diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/frontends/mpd/dispatcher_test.py index 0b5098c1..1e108e07 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -1,4 +1,4 @@ -from pykka.registry import ActorRegistry +import pykka from mopidy import core from mopidy.backends import dummy @@ -16,7 +16,7 @@ class MpdDispatcherTest(unittest.TestCase): self.dispatcher = MpdDispatcher() def tearDown(self): - ActorRegistry.stop_all() + pykka.ActorRegistry.stop_all() def test_register_same_pattern_twice_fails(self): func = lambda: None diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index 34557513..4c6d3584 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -1,6 +1,5 @@ import mock - -from pykka.registry import ActorRegistry +import pykka from mopidy import core, settings from mopidy.backends import dummy @@ -32,7 +31,7 @@ class BaseTestCase(unittest.TestCase): self.context = self.dispatcher.context def tearDown(self): - ActorRegistry.stop_all() + pykka.ActorRegistry.stop_all() settings.runtime.clear() def sendRequest(self, request): diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 61fd0854..c1b43deb 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -1,4 +1,4 @@ -from pykka.registry import ActorRegistry +import pykka from mopidy import core from mopidy.backends import dummy @@ -26,7 +26,7 @@ class StatusHandlerTest(unittest.TestCase): self.context = self.dispatcher.context def tearDown(self): - ActorRegistry.stop_all() + pykka.ActorRegistry.stop_all() def test_stats_method(self): result = status.stats(self.context) diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index 5c3d2cae..34375098 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -1,8 +1,7 @@ import sys import mock - -from pykka.registry import ActorRegistry +import pykka from mopidy import core, exceptions from mopidy.backends import dummy @@ -30,7 +29,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris = objects.MprisObject(core=self.core) def tearDown(self): - ActorRegistry.stop_all() + pykka.ActorRegistry.stop_all() def test_get_playback_status_is_playing_when_playing(self): self.core.playback.state = PLAYING diff --git a/tests/frontends/mpris/root_interface_test.py b/tests/frontends/mpris/root_interface_test.py index 8f37cc47..d185895f 100644 --- a/tests/frontends/mpris/root_interface_test.py +++ b/tests/frontends/mpris/root_interface_test.py @@ -1,8 +1,7 @@ import sys import mock - -from pykka.registry import ActorRegistry +import pykka from mopidy import core, exceptions, settings from mopidy.backends import dummy @@ -25,7 +24,7 @@ class RootInterfaceTest(unittest.TestCase): self.mpris = objects.MprisObject(core=self.core) def tearDown(self): - ActorRegistry.stop_all() + pykka.ActorRegistry.stop_all() def test_constructor_connects_to_dbus(self): self.assert_(self.mpris._connect_to_dbus.called) From c076fb75d40b85b593bd569eaf7f6e13ab95cdd8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 26 Oct 2012 23:05:53 +0200 Subject: [PATCH 118/233] Replace Pykka internals misuse with proxies --- mopidy/frontends/mpd/actor.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index 0c73bc2b..f69334b5 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -48,14 +48,9 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): process.stop_actors_by_class(session.MpdSession) def send_idle(self, subsystem): - # FIXME this should be updated once pykka supports non-blocking calls - # on proxies or some similar solution - pykka.ActorRegistry.broadcast({ - 'command': 'pykka_call', - 'attr_path': ('on_idle',), - 'args': [subsystem], - 'kwargs': {}, - }, target_class=session.MpdSession) + listeners = pykka.ActorRegistry.get_by_class(session.MpdSession) + for listener in listeners: + getattr(listener.proxy(), 'on_idle')(subsystem) def playback_state_changed(self, old_state, new_state): self.send_idle('player') From a8e71afeaf9238d69df90fa8cdba233e960912c2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 26 Oct 2012 23:09:44 +0200 Subject: [PATCH 119/233] Update Pykka version yet another place --- requirements/core.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/core.txt b/requirements/core.txt index 1c2371f3..7f83e251 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -1 +1 @@ -Pykka >= 0.16 +Pykka >= 1.0 From 956655f7428cb1491a64330845cbd3602dfed250 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Oct 2012 11:22:54 +0200 Subject: [PATCH 120/233] Update tests to use tracks with valid URIs --- mopidy/backends/dummy.py | 4 +- tests/backends/events_test.py | 10 +- tests/frontends/mpd/protocol/playback_test.py | 75 ++-- .../frontends/mpd/protocol/regression_test.py | 36 +- tests/frontends/mpd/status_test.py | 14 +- .../frontends/mpris/player_interface_test.py | 385 ++++++++++-------- 6 files changed, 291 insertions(+), 233 deletions(-) diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 3a1d65b7..94bb9b1d 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -51,9 +51,9 @@ class DummyPlaybackProvider(base.BasePlaybackProvider): return True def play(self, track): - """Pass None as track to force failure""" + """Pass a track with URI 'dummy:error' to force failure""" self._time_position = 0 - return track is not None + return track.uri != 'dummy:error' def resume(self): return True diff --git a/tests/backends/events_test.py b/tests/backends/events_test.py index 9c552f39..a25a73c2 100644 --- a/tests/backends/events_test.py +++ b/tests/backends/events_test.py @@ -19,14 +19,14 @@ class BackendEventsTest(unittest.TestCase): pykka.ActorRegistry.stop_all() def test_pause_sends_track_playback_paused_event(self, send): - self.core.current_playlist.add(Track(uri='a')) + self.core.current_playlist.add(Track(uri='dummy:a')) self.core.playback.play().get() send.reset_mock() self.core.playback.pause().get() self.assertEqual(send.call_args[0][0], 'track_playback_paused') def test_resume_sends_track_playback_resumed(self, send): - self.core.current_playlist.add(Track(uri='a')) + self.core.current_playlist.add(Track(uri='dummy:a')) self.core.playback.play() self.core.playback.pause().get() send.reset_mock() @@ -34,20 +34,20 @@ class BackendEventsTest(unittest.TestCase): self.assertEqual(send.call_args[0][0], 'track_playback_resumed') def test_play_sends_track_playback_started_event(self, send): - self.core.current_playlist.add(Track(uri='a')) + self.core.current_playlist.add(Track(uri='dummy:a')) send.reset_mock() self.core.playback.play().get() self.assertEqual(send.call_args[0][0], 'track_playback_started') def test_stop_sends_track_playback_ended_event(self, send): - self.core.current_playlist.add(Track(uri='a')) + self.core.current_playlist.add(Track(uri='dummy:a')) self.core.playback.play().get() send.reset_mock() self.core.playback.stop().get() self.assertEqual(send.call_args_list[0][0][0], 'track_playback_ended') def test_seek_sends_seeked_event(self, send): - self.core.current_playlist.add(Track(uri='a', length=40000)) + self.core.current_playlist.add(Track(uri='dummy:a', length=40000)) self.core.playback.play().get() send.reset_mock() self.core.playback.seek(1000).get() diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 431c4663..ab254bdf 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -166,7 +166,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_pause_off(self): - self.core.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'play "0"') self.sendRequest(u'pause "1"') @@ -175,7 +175,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_pause_on(self): - self.core.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'play "0"') self.sendRequest(u'pause "1"') @@ -183,7 +183,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_pause_toggle(self): - self.core.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'play "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -198,22 +198,21 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_play_without_pos(self): - self.core.current_playlist.append([Track()]) - self.core.playback.state = PAUSED + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'play') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_play_with_pos(self): - self.core.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'play "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_play_with_pos_without_quotes(self): - self.core.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'play 0') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -228,15 +227,22 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): 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.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), + Track(uri='dummy:b'), + ]) self.sendRequest(u'play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertEqual('a', self.core.playback.current_track.get().uri) + self.assertEqual('dummy:a', + self.core.playback.current_track.get().uri) self.assertInResponse(u'OK') def test_play_minus_one_plays_current_track_if_current_track_is_set(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + 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() @@ -245,7 +251,8 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.sendRequest(u'play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertEqual('b', self.core.playback.current_track.get().uri) + self.assertEqual('dummy:b', + self.core.playback.current_track.get().uri) self.assertInResponse(u'OK') def test_play_minus_one_on_empty_playlist_does_not_ack(self): @@ -257,7 +264,8 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_play_minus_is_ignored_if_playing(self): - self.core.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([ + Track(uri='dummy:a', length=40000)]) self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -270,7 +278,8 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_play_minus_one_resumes_if_paused(self): - self.core.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([ + Track(uri='dummy:a', length=40000)]) self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -285,14 +294,14 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playid(self): - self.core.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'playid "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_playid_without_quotes(self): - self.core.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'playid 0') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -300,15 +309,22 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): 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.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), + Track(uri='dummy:b'), + ]) self.sendRequest(u'playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertEqual('a', self.core.playback.current_track.get().uri) + self.assertEqual('dummy:a', + self.core.playback.current_track.get().uri) self.assertInResponse(u'OK') def test_playid_minus_1_plays_current_track_if_current_track_is_set(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + 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() @@ -317,7 +333,8 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.sendRequest(u'playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertEqual('b', self.core.playback.current_track.get().uri) + self.assertEqual('dummy:b', + self.core.playback.current_track.get().uri) self.assertInResponse(u'OK') def test_playid_minus_one_on_empty_playlist_does_not_ack(self): @@ -329,7 +346,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playid_minus_is_ignored_if_playing(self): - self.core.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -342,7 +359,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playid_minus_one_resumes_if_paused(self): - self.core.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -357,7 +374,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playid_which_does_not_exist(self): - self.core.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'playid "12345"') self.assertInResponse(u'ACK [50@0] {playid} No such song') @@ -367,7 +384,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_seek(self): - self.core.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.sendRequest(u'seek "0"') self.sendRequest(u'seek "0" "30"') @@ -375,16 +392,16 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_seek_with_songpos(self): - seek_track = Track(uri='2', length=40000) + seek_track = Track(uri='dummy:2', length=40000) self.core.current_playlist.append( - [Track(uri='1', length=40000), seek_track]) + [Track(uri='dummy:1', length=40000), seek_track]) self.sendRequest(u'seek "1" "30"') self.assertEqual(self.core.playback.current_track.get(), seek_track) self.assertInResponse(u'OK') def test_seek_without_quotes(self): - self.core.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.sendRequest(u'seek 0') self.sendRequest(u'seek 0 30') @@ -393,16 +410,16 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_seekid(self): - self.core.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.sendRequest(u'seekid "0" "30"') self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_seekid_with_cpid(self): - seek_track = Track(uri='2', length=40000) + seek_track = Track(uri='dummy:2', length=40000) self.core.current_playlist.append( - [Track(length=40000), seek_track]) + [Track(uri='dummy:1', length=40000), seek_track]) self.sendRequest(u'seekid "1" "30"') self.assertEqual(1, self.core.playback.current_cpid.get()) diff --git a/tests/frontends/mpd/protocol/regression_test.py b/tests/frontends/mpd/protocol/regression_test.py index a7b7611d..a90e37ab 100644 --- a/tests/frontends/mpd/protocol/regression_test.py +++ b/tests/frontends/mpd/protocol/regression_test.py @@ -17,22 +17,32 @@ class IssueGH17RegressionTest(protocol.BaseTestCase): """ def test(self): self.core.current_playlist.append([ - Track(uri='a'), Track(uri='b'), None, - Track(uri='d'), Track(uri='e'), Track(uri='f')]) + Track(uri='dummy:a'), + Track(uri='dummy:b'), + Track(uri='dummy:error'), + Track(uri='dummy:d'), + Track(uri='dummy:e'), + Track(uri='dummy:f'), + ]) random.seed(1) # Playlist order: abcfde self.sendRequest(u'play') - self.assertEquals('a', self.core.playback.current_track.get().uri) + self.assertEquals('dummy:a', + self.core.playback.current_track.get().uri) self.sendRequest(u'random "1"') self.sendRequest(u'next') - self.assertEquals('b', self.core.playback.current_track.get().uri) + self.assertEquals('dummy:b', + self.core.playback.current_track.get().uri) self.sendRequest(u'next') # Should now be at track 'c', but playback fails and it skips ahead - self.assertEquals('f', self.core.playback.current_track.get().uri) + self.assertEquals('dummy:f', + self.core.playback.current_track.get().uri) self.sendRequest(u'next') - self.assertEquals('d', self.core.playback.current_track.get().uri) + self.assertEquals('dummy:d', + self.core.playback.current_track.get().uri) self.sendRequest(u'next') - self.assertEquals('e', self.core.playback.current_track.get().uri) + self.assertEquals('dummy:e', + self.core.playback.current_track.get().uri) class IssueGH18RegressionTest(protocol.BaseTestCase): @@ -48,8 +58,8 @@ class IssueGH18RegressionTest(protocol.BaseTestCase): def test(self): self.core.current_playlist.append([ - Track(uri='a'), Track(uri='b'), Track(uri='c'), - Track(uri='d'), Track(uri='e'), Track(uri='f')]) + Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), + Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) random.seed(1) self.sendRequest(u'play') @@ -84,8 +94,8 @@ class IssueGH22RegressionTest(protocol.BaseTestCase): def test(self): self.core.current_playlist.append([ - Track(uri='a'), Track(uri='b'), Track(uri='c'), - Track(uri='d'), Track(uri='e'), Track(uri='f')]) + Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), + Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) random.seed(1) self.sendRequest(u'play') @@ -113,8 +123,8 @@ class IssueGH69RegressionTest(protocol.BaseTestCase): def test(self): self.core.stored_playlists.create('foo') self.core.current_playlist.append([ - Track(uri='a'), Track(uri='b'), Track(uri='c'), - Track(uri='d'), Track(uri='e'), Track(uri='f')]) + Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), + Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) self.sendRequest(u'play') self.sendRequest(u'stop') diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index c1b43deb..46f500e7 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -129,21 +129,21 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['state'], 'pause') def test_status_method_when_playlist_loaded_contains_song(self): - self.core.current_playlist.append([Track()]) + self.core.current_playlist.append([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_cpid_as_songid(self): - self.core.current_playlist.append([Track()]) + self.core.current_playlist.append([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.current_playlist.append([Track(length=None)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=None)]) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('time', result) @@ -153,7 +153,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertLessEqual(position, total) def test_status_method_when_playing_contains_time_with_length(self): - self.core.current_playlist.append([Track(length=10000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=10000)]) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('time', result) @@ -163,7 +163,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertLessEqual(position, total) def test_status_method_when_playing_contains_elapsed(self): - self.core.current_playlist.append([Track(length=60000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=60000)]) self.core.playback.play() self.core.playback.pause() self.core.playback.seek(59123) @@ -172,7 +172,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['elapsed'], '59.123') def test_status_method_when_starting_playing_contains_elapsed_zero(self): - self.core.current_playlist.append([Track(length=10000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=10000)]) self.core.playback.play() self.core.playback.pause() result = dict(status.status(self.context)) @@ -180,7 +180,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['elapsed'], '0.000') def test_status_method_when_playing_contains_bitrate(self): - self.core.current_playlist.append([Track(bitrate=320)]) + self.core.current_playlist.append([Track(uri='dummy:a', bitrate=320)]) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('bitrate', result) diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index 34375098..bd0c1728 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -69,23 +69,23 @@ class PlayerInterfaceTest(unittest.TestCase): self.core.playback.repeat = True self.core.playback.single = True self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'None') - self.assertEquals(self.core.playback.repeat.get(), True) - self.assertEquals(self.core.playback.single.get(), True) + self.assertEqual(self.core.playback.repeat.get(), True) + self.assertEqual(self.core.playback.single.get(), True) def test_set_loop_status_to_none_unsets_repeat_and_single(self): self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'None') - self.assertEquals(self.core.playback.repeat.get(), False) - self.assertEquals(self.core.playback.single.get(), False) + self.assertEqual(self.core.playback.repeat.get(), False) + self.assertEqual(self.core.playback.single.get(), False) def test_set_loop_status_to_track_sets_repeat_and_single(self): self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'Track') - self.assertEquals(self.core.playback.repeat.get(), True) - self.assertEquals(self.core.playback.single.get(), True) + self.assertEqual(self.core.playback.repeat.get(), True) + self.assertEqual(self.core.playback.single.get(), True) def test_set_loop_status_to_playlists_sets_repeat_and_not_single(self): self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'Playlist') - self.assertEquals(self.core.playback.repeat.get(), True) - self.assertEquals(self.core.playback.single.get(), False) + self.assertEqual(self.core.playback.repeat.get(), True) + self.assertEqual(self.core.playback.single.get(), False) def test_get_rate_is_greater_or_equal_than_minimum_rate(self): rate = self.mpris.Get(objects.PLAYER_IFACE, 'Rate') @@ -99,18 +99,20 @@ class PlayerInterfaceTest(unittest.TestCase): def test_set_rate_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Set(objects.PLAYER_IFACE, 'Rate', 0) - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_set_rate_to_zero_pauses_playback(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Set(objects.PLAYER_IFACE, 'Rate', 0) - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.state.get(), PAUSED) def test_get_shuffle_returns_true_if_random_is_active(self): self.core.playback.random = True @@ -143,37 +145,37 @@ class PlayerInterfaceTest(unittest.TestCase): def test_get_metadata_has_trackid_even_when_no_current_track(self): result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('mpris:trackid', result.keys()) - self.assertEquals(result['mpris:trackid'], '') + self.assertEqual(result['mpris:trackid'], '') def test_get_metadata_has_trackid_based_on_cpid(self): - self.core.current_playlist.append([Track(uri='a')]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.core.playback.play() (cpid, track) = self.core.playback.current_cp_track.get() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('mpris:trackid', result.keys()) - self.assertEquals( + self.assertEqual( result['mpris:trackid'], '/com/mopidy/track/%d' % cpid) def test_get_metadata_has_track_length(self): - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('mpris:length', result.keys()) - self.assertEquals(result['mpris:length'], 40000000) + self.assertEqual(result['mpris:length'], 40000000) def test_get_metadata_has_track_uri(self): - self.core.current_playlist.append([Track(uri='a')]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:url', result.keys()) - self.assertEquals(result['xesam:url'], 'a') + self.assertEqual(result['xesam:url'], 'dummy:a') def test_get_metadata_has_track_title(self): self.core.current_playlist.append([Track(name='a')]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:title', result.keys()) - self.assertEquals(result['xesam:title'], 'a') + self.assertEqual(result['xesam:title'], 'a') def test_get_metadata_has_track_artists(self): self.core.current_playlist.append([Track(artists=[ @@ -181,14 +183,14 @@ class PlayerInterfaceTest(unittest.TestCase): self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:artist', result.keys()) - self.assertEquals(result['xesam:artist'], ['a', 'b']) + self.assertEqual(result['xesam:artist'], ['a', 'b']) def test_get_metadata_has_track_album(self): self.core.current_playlist.append([Track(album=Album(name='a'))]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:album', result.keys()) - self.assertEquals(result['xesam:album'], 'a') + self.assertEqual(result['xesam:album'], 'a') def test_get_metadata_has_track_album_artists(self): self.core.current_playlist.append([Track(album=Album(artists=[ @@ -196,53 +198,53 @@ class PlayerInterfaceTest(unittest.TestCase): self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:albumArtist', result.keys()) - self.assertEquals(result['xesam:albumArtist'], ['a', 'b']) + self.assertEqual(result['xesam:albumArtist'], ['a', 'b']) def test_get_metadata_has_track_number_in_album(self): self.core.current_playlist.append([Track(track_no=7)]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:trackNumber', result.keys()) - self.assertEquals(result['xesam:trackNumber'], 7) + self.assertEqual(result['xesam:trackNumber'], 7) def test_get_volume_should_return_volume_between_zero_and_one(self): self.core.playback.volume = None result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') - self.assertEquals(result, 0) + self.assertEqual(result, 0) self.core.playback.volume = 0 result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') - self.assertEquals(result, 0) + self.assertEqual(result, 0) self.core.playback.volume = 50 result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') - self.assertEquals(result, 0.5) + self.assertEqual(result, 0.5) self.core.playback.volume = 100 result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') - self.assertEquals(result, 1) + self.assertEqual(result, 1) def test_set_volume_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False self.core.playback.volume = 0 self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) - self.assertEquals(self.core.playback.volume.get(), 0) + self.assertEqual(self.core.playback.volume.get(), 0) def test_set_volume_to_one_should_set_mixer_volume_to_100(self): self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) - self.assertEquals(self.core.playback.volume.get(), 100) + self.assertEqual(self.core.playback.volume.get(), 100) def test_set_volume_to_anything_above_one_sets_mixer_volume_to_100(self): self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 2.0) - self.assertEquals(self.core.playback.volume.get(), 100) + self.assertEqual(self.core.playback.volume.get(), 100) def test_set_volume_to_anything_not_a_number_does_not_change_volume(self): self.core.playback.volume = 10 self.mpris.Set(objects.PLAYER_IFACE, 'Volume', None) - self.assertEquals(self.core.playback.volume.get(), 10) + self.assertEqual(self.core.playback.volume.get(), 10) def test_get_position_returns_time_position_in_microseconds(self): - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(10000) result_in_microseconds = self.mpris.Get( @@ -254,7 +256,7 @@ class PlayerInterfaceTest(unittest.TestCase): result_in_microseconds = self.mpris.Get( objects.PLAYER_IFACE, 'Position') result_in_milliseconds = result_in_microseconds // 1000 - self.assertEquals(result_in_milliseconds, 0) + self.assertEqual(result_in_milliseconds, 0) def test_get_minimum_rate_is_one_or_less(self): result = self.mpris.Get(objects.PLAYER_IFACE, 'MinimumRate') @@ -266,14 +268,15 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_go_next_is_true_if_can_control_and_other_next_track(self): self.mpris.get_CanControl = lambda *_: True - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') self.assertTrue(result) def test_can_go_next_is_false_if_next_track_is_the_same(self): self.mpris.get_CanControl = lambda *_: True - self.core.current_playlist.append([Track(uri='a')]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.core.playback.repeat = True self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') @@ -281,14 +284,16 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_go_next_is_false_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') self.assertFalse(result) def test_can_go_previous_is_true_if_can_control_and_previous_track(self): self.mpris.get_CanControl = lambda *_: True - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') @@ -296,7 +301,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_go_previous_is_false_if_previous_track_is_the_same(self): self.mpris.get_CanControl = lambda *_: True - self.core.current_playlist.append([Track(uri='a')]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.core.playback.repeat = True self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') @@ -304,7 +309,8 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_go_previous_is_false_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') @@ -312,7 +318,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_play_is_true_if_can_control_and_current_track(self): self.mpris.get_CanControl = lambda *_: True - self.core.current_playlist.append([Track(uri='a')]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.core.playback.play() self.assertTrue(self.core.playback.current_track.get()) result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') @@ -355,220 +361,242 @@ class PlayerInterfaceTest(unittest.TestCase): def test_next_is_ignored_if_can_go_next_is_false(self): self.mpris.get_CanGoNext = lambda *_: False - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') self.mpris.Next() - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') def test_next_when_playing_skips_to_next_track_and_keep_playing(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.current_track.get().uri, 'a') - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Next() - self.assertEquals(self.core.playback.current_track.get().uri, 'b') - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_next_when_at_end_of_list_should_stop_playback(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() - self.assertEquals(self.core.playback.current_track.get().uri, 'b') - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Next() - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_next_when_paused_should_skip_to_next_track_and_stay_paused(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.pause() - self.assertEquals(self.core.playback.current_track.get().uri, 'a') - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') + self.assertEqual(self.core.playback.state.get(), PAUSED) self.mpris.Next() - self.assertEquals(self.core.playback.current_track.get().uri, 'b') - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') + self.assertEqual(self.core.playback.state.get(), PAUSED) def test_next_when_stopped_skips_to_next_track_and_stay_stopped(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.stop() - self.assertEquals(self.core.playback.current_track.get().uri, 'a') - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') + self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.Next() - self.assertEquals(self.core.playback.current_track.get().uri, 'b') - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_previous_is_ignored_if_can_go_previous_is_false(self): self.mpris.get_CanGoPrevious = lambda *_: False - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() - self.assertEquals(self.core.playback.current_track.get().uri, 'b') + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') self.mpris.Previous() - self.assertEquals(self.core.playback.current_track.get().uri, 'b') + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') def test_previous_when_playing_skips_to_prev_track_and_keep_playing(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() - self.assertEquals(self.core.playback.current_track.get().uri, 'b') - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Previous() - self.assertEquals(self.core.playback.current_track.get().uri, 'a') - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_previous_when_at_start_of_list_should_stop_playback(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.current_track.get().uri, 'a') - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Previous() - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_previous_when_paused_skips_to_previous_track_and_pause(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() self.core.playback.pause() - self.assertEquals(self.core.playback.current_track.get().uri, 'b') - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') + self.assertEqual(self.core.playback.state.get(), PAUSED) self.mpris.Previous() - self.assertEquals(self.core.playback.current_track.get().uri, 'a') - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') + self.assertEqual(self.core.playback.state.get(), PAUSED) def test_previous_when_stopped_skips_to_previous_track_and_stops(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() self.core.playback.stop() - self.assertEquals(self.core.playback.current_track.get().uri, 'b') - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') + self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.Previous() - self.assertEquals(self.core.playback.current_track.get().uri, 'a') - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_pause_is_ignored_if_can_pause_is_false(self): self.mpris.get_CanPause = lambda *_: False - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Pause() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_pause_when_playing_should_pause_playback(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Pause() - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.state.get(), PAUSED) def test_pause_when_paused_has_no_effect(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.pause() - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.state.get(), PAUSED) self.mpris.Pause() - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.state.get(), PAUSED) def test_playpause_is_ignored_if_can_pause_is_false(self): self.mpris.get_CanPause = lambda *_: False - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.PlayPause() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_playpause_when_playing_should_pause_playback(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.PlayPause() - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.state.get(), PAUSED) def test_playpause_when_paused_should_resume_playback(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.pause() - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.state.get(), PAUSED) at_pause = self.core.playback.time_position.get() self.assertGreaterEqual(at_pause, 0) self.mpris.PlayPause() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) after_pause = self.core.playback.time_position.get() self.assertGreaterEqual(after_pause, at_pause) def test_playpause_when_stopped_should_start_playback(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.PlayPause() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_stop_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Stop() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_stop_when_playing_should_stop_playback(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Stop() - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_stop_when_paused_should_stop_playback(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.pause() - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.state.get(), PAUSED) self.mpris.Stop() - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_play_is_ignored_if_can_play_is_false(self): self.mpris.get_CanPlay = lambda *_: False - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.Play() - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_play_when_stopped_starts_playback(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.Play() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_play_after_pause_resumes_from_same_position(self): - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() before_pause = self.core.playback.time_position.get() self.assertGreaterEqual(before_pause, 0) self.mpris.Pause() - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.state.get(), PAUSED) at_pause = self.core.playback.time_position.get() self.assertGreaterEqual(at_pause, before_pause) self.mpris.Play() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) after_pause = self.core.playback.time_position.get() self.assertGreaterEqual(after_pause, at_pause) def test_play_when_there_is_no_track_has_no_effect(self): self.core.current_playlist.clear() - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.Play() - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_seek_is_ignored_if_can_seek_is_false(self): self.mpris.get_CanSeek = lambda *_: False - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() before_seek = self.core.playback.time_position.get() @@ -584,7 +612,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertLess(after_seek, before_seek + milliseconds_to_seek) def test_seek_seeks_given_microseconds_forward_in_the_current_track(self): - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() before_seek = self.core.playback.time_position.get() @@ -595,13 +623,13 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Seek(microseconds_to_seek) - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) after_seek = self.core.playback.time_position.get() self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) def test_seek_seeks_given_microseconds_backward_if_negative(self): - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) @@ -613,14 +641,14 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Seek(microseconds_to_seek) - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) after_seek = self.core.playback.time_position.get() self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) self.assertLess(after_seek, before_seek) def test_seek_seeks_to_start_of_track_if_new_position_is_negative(self): - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) @@ -632,7 +660,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Seek(microseconds_to_seek) - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) after_seek = self.core.playback.time_position.get() self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) @@ -641,23 +669,23 @@ class PlayerInterfaceTest(unittest.TestCase): def test_seek_skips_to_next_track_if_new_position_gt_track_length(self): self.core.current_playlist.append([ - Track(uri='a', length=40000), - Track(uri='b')]) + Track(uri='dummy:a', length=40000), + Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.seek(20000) before_seek = self.core.playback.time_position.get() self.assertGreaterEqual(before_seek, 20000) - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') milliseconds_to_seek = 50000 microseconds_to_seek = milliseconds_to_seek * 1000 self.mpris.Seek(microseconds_to_seek) - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, 'b') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') after_seek = self.core.playback.time_position.get() self.assertGreaterEqual(after_seek, 0) @@ -665,7 +693,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_set_position_is_ignored_if_can_seek_is_false(self): self.mpris.get_CanSeek = lambda *_: False - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() before_set_position = self.core.playback.time_position.get() @@ -683,12 +711,12 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertLess(after_set_position, position_to_set_in_millisec) def test_set_position_sets_the_current_track_position_in_microsecs(self): - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() before_set_position = self.core.playback.time_position.get() self.assertLessEqual(before_set_position, 5000) - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) track_id = '/com/mopidy/track/0' @@ -697,22 +725,22 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.SetPosition(track_id, position_to_set_in_microsec) - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual( after_set_position, position_to_set_in_millisec) def test_set_position_does_nothing_if_the_position_is_negative(self): - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) before_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(before_set_position, 20000) self.assertLessEqual(before_set_position, 25000) - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') track_id = '/com/mopidy/track/0' @@ -723,19 +751,19 @@ class PlayerInterfaceTest(unittest.TestCase): after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(after_set_position, before_set_position) - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') def test_set_position_does_nothing_if_position_is_gt_track_length(self): - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) before_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(before_set_position, 20000) self.assertLessEqual(before_set_position, 25000) - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') track_id = 'a' @@ -746,19 +774,19 @@ class PlayerInterfaceTest(unittest.TestCase): after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(after_set_position, before_set_position) - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') def test_set_position_is_noop_if_track_id_isnt_current_track(self): - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) before_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(before_set_position, 20000) self.assertLessEqual(before_set_position, 25000) - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') track_id = 'b' @@ -769,15 +797,15 @@ class PlayerInterfaceTest(unittest.TestCase): after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(after_set_position, before_set_position) - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') def test_open_uri_is_ignored_if_can_play_is_false(self): self.mpris.get_CanPlay = lambda *_: False self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(len(self.core.current_playlist.tracks.get()), 0) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 0) def test_open_uri_ignores_uris_with_unknown_uri_scheme(self): self.assertListEqual(self.core.uri_schemes.get(), ['dummy']) @@ -785,56 +813,59 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.library.dummy_library = [ Track(uri='notdummy:/test/uri')] self.mpris.OpenUri('notdummy:/test/uri') - self.assertEquals(len(self.core.current_playlist.tracks.get()), 0) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 0) def test_open_uri_adds_uri_to_current_playlist(self): self.mpris.get_CanPlay = lambda *_: True self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals( + self.assertEqual( self.core.current_playlist.tracks.get()[0].uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_stopped(self): self.mpris.get_CanPlay = lambda *_: True self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals( + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual( self.core.playback.current_track.get().uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_paused(self): self.mpris.get_CanPlay = lambda *_: True self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.pause() - self.assertEquals(self.core.playback.state.get(), PAUSED) - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals( + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual( self.core.playback.current_track.get().uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_playing(self): self.mpris.get_CanPlay = lambda *_: True self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals( + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual( self.core.playback.current_track.get().uri, 'dummy:/test/uri') From 2d92a7a228a25267e3856685bf3213a683d66e83 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 02:23:54 +0200 Subject: [PATCH 121/233] Start multiple backends --- mopidy/__main__.py | 24 ++++++++++++------- mopidy/core/actor.py | 12 +++++----- tests/backends/base/current_playlist.py | 2 +- tests/backends/base/library.py | 2 +- tests/backends/base/playback.py | 2 +- tests/backends/base/stored_playlists.py | 2 +- tests/backends/events_test.py | 2 +- tests/frontends/mpd/dispatcher_test.py | 2 +- tests/frontends/mpd/protocol/__init__.py | 2 +- tests/frontends/mpd/status_test.py | 2 +- .../frontends/mpris/player_interface_test.py | 2 +- tests/frontends/mpris/root_interface_test.py | 2 +- 12 files changed, 31 insertions(+), 25 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 67fefde6..965cd9ba 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -54,8 +54,8 @@ def main(): check_old_folders() setup_settings(options.interactive) audio = setup_audio() - backend = setup_backend(audio) - core = setup_core(audio, backend) + backends = setup_backends(audio) + core = setup_core(audio, backends) setup_frontends(core) loop.run() except exceptions.SettingsError as ex: @@ -68,7 +68,7 @@ def main(): loop.quit() stop_frontends() stop_core() - stop_backend() + stop_backends() stop_audio() process.stop_remaining_actors() @@ -147,16 +147,22 @@ def stop_audio(): process.stop_actors_by_class(Audio) -def setup_backend(audio): - return importing.get_class(settings.BACKENDS[0]).start(audio=audio).proxy() +def setup_backends(audio): + backends = [] + for backend_class_name in settings.BACKENDS: + backend_class = importing.get_class(backend_class_name) + backend = backend_class.start(audio=audio).proxy() + backends.append(backend) + return backends -def stop_backend(): - process.stop_actors_by_class(importing.get_class(settings.BACKENDS[0])) +def stop_backends(): + for backend_class_name in settings.BACKENDS: + process.stop_actors_by_class(importing.get_class(backend_class_name)) -def setup_core(audio, backend): - return Core.start(audio=audio, backend=backend).proxy() +def setup_core(audio, backends): + return Core.start(audio=audio, backends=backends).proxy() def stop_core(): diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index a3766fff..0ad68a07 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -25,25 +25,25 @@ class Core(pykka.ThreadingActor, AudioListener): #: :class:`mopidy.core.StoredPlaylistsController`. stored_playlists = None - def __init__(self, audio=None, backend=None): + def __init__(self, audio=None, backends=None): super(Core, self).__init__() - self._backend = backend + self._backends = backends self.current_playlist = CurrentPlaylistController(core=self) - self.library = LibraryController(backend=backend, core=self) + self.library = LibraryController(backend=backends[0], core=self) self.playback = PlaybackController( - audio=audio, backend=backend, core=self) + audio=audio, backend=backends[0], core=self) self.stored_playlists = StoredPlaylistsController( - backend=backend, core=self) + backend=backends[0], core=self) @property def uri_schemes(self): """List of URI schemes we can handle""" - return self._backend.uri_schemes.get() + return self._backends[0].uri_schemes.get() def reached_end_of_stream(self): self.playback.on_end_of_track() diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index 9d86027e..2ba77ee3 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -16,7 +16,7 @@ class CurrentPlaylistControllerTest(object): def setUp(self): self.audio = mock.Mock(spec=audio.Audio) self.backend = self.backend_class.start(audio=self.audio).proxy() - self.core = core.Core(audio=audio, backend=self.backend) + self.core = core.Core(audio=audio, backends=[self.backend]) self.controller = self.core.current_playlist self.playback = self.core.playback diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index edaa704d..cc2a0004 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -23,7 +23,7 @@ class LibraryControllerTest(object): def setUp(self): self.backend = self.backend_class.start(audio=None).proxy() - self.core = core.Core(backend=self.backend) + self.core = core.Core(backends=[self.backend]) self.library = self.core.library def tearDown(self): diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 5a3b9157..cd55668c 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -18,7 +18,7 @@ class PlaybackControllerTest(object): def setUp(self): self.audio = mock.Mock(spec=audio.Audio) self.backend = self.backend_class.start(audio=self.audio).proxy() - self.core = core.Core(backend=self.backend) + self.core = core.Core(backends=[self.backend]) self.playback = self.core.playback self.current_playlist = self.core.current_playlist diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index c16be173..57096fd3 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -18,7 +18,7 @@ class StoredPlaylistsControllerTest(object): self.audio = mock.Mock(spec=audio.Audio) self.backend = self.backend_class.start(audio=self.audio).proxy() - self.core = core.Core(backend=self.backend) + self.core = core.Core(backends=[self.backend]) self.stored = self.core.stored_playlists def tearDown(self): diff --git a/tests/backends/events_test.py b/tests/backends/events_test.py index a25a73c2..600dbf6c 100644 --- a/tests/backends/events_test.py +++ b/tests/backends/events_test.py @@ -13,7 +13,7 @@ class BackendEventsTest(unittest.TestCase): def setUp(self): self.audio = mock.Mock(spec=audio.Audio) self.backend = dummy.DummyBackend.start(audio=audio).proxy() - self.core = core.Core.start(backend=self.backend).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() def tearDown(self): pykka.ActorRegistry.stop_all() diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/frontends/mpd/dispatcher_test.py index 1e108e07..9b047641 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -12,7 +12,7 @@ from tests import unittest class MpdDispatcherTest(unittest.TestCase): def setUp(self): self.backend = dummy.DummyBackend.start(audio=None).proxy() - self.core = core.Core.start(backend=self.backend).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() self.dispatcher = MpdDispatcher() def tearDown(self): diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index 4c6d3584..f7b055fc 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -23,7 +23,7 @@ class MockConnection(mock.Mock): class BaseTestCase(unittest.TestCase): def setUp(self): self.backend = dummy.DummyBackend.start(audio=None).proxy() - self.core = core.Core.start(backend=self.backend).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() self.connection = MockConnection() self.session = session.MpdSession(self.connection, core=self.core) diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 46f500e7..9f2395e5 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -21,7 +21,7 @@ STOPPED = PlaybackState.STOPPED class StatusHandlerTest(unittest.TestCase): def setUp(self): self.backend = dummy.DummyBackend.start(audio=None).proxy() - self.core = core.Core.start(backend=self.backend).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() self.dispatcher = dispatcher.MpdDispatcher(core=self.core) self.context = self.dispatcher.context diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index bd0c1728..620845e4 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -25,7 +25,7 @@ class PlayerInterfaceTest(unittest.TestCase): def setUp(self): objects.MprisObject._connect_to_dbus = mock.Mock() self.backend = dummy.DummyBackend.start(audio=None).proxy() - self.core = core.Core.start(backend=self.backend).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() self.mpris = objects.MprisObject(core=self.core) def tearDown(self): diff --git a/tests/frontends/mpris/root_interface_test.py b/tests/frontends/mpris/root_interface_test.py index d185895f..79a8b07f 100644 --- a/tests/frontends/mpris/root_interface_test.py +++ b/tests/frontends/mpris/root_interface_test.py @@ -20,7 +20,7 @@ class RootInterfaceTest(unittest.TestCase): objects.exit_process = mock.Mock() objects.MprisObject._connect_to_dbus = mock.Mock() self.backend = dummy.DummyBackend.start(audio=None).proxy() - self.core = core.Core.start(backend=self.backend).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() self.mpris = objects.MprisObject(core=self.core) def tearDown(self): From a5af7290ad7d49759c0b048a924a434b08c191c6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Oct 2012 11:26:13 +0200 Subject: [PATCH 122/233] Give the core controllers a list of backends --- mopidy/core/actor.py | 6 +++--- mopidy/core/current_playlist.py | 6 +----- mopidy/core/library.py | 19 ++++++------------- mopidy/core/playback.py | 28 ++++++++++++---------------- mopidy/core/stored_playlists.py | 31 ++++++++++++------------------- 5 files changed, 34 insertions(+), 56 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 0ad68a07..ea360055 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -32,13 +32,13 @@ class Core(pykka.ThreadingActor, AudioListener): self.current_playlist = CurrentPlaylistController(core=self) - self.library = LibraryController(backend=backends[0], core=self) + self.library = LibraryController(backends=backends, core=self) self.playback = PlaybackController( - audio=audio, backend=backends[0], core=self) + audio=audio, backends=backends, core=self) self.stored_playlists = StoredPlaylistsController( - backend=backends[0], core=self) + backends=backends, core=self) @property def uri_schemes(self): diff --git a/mopidy/core/current_playlist.py b/mopidy/core/current_playlist.py index 5aa7ed5d..6c484daf 100644 --- a/mopidy/core/current_playlist.py +++ b/mopidy/core/current_playlist.py @@ -6,15 +6,11 @@ from mopidy.models import CpTrack from . import listener + logger = logging.getLogger('mopidy.core') class CurrentPlaylistController(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - """ - pykka_traversable = True def __init__(self, core): diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 52f85b55..469b6160 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,15 +1,8 @@ class LibraryController(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - :param provider: provider the controller should use - :type provider: instance of :class:`BaseLibraryProvider` - """ - pykka_traversable = True - def __init__(self, backend, core): - self.backend = backend + def __init__(self, backends, core): + self.backends = backends self.core = core def find_exact(self, **query): @@ -29,7 +22,7 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - return self.backend.library.find_exact(**query).get() + return self.backends[0].library.find_exact(**query).get() def lookup(self, uri): """ @@ -39,7 +32,7 @@ class LibraryController(object): :type uri: string :rtype: :class:`mopidy.models.Track` or :class:`None` """ - return self.backend.library.lookup(uri).get() + return self.backends[0].library.lookup(uri).get() def refresh(self, uri=None): """ @@ -48,7 +41,7 @@ class LibraryController(object): :param uri: directory or track URI :type uri: string """ - return self.backend.library.refresh(uri).get() + return self.backends[0].library.refresh(uri).get() def search(self, **query): """ @@ -67,4 +60,4 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - return self.backend.library.search(**query).get() + return self.backends[0].library.search(**query).get() diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index d2411738..85faaa13 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -4,7 +4,7 @@ import random from . import listener -logger = logging.getLogger('mopidy.backends.base') +logger = logging.getLogger('mopidy.core') def option_wrapper(name, default): @@ -37,13 +37,6 @@ class PlaybackState(object): class PlaybackController(object): - """ - :param backend: the backend - :type backend: :class:`mopidy.backends.base.Backend` - :param provider: provider the controller should use - :type provider: instance of :class:`BasePlaybackProvider` - """ - # pylint: disable = R0902 # Too many instance attributes @@ -81,10 +74,13 @@ class PlaybackController(object): #: Playback continues after current song. single = option_wrapper('_single', False) - def __init__(self, audio, backend, core): + def __init__(self, audio, backends, core): self.audio = audio - self.backend = backend + + self.backends = backends + self.core = core + self._state = PlaybackState.STOPPED self._shuffled = [] self._first_shuffle = True @@ -295,7 +291,7 @@ class PlaybackController(object): @property def time_position(self): """Time position in milliseconds.""" - return self.backend.playback.get_time_position().get() + return self.backends[0].playback.get_time_position().get() @property def volume(self): @@ -381,7 +377,7 @@ class PlaybackController(object): def pause(self): """Pause playback.""" - if self.backend.playback.pause().get(): + if self.backends[0].playback.pause().get(): self.state = PlaybackState.PAUSED self._trigger_track_playback_paused() @@ -413,7 +409,7 @@ class PlaybackController(object): if cp_track is not None: self.current_cp_track = cp_track self.state = PlaybackState.PLAYING - if not self.backend.playback.play(cp_track.track).get(): + if not self.backends[0].playback.play(cp_track.track).get(): # Track is not playable if self.random and self._shuffled: self._shuffled.remove(cp_track) @@ -440,7 +436,7 @@ class PlaybackController(object): def resume(self): """If paused, resume playing the current track.""" if (self.state == PlaybackState.PAUSED and - self.backend.playback.resume().get()): + self.backends[0].playback.resume().get()): self.state = PlaybackState.PLAYING self._trigger_track_playback_resumed() @@ -466,7 +462,7 @@ class PlaybackController(object): self.next() return True - success = self.backend.playback.seek(time_position).get() + success = self.backends[0].playback.seek(time_position).get() if success: self._trigger_seeked(time_position) return success @@ -480,7 +476,7 @@ class PlaybackController(object): :type clear_current_track: boolean """ if self.state != PlaybackState.STOPPED: - if self.backend.playback.stop().get(): + if self.backends[0].playback.stop().get(): self._trigger_track_playback_ended() self.state = PlaybackState.STOPPED if clear_current_track: diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index 2c5ef752..d7bcbd0c 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -1,15 +1,8 @@ class StoredPlaylistsController(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - :param provider: provider the controller should use - :type provider: instance of :class:`BaseStoredPlaylistsProvider` - """ - pykka_traversable = True - def __init__(self, backend, core): - self.backend = backend + def __init__(self, backends, core): + self.backends = backends self.core = core @property @@ -19,11 +12,11 @@ class StoredPlaylistsController(object): Read/write. List of :class:`mopidy.models.Playlist`. """ - return self.backend.stored_playlists.playlists.get() + return self.backends[0].stored_playlists.playlists.get() @playlists.setter # noqa def playlists(self, playlists): - self.backend.stored_playlists.playlists = playlists + self.backends[0].stored_playlists.playlists = playlists def create(self, name): """ @@ -33,7 +26,7 @@ class StoredPlaylistsController(object): :type name: string :rtype: :class:`mopidy.models.Playlist` """ - return self.backend.stored_playlists.create(name).get() + return self.backends[0].stored_playlists.create(name).get() def delete(self, playlist): """ @@ -42,7 +35,7 @@ class StoredPlaylistsController(object): :param playlist: the playlist to delete :type playlist: :class:`mopidy.models.Playlist` """ - return self.backend.stored_playlists.delete(playlist).get() + return self.backends[0].stored_playlists.delete(playlist).get() def get(self, **criteria): """ @@ -83,14 +76,13 @@ class StoredPlaylistsController(object): :type uri: string :rtype: :class:`mopidy.models.Playlist` """ - return self.backend.stored_playlists.lookup(uri).get() + return self.backends[0].stored_playlists.lookup(uri).get() def refresh(self): """ - Refresh the stored playlists in - :attr:`mopidy.backends.base.StoredPlaylistsController.playlists`. + Refresh the stored playlists in :attr:`playlists`. """ - return self.backend.stored_playlists.refresh().get() + return self.backends[0].stored_playlists.refresh().get() def rename(self, playlist, new_name): """ @@ -101,7 +93,8 @@ class StoredPlaylistsController(object): :param new_name: the new name :type new_name: string """ - return self.backend.stored_playlists.rename(playlist, new_name).get() + return self.backends[0].stored_playlists.rename( + playlist, new_name).get() def save(self, playlist): """ @@ -110,4 +103,4 @@ class StoredPlaylistsController(object): :param playlist: the playlist :type playlist: :class:`mopidy.models.Playlist` """ - return self.backend.stored_playlists.save(playlist).get() + return self.backends[0].stored_playlists.save(playlist).get() From 11ddc42120874920a858c641f924e19a92bb6cf1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 11:10:20 +0100 Subject: [PATCH 123/233] Update descriptions of settings --- docs/api/backends.rst | 2 ++ docs/api/frontends.rst | 2 ++ mopidy/settings.py | 15 ++++++++++----- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/api/backends.rst b/docs/api/backends.rst index 781723d6..a1aa48a0 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -30,6 +30,8 @@ Library provider :members: +.. _backend-implementations: + Backend implementations ======================= diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index 36626fa0..fc54a8a2 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -37,6 +37,8 @@ The following requirements applies to any frontend implementation: specified events. +.. _frontend-implementations: + Frontend implementations ======================== diff --git a/mopidy/settings.py b/mopidy/settings.py index 98f7e05e..31de4a6e 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -7,7 +7,7 @@ All available settings and their default values. file called ``~/.config/mopidy/settings.py`` and redefine settings there. """ -#: List of playback backends to use. See :mod:`mopidy.backends` for all +#: List of playback backends to use. See :ref:`backend-implementations` for all #: available backends. #: #: Default:: @@ -54,7 +54,8 @@ DEBUG_LOG_FILENAME = u'mopidy.log' #: DESKTOP_FILE = u'/usr/share/applications/mopidy.desktop' DESKTOP_FILE = u'/usr/share/applications/mopidy.desktop' -#: List of server frontends to use. +#: List of server frontends to use. See :ref:`frontend-implementations` for +#: available frontends. #: #: Default:: #: @@ -106,7 +107,7 @@ LOCAL_PLAYLIST_PATH = u'$XDG_DATA_DIR/mopidy/playlists' #: LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache' LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache' -#: Sound mixer to use. +#: Audio mixer to use. #: #: Expects a GStreamer mixer to use, typical values are: #: ``alsamixer``, ``pulsemixer``, ``ossmixer``, and ``oss4mixer``. @@ -119,7 +120,7 @@ LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache' #: MIXER = u'autoaudiomixer' MIXER = u'autoaudiomixer' -#: Sound mixer track to use. +#: Audio mixer track to use. #: #: Name of the mixer track to use. If this is not set we will try to find the #: master output track. As an example, using ``alsamixer`` you would @@ -167,7 +168,11 @@ MPD_SERVER_PASSWORD = None #: Default: 20 MPD_SERVER_MAX_CONNECTIONS = 20 -#: Output to use. See :mod:`mopidy.outputs` for all available backends +#: Audio output to use. +#: +#: Expects a GStreamer sink. Typical values are ``autoaudiosink``, +#: ``alsasink``, ``osssink``, ``oss4sink``, ``pulsesink``, and ``shout2send``, +#: and additional arguments specific to each sink. #: #: Default:: #: From 7b9c682e951fcd41c10fb633a929ab9ca8c311e5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 02:24:58 +0200 Subject: [PATCH 124/233] Make core.uri_schemes include URI schemes from all backends --- mopidy/core/actor.py | 5 ++++- tests/core/actor_test.py | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 tests/core/actor_test.py diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index ea360055..f5de038d 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -43,7 +43,10 @@ class Core(pykka.ThreadingActor, AudioListener): @property def uri_schemes(self): """List of URI schemes we can handle""" - return self._backends[0].uri_schemes.get() + futures = [backend.uri_schemes for backend in self._backends] + results = pykka.get_all(futures) + schemes = [uri_scheme for result in results for uri_scheme in result] + return sorted(schemes) def reached_end_of_stream(self): self.playback.on_end_of_track() diff --git a/tests/core/actor_test.py b/tests/core/actor_test.py new file mode 100644 index 00000000..95639cf8 --- /dev/null +++ b/tests/core/actor_test.py @@ -0,0 +1,26 @@ +import mock +import pykka + +from mopidy.core import Core + +from tests import unittest + + +class CoreActorTest(unittest.TestCase): + def setUp(self): + self.backend1 = mock.Mock() + self.backend1.uri_schemes.get.return_value = ['dummy1'] + + self.backend2 = mock.Mock() + self.backend2.uri_schemes.get.return_value = ['dummy2'] + + self.core = Core(audio=None, backends=[self.backend1, self.backend2]) + + def tearDown(self): + pykka.ActorRegistry.stop_all() + + def test_uri_schemes_has_uris_from_all_backends(self): + result = self.core.uri_schemes + + self.assertIn('dummy1', result) + self.assertIn('dummy2', result) From c47cec9e654a055459a8059d96359b32bcfde5af Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Oct 2012 11:27:54 +0200 Subject: [PATCH 125/233] Make core.playback select backend based on track URI --- mopidy/core/playback.py | 28 +++++++-- tests/core/playback_test.py | 118 ++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 tests/core/playback_test.py diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 85faaa13..4cef8db6 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -78,6 +78,11 @@ class PlaybackController(object): self.audio = audio self.backends = backends + uri_schemes_by_backend = {backend: backend.uri_schemes.get() + for backend in backends} + self.backends_by_uri_scheme = {uri_scheme: backend + for backend, uri_schemes in uri_schemes_by_backend.items() + for uri_scheme in uri_schemes} self.core = core @@ -86,6 +91,13 @@ class PlaybackController(object): self._first_shuffle = True self._volume = None + def _get_backend(self): + if self.current_cp_track is None: + return None + track = self.current_cp_track.track + uri_scheme = track.uri.split(':', 1)[0] + return self.backends_by_uri_scheme[uri_scheme] + def _get_cpid(self, cp_track): if cp_track is None: return None @@ -291,7 +303,10 @@ class PlaybackController(object): @property def time_position(self): """Time position in milliseconds.""" - return self.backends[0].playback.get_time_position().get() + backend = self._get_backend() + if backend is None: + return 0 + return backend.playback.get_time_position().get() @property def volume(self): @@ -377,7 +392,8 @@ class PlaybackController(object): def pause(self): """Pause playback.""" - if self.backends[0].playback.pause().get(): + backend = self._get_backend() + if backend is None or backend.playback.pause().get(): self.state = PlaybackState.PAUSED self._trigger_track_playback_paused() @@ -409,7 +425,7 @@ class PlaybackController(object): if cp_track is not None: self.current_cp_track = cp_track self.state = PlaybackState.PLAYING - if not self.backends[0].playback.play(cp_track.track).get(): + if not self._get_backend().playback.play(cp_track.track).get(): # Track is not playable if self.random and self._shuffled: self._shuffled.remove(cp_track) @@ -436,7 +452,7 @@ class PlaybackController(object): def resume(self): """If paused, resume playing the current track.""" if (self.state == PlaybackState.PAUSED and - self.backends[0].playback.resume().get()): + self._get_backend().playback.resume().get()): self.state = PlaybackState.PLAYING self._trigger_track_playback_resumed() @@ -462,7 +478,7 @@ class PlaybackController(object): self.next() return True - success = self.backends[0].playback.seek(time_position).get() + success = self._get_backend().playback.seek(time_position).get() if success: self._trigger_seeked(time_position) return success @@ -476,7 +492,7 @@ class PlaybackController(object): :type clear_current_track: boolean """ if self.state != PlaybackState.STOPPED: - if self.backends[0].playback.stop().get(): + if self._get_backend().playback.stop().get(): self._trigger_track_playback_ended() self.state = PlaybackState.STOPPED if clear_current_track: diff --git a/tests/core/playback_test.py b/tests/core/playback_test.py new file mode 100644 index 00000000..b3a75773 --- /dev/null +++ b/tests/core/playback_test.py @@ -0,0 +1,118 @@ +import mock + +from mopidy.backends import base +from mopidy.core import Core +from mopidy.models import Track + +from tests import unittest + + +class CorePlaybackTest(unittest.TestCase): + def setUp(self): + self.backend1 = mock.Mock() + self.backend1.uri_schemes.get.return_value = ['dummy1'] + self.playback1 = mock.Mock(spec=base.BasePlaybackProvider) + self.backend1.playback = self.playback1 + + self.backend2 = mock.Mock() + self.backend2.uri_schemes.get.return_value = ['dummy2'] + self.playback2 = mock.Mock(spec=base.BasePlaybackProvider) + self.backend2.playback = self.playback2 + + self.tracks = [ + Track(uri='dummy1://foo', length=40000), + Track(uri='dummy1://bar', length=40000), + Track(uri='dummy2://foo', length=40000), + Track(uri='dummy2://bar', length=40000), + ] + + self.core = Core(audio=None, backends=[self.backend1, self.backend2]) + self.core.current_playlist.append(self.tracks) + + self.cp_tracks = self.core.current_playlist.cp_tracks + + def test_play_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + + self.playback1.play.assert_called_once_with(self.tracks[0]) + self.assertFalse(self.playback2.play.called) + + def test_play_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + + self.assertFalse(self.playback1.play.called) + self.playback2.play.assert_called_once_with(self.tracks[2]) + + def test_pause_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + self.core.playback.pause() + + self.playback1.pause.assert_called_once_with() + self.assertFalse(self.playback2.pause.called) + + def test_pause_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + self.core.playback.pause() + + self.assertFalse(self.playback1.pause.called) + self.playback2.pause.assert_called_once_with() + + def test_resume_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + self.core.playback.pause() + self.core.playback.resume() + + self.playback1.resume.assert_called_once_with() + self.assertFalse(self.playback2.resume.called) + + def test_resume_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + self.core.playback.pause() + self.core.playback.resume() + + self.assertFalse(self.playback1.resume.called) + self.playback2.resume.assert_called_once_with() + + def test_stop_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + self.core.playback.stop() + + self.playback1.stop.assert_called_once_with() + self.assertFalse(self.playback2.stop.called) + + def test_stop_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + self.core.playback.stop() + + self.assertFalse(self.playback1.stop.called) + self.playback2.stop.assert_called_once_with() + + def test_seek_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + self.core.playback.seek(10000) + + self.playback1.seek.assert_called_once_with(10000) + self.assertFalse(self.playback2.seek.called) + + def test_seek_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + self.core.playback.seek(10000) + + self.assertFalse(self.playback1.seek.called) + self.playback2.seek.assert_called_once_with(10000) + + def test_time_position_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + self.core.playback.seek(10000) + self.core.playback.time_position + + self.playback1.get_time_position.assert_called_once_with() + self.assertFalse(self.playback2.get_time_position.called) + + def test_time_position_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + self.core.playback.seek(10000) + self.core.playback.time_position + + self.assertFalse(self.playback1.get_time_position.called) + self.playback2.get_time_position.assert_called_once_with() From a35deec0507497c7b4d68869d0ae4e34f36f1ff8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Oct 2012 14:47:56 +0200 Subject: [PATCH 126/233] Make core.library support multiple backends --- mopidy/core/library.py | 43 ++++++++++++++++++-- tests/core/library_test.py | 82 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 tests/core/library_test.py diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 469b6160..80d9cbe5 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,10 +1,25 @@ +import pykka + +from mopidy.models import Playlist + + class LibraryController(object): pykka_traversable = True def __init__(self, backends, core): self.backends = backends + uri_schemes_by_backend = {backend: backend.uri_schemes.get() + for backend in backends} + self.backends_by_uri_scheme = {uri_scheme: backend + for backend, uri_schemes in uri_schemes_by_backend.items() + for uri_scheme in uri_schemes} + self.core = core + def _get_backend(self, uri): + uri_scheme = uri.split(':', 1)[0] + return self.backends_by_uri_scheme.get(uri_scheme) + def find_exact(self, **query): """ Search the library for tracks where ``field`` is ``values``. @@ -22,7 +37,12 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - return self.backends[0].library.find_exact(**query).get() + futures = [] + for backend in self.backends: + futures.append(backend.library.find_exact(**query)) + results = pykka.get_all(futures) + return Playlist(tracks=[ + track for playlist in results for track in playlist.tracks]) def lookup(self, uri): """ @@ -32,7 +52,9 @@ class LibraryController(object): :type uri: string :rtype: :class:`mopidy.models.Track` or :class:`None` """ - return self.backends[0].library.lookup(uri).get() + backend = self._get_backend(uri) + if backend: + return backend.library.lookup(uri).get() def refresh(self, uri=None): """ @@ -41,7 +63,15 @@ class LibraryController(object): :param uri: directory or track URI :type uri: string """ - return self.backends[0].library.refresh(uri).get() + if uri is not None: + backend = self._get_backend(uri) + if backend: + return backend.library.refresh(uri).get() + else: + futures = [] + for backend in self.backends: + futures.append(backend.library.refresh(uri)) + return pykka.get_all(futures) def search(self, **query): """ @@ -60,4 +90,9 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - return self.backends[0].library.search(**query).get() + futures = [] + for backend in self.backends: + futures.append(backend.library.search(**query)) + results = pykka.get_all(futures) + return Playlist(tracks=[ + track for playlist in results for track in playlist.tracks]) diff --git a/tests/core/library_test.py b/tests/core/library_test.py new file mode 100644 index 00000000..04f19909 --- /dev/null +++ b/tests/core/library_test.py @@ -0,0 +1,82 @@ +import mock + +from mopidy.backends import base +from mopidy.core import Core +from mopidy.models import Playlist, Track + +from tests import unittest + + +class CoreLibraryTest(unittest.TestCase): + def setUp(self): + self.backend1 = mock.Mock() + self.backend1.uri_schemes.get.return_value = ['dummy1'] + self.library1 = mock.Mock(spec=base.BaseLibraryProvider) + self.backend1.library = self.library1 + + self.backend2 = mock.Mock() + self.backend2.uri_schemes.get.return_value = ['dummy2'] + self.library2 = mock.Mock(spec=base.BaseLibraryProvider) + self.backend2.library = self.library2 + + self.core = Core(audio=None, backends=[self.backend1, self.backend2]) + + 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_refresh_with_uri_selects_dummy1_backend(self): + self.core.library.refresh('dummy1:a') + + self.library1.refresh.assert_called_once_with('dummy1:a') + self.assertFalse(self.library2.refresh.called) + + def test_refresh_with_uri_selects_dummy2_backend(self): + self.core.library.refresh('dummy2:a') + + self.assertFalse(self.library1.refresh.called) + self.library2.refresh.assert_called_once_with('dummy2:a') + + def test_refresh_without_uri_calls_all_backends(self): + self.core.library.refresh() + + self.library1.refresh.assert_called_once_with(None) + self.library2.refresh.assert_called_once_with(None) + + def test_find_exact_combines_results_from_all_backends(self): + track1 = Track(uri='dummy1:a') + track2 = Track(uri='dummy2:a') + self.library1.find_exact().get.return_value = Playlist(tracks=[track1]) + self.library1.find_exact.reset_mock() + self.library2.find_exact().get.return_value = Playlist(tracks=[track2]) + self.library2.find_exact.reset_mock() + + result = self.core.library.find_exact(any=['a']) + + self.assertIn(track1, result.tracks) + self.assertIn(track2, result.tracks) + self.library1.find_exact.assert_called_once_with(any=['a']) + self.library2.find_exact.assert_called_once_with(any=['a']) + + def test_search_combines_results_from_all_backends(self): + track1 = Track(uri='dummy1:a') + track2 = Track(uri='dummy2:a') + self.library1.search().get.return_value = Playlist(tracks=[track1]) + self.library1.search.reset_mock() + self.library2.search().get.return_value = Playlist(tracks=[track2]) + self.library2.search.reset_mock() + + result = self.core.library.search(any=['a']) + + self.assertIn(track1, result.tracks) + self.assertIn(track2, result.tracks) + self.library1.search.assert_called_once_with(any=['a']) + self.library2.search.assert_called_once_with(any=['a']) From 0641d2d2074cc4e7da80e13410ada5387ded7421 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Oct 2012 23:07:19 +0200 Subject: [PATCH 127/233] Make core.stored_playlists.playlists support multiple backends --- mopidy/core/stored_playlists.py | 15 ++++++++++- tests/core/stored_playlists_test.py | 41 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 tests/core/stored_playlists_test.py diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index d7bcbd0c..4a8f5463 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -1,3 +1,6 @@ +import pykka + + class StoredPlaylistsController(object): pykka_traversable = True @@ -12,10 +15,14 @@ class StoredPlaylistsController(object): Read/write. List of :class:`mopidy.models.Playlist`. """ - return self.backends[0].stored_playlists.playlists.get() + futures = [backend.stored_playlists.playlists + for backend in self.backends] + results = pykka.get_all(futures) + return [playlist for result in results for playlist in result] @playlists.setter # noqa def playlists(self, playlists): + # TODO Support multiple backends self.backends[0].stored_playlists.playlists = playlists def create(self, name): @@ -26,6 +33,7 @@ class StoredPlaylistsController(object): :type name: string :rtype: :class:`mopidy.models.Playlist` """ + # TODO Support multiple backends return self.backends[0].stored_playlists.create(name).get() def delete(self, playlist): @@ -35,6 +43,7 @@ class StoredPlaylistsController(object): :param playlist: the playlist to delete :type playlist: :class:`mopidy.models.Playlist` """ + # TODO Support multiple backends return self.backends[0].stored_playlists.delete(playlist).get() def get(self, **criteria): @@ -76,12 +85,14 @@ class StoredPlaylistsController(object): :type uri: string :rtype: :class:`mopidy.models.Playlist` """ + # TODO Support multiple backends return self.backends[0].stored_playlists.lookup(uri).get() def refresh(self): """ Refresh the stored playlists in :attr:`playlists`. """ + # TODO Support multiple backends return self.backends[0].stored_playlists.refresh().get() def rename(self, playlist, new_name): @@ -93,6 +104,7 @@ class StoredPlaylistsController(object): :param new_name: the new name :type new_name: string """ + # TODO Support multiple backends return self.backends[0].stored_playlists.rename( playlist, new_name).get() @@ -103,4 +115,5 @@ class StoredPlaylistsController(object): :param playlist: the playlist :type playlist: :class:`mopidy.models.Playlist` """ + # TODO Support multiple backends return self.backends[0].stored_playlists.save(playlist).get() diff --git a/tests/core/stored_playlists_test.py b/tests/core/stored_playlists_test.py new file mode 100644 index 00000000..d92b89c0 --- /dev/null +++ b/tests/core/stored_playlists_test.py @@ -0,0 +1,41 @@ +import mock + +from mopidy.backends import base +from mopidy.core import Core +from mopidy.models import Playlist, Track + +from tests import unittest + + +class StoredPlaylistsTest(unittest.TestCase): + def setUp(self): + self.backend1 = mock.Mock() + self.backend1.uri_schemes.get.return_value = ['dummy1'] + self.sp1 = mock.Mock(spec=base.BaseStoredPlaylistsProvider) + self.backend1.stored_playlists = self.sp1 + + self.backend2 = mock.Mock() + self.backend2.uri_schemes.get.return_value = ['dummy2'] + self.sp2 = mock.Mock(spec=base.BaseStoredPlaylistsProvider) + self.backend2.stored_playlists = self.sp2 + + self.pl1a = Playlist(tracks=[Track(uri='dummy1:a')]) + self.pl1b = Playlist(tracks=[Track(uri='dummy1:b')]) + self.sp1.playlists.get.return_value = [self.pl1a, self.pl1b] + + self.pl2a = Playlist(tracks=[Track(uri='dummy2:a')]) + self.pl2b = Playlist(tracks=[Track(uri='dummy2:b')]) + self.sp2.playlists.get.return_value = [self.pl2a, self.pl2b] + + self.core = Core(audio=None, backends=[self.backend1, self.backend2]) + + def test_get_playlists_combines_result_from_backends(self): + result = self.core.stored_playlists.playlists + + self.assertIn(self.pl1a, result) + self.assertIn(self.pl1b, result) + self.assertIn(self.pl2a, result) + self.assertIn(self.pl2b, result) + + # TODO The rest of the stored playlists API is pending redesign before + # we'll update it to support multiple backends. From d450e5a238795e194fed2c68fb116596fcef9e8a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 11:11:05 +0100 Subject: [PATCH 128/233] Turn both local and Spotify backend on by default --- mopidy/settings.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/mopidy/settings.py b/mopidy/settings.py index 31de4a6e..c1f35887 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -10,17 +10,17 @@ All available settings and their default values. #: List of playback backends to use. See :ref:`backend-implementations` for all #: available backends. #: +#: When results from multiple backends are combined, they are combined in the +#: order the backends are listed here. +#: #: Default:: #: -#: BACKENDS = (u'mopidy.backends.spotify.SpotifyBackend',) -#: -#: Other typical values:: -#: -#: BACKENDS = (u'mopidy.backends.local.LocalBackend',) -#: -#: .. note:: -#: Currently only the first backend in the list is used. +#: BACKENDS = ( +#: u'mopidy.backends.local.LocalBackend', +#: u'mopidy.backends.spotify.SpotifyBackend', +#: ) BACKENDS = ( + u'mopidy.backends.local.LocalBackend', u'mopidy.backends.spotify.SpotifyBackend', ) From 9a617b180372972add84f6dc41e2c10bb93c86e5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 20:58:51 +0100 Subject: [PATCH 129/233] Improvements after code review --- mopidy/core/actor.py | 6 ++-- mopidy/core/library.py | 28 +++++++++---------- mopidy/core/playback.py | 11 +++++--- tests/frontends/mpd/protocol/playback_test.py | 8 +++--- 4 files changed, 29 insertions(+), 24 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index f5de038d..05c085fd 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -1,3 +1,5 @@ +import itertools + import pykka from mopidy.audio import AudioListener @@ -45,8 +47,8 @@ class Core(pykka.ThreadingActor, AudioListener): """List of URI schemes we can handle""" futures = [backend.uri_schemes for backend in self._backends] results = pykka.get_all(futures) - schemes = [uri_scheme for result in results for uri_scheme in result] - return sorted(schemes) + uri_schemes = itertools.chain(*results) + return sorted(uri_schemes) def reached_end_of_stream(self): self.playback.on_end_of_track() diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 80d9cbe5..37c8c522 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,3 +1,5 @@ +import urlparse + import pykka from mopidy.models import Playlist @@ -8,16 +10,18 @@ class LibraryController(object): def __init__(self, backends, core): self.backends = backends - uri_schemes_by_backend = {backend: backend.uri_schemes.get() + uri_schemes_by_backend = { + backend: backend.uri_schemes.get() for backend in backends} - self.backends_by_uri_scheme = {uri_scheme: backend + self.backends_by_uri_scheme = { + uri_scheme: backend for backend, uri_schemes in uri_schemes_by_backend.items() for uri_scheme in uri_schemes} self.core = core def _get_backend(self, uri): - uri_scheme = uri.split(':', 1)[0] + uri_scheme = urlparse.urlparse(uri).scheme return self.backends_by_uri_scheme.get(uri_scheme) def find_exact(self, **query): @@ -37,9 +41,7 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - futures = [] - for backend in self.backends: - futures.append(backend.library.find_exact(**query)) + futures = [b.library.find_exact(**query) for b in self.backends] results = pykka.get_all(futures) return Playlist(tracks=[ track for playlist in results for track in playlist.tracks]) @@ -55,6 +57,8 @@ class LibraryController(object): backend = self._get_backend(uri) if backend: return backend.library.lookup(uri).get() + else: + return None def refresh(self, uri=None): """ @@ -66,12 +70,10 @@ class LibraryController(object): if uri is not None: backend = self._get_backend(uri) if backend: - return backend.library.refresh(uri).get() + backend.library.refresh(uri).get() else: - futures = [] - for backend in self.backends: - futures.append(backend.library.refresh(uri)) - return pykka.get_all(futures) + futures = [b.library.refresh(uri) for b in self.backends] + pykka.get_all(futures) def search(self, **query): """ @@ -90,9 +92,7 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - futures = [] - for backend in self.backends: - futures.append(backend.library.search(**query)) + futures = [b.library.search(**query) for b in self.backends] results = pykka.get_all(futures) return Playlist(tracks=[ track for playlist in results for track in playlist.tracks]) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 4cef8db6..721bc2a8 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -1,5 +1,6 @@ import logging import random +import urlparse from . import listener @@ -78,9 +79,11 @@ class PlaybackController(object): self.audio = audio self.backends = backends - uri_schemes_by_backend = {backend: backend.uri_schemes.get() + uri_schemes_by_backend = { + backend: backend.uri_schemes.get() for backend in backends} - self.backends_by_uri_scheme = {uri_scheme: backend + self.backends_by_uri_scheme = { + uri_scheme: backend for backend, uri_schemes in uri_schemes_by_backend.items() for uri_scheme in uri_schemes} @@ -94,8 +97,8 @@ class PlaybackController(object): def _get_backend(self): if self.current_cp_track is None: return None - track = self.current_cp_track.track - uri_scheme = track.uri.split(':', 1)[0] + uri = self.current_cp_track.track.uri + uri_scheme = urlparse.urlparse(uri).scheme return self.backends_by_uri_scheme[uri_scheme] def _get_cpid(self, cp_track): diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index ab254bdf..202ac649 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -392,9 +392,9 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_seek_with_songpos(self): - seek_track = Track(uri='dummy:2', length=40000) + seek_track = Track(uri='dummy:b', length=40000) self.core.current_playlist.append( - [Track(uri='dummy:1', length=40000), seek_track]) + [Track(uri='dummy:a', length=40000), seek_track]) self.sendRequest(u'seek "1" "30"') self.assertEqual(self.core.playback.current_track.get(), seek_track) @@ -417,9 +417,9 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_seekid_with_cpid(self): - seek_track = Track(uri='dummy:2', length=40000) + seek_track = Track(uri='dummy:b', length=40000) self.core.current_playlist.append( - [Track(uri='dummy:1', length=40000), seek_track]) + [Track(uri='dummy:a', length=40000), seek_track]) self.sendRequest(u'seekid "1" "30"') self.assertEqual(1, self.core.playback.current_cpid.get()) From 4f411a48d6bd8875009359cff8350461814fb67f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 21:22:09 +0100 Subject: [PATCH 130/233] Update docstring references --- mopidy/backends/base.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index e8a7decd..7ae2c3dc 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -37,7 +37,7 @@ class BaseLibraryProvider(object): def find_exact(self, **query): """ - See :meth:`mopidy.backends.base.LibraryController.find_exact`. + See :meth:`mopidy.core.LibraryController.find_exact`. *MUST be implemented by subclass.* """ @@ -45,7 +45,7 @@ class BaseLibraryProvider(object): def lookup(self, uri): """ - See :meth:`mopidy.backends.base.LibraryController.lookup`. + See :meth:`mopidy.core.LibraryController.lookup`. *MUST be implemented by subclass.* """ @@ -53,7 +53,7 @@ class BaseLibraryProvider(object): def refresh(self, uri=None): """ - See :meth:`mopidy.backends.base.LibraryController.refresh`. + See :meth:`mopidy.core.LibraryController.refresh`. *MUST be implemented by subclass.* """ @@ -61,7 +61,7 @@ class BaseLibraryProvider(object): def search(self, **query): """ - See :meth:`mopidy.backends.base.LibraryController.search`. + See :meth:`mopidy.core.LibraryController.search`. *MUST be implemented by subclass.* """ @@ -174,7 +174,7 @@ class BaseStoredPlaylistsProvider(object): def create(self, name): """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.create`. + See :meth:`mopidy.core.StoredPlaylistsController.create`. *MUST be implemented by subclass.* """ @@ -182,7 +182,7 @@ class BaseStoredPlaylistsProvider(object): def delete(self, playlist): """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.delete`. + See :meth:`mopidy.core.StoredPlaylistsController.delete`. *MUST be implemented by subclass.* """ @@ -190,7 +190,7 @@ class BaseStoredPlaylistsProvider(object): def lookup(self, uri): """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.lookup`. + See :meth:`mopidy.core.StoredPlaylistsController.lookup`. *MUST be implemented by subclass.* """ @@ -198,7 +198,7 @@ class BaseStoredPlaylistsProvider(object): def refresh(self): """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.refresh`. + See :meth:`mopidy.core.StoredPlaylistsController.refresh`. *MUST be implemented by subclass.* """ @@ -206,7 +206,7 @@ class BaseStoredPlaylistsProvider(object): def rename(self, playlist, new_name): """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.rename`. + See :meth:`mopidy.core.StoredPlaylistsController.rename`. *MUST be implemented by subclass.* """ @@ -214,7 +214,7 @@ class BaseStoredPlaylistsProvider(object): def save(self, playlist): """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.save`. + See :meth:`mopidy.core.StoredPlaylistsController.save`. *MUST be implemented by subclass.* """ From 29a19a8b27d747ab8388eaa03b1485537f1139ac Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 21:26:10 +0100 Subject: [PATCH 131/233] Document parameter --- mopidy/core/current_playlist.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/core/current_playlist.py b/mopidy/core/current_playlist.py index 6c484daf..fb296a52 100644 --- a/mopidy/core/current_playlist.py +++ b/mopidy/core/current_playlist.py @@ -67,6 +67,8 @@ class CurrentPlaylistController(object): :type track: :class:`mopidy.models.Track` :param at_position: position in current playlist to add track :type at_position: int or :class:`None` + :param increase_version: if the playlist version should be increased + :type increase_version: :class:`True` or :class:`False` :rtype: two-tuple of (CPID integer, :class:`mopidy.models.Track`) that was added to the current playlist playlist """ From a6200415842b2bd1cf06856e0f157afed6ee1d07 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 21:37:37 +0100 Subject: [PATCH 132/233] More improvements after code review --- mopidy/core/stored_playlists.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index 4a8f5463..9de1545f 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -1,3 +1,5 @@ +import itertools + import pykka @@ -15,10 +17,9 @@ class StoredPlaylistsController(object): Read/write. List of :class:`mopidy.models.Playlist`. """ - futures = [backend.stored_playlists.playlists - for backend in self.backends] + futures = [b.stored_playlists.playlists for b in self.backends] results = pykka.get_all(futures) - return [playlist for result in results for playlist in result] + return list(itertools.chain(*results)) @playlists.setter # noqa def playlists(self, playlists): From c2cde5267ab35a91f7ce048907f703e750a1722e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 21:49:29 +0100 Subject: [PATCH 133/233] Update changelog with multi-backend changes --- docs/changes.rst | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index c68db685..5a17c810 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -12,6 +12,54 @@ v0.9.0 (in development) - Pykka >= 1.0 is now required. +**Multiple backends support** + +Support for using the local and Spotify backends simultaneously have for a very +long time been our most requested feature. Finally, it's here! + +- Both the local backend and the Spotify backend are now turned on by default. + The local backend is listed first in the :attr:`mopidy.settings.BACKENDS` + setting, and are thus given the highest priority in e.g. search results, + meaning that we're listing search hits from the local backend first. If you + want to prioritize the backends in another way, simply set ``BACKENDS`` in + your own settings file and reorder the backends. + + There are no other setting changes related to the local and Spotify backends. + As always, see :mod:`mopidy.settings` for the full list of available + settings. + +Internally, Mopidy have seen a lot of changes to pave the way for multiple +backends: + +- A new layer and actor, "core", have been added to our stack, inbetween the + frontends and the backends. The responsibility of this layer and actor is to + take requests from the frontends, pass them on to one or more backends, and + combining the response from the backends into a single response to the + requesting frontend. + + The frontends no longer know anything about the backends. They just use the + :ref:`core-api`. + +- The base playback provider have gotten sane default behavior instead of the + old empty functions. By default, the playback provider now lets GStreamer + keep track of the current track's time position. The local backend simply + uses the base playback provider without any changes. The same applies to any + future backend that just needs GStreamer to play an URI for it. + +- The dependency graph between the core controllers and the backend providers + have been straightened out, so that we don't have any circular dependencies + or similar. The frontend, core, backend, and audio layers are now strictly + separate. The frontend layer calls on the core layer, and the core layer + calls on the backend layer. Both the core layer and the backends are allowed + to call on the audio layer. Any data flow in the opposite direction is done + by broadcasting of events to listeners, through e.g. + :class:`mopidy.core.CoreListener` and :class:`mopidy.audio.AudioListener`. + +- All dependencies are now explicitly passed to the constructors of the + frontends, core, and the backends. This makes testing each layer with + dummy/mocked lower layers easier than with the old variant, where + dependencies where looked up in Pykka's actor registry. + **Bug fixes** - :issue:`213`: Fix "streaming task paused, reason not-negotiated" errors From 86ca1bf3c9e637713f687966d2f6e2c9a1843b29 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 21:58:07 +0100 Subject: [PATCH 134/233] Update README with multi-backend, MPRIS and DLNA possibilities --- README.rst | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index e7ecd614..a7df7692 100644 --- a/README.rst +++ b/README.rst @@ -4,11 +4,17 @@ Mopidy .. image:: https://secure.travis-ci.org/mopidy/mopidy.png?branch=develop -Mopidy is a music server which can play music from `Spotify -`_ or from your local hard drive. To search for music -in Spotify's vast archive, manage playlists, and play music, you can use any -`MPD client `_. MPD clients are available for most -platforms, including Windows, Mac OS X, Linux, Android and iOS. +Mopidy is a music server which can play music both from your local hard drive +and from `Spotify `_. Searches returns results from +both your local hard drive and from Spotify, and you can mix tracks from both +sources in your play queue. Your Spotify playlists are also available for use, +though we don't support modifying them yet. + +To control your music server, you can use the Ubuntu Sound Menu on the machine +running Mopidy, any device on the same network which supports the DLNA media +controller spec (with the help of Rygel in addition to Mopidy), or any `MPD +client `_. MPD clients are available for most platforms, +including Windows, Mac OS X, Linux, Android and iOS. To install Mopidy, check out `the installation docs `_. From 17b0a2ccc3413c0ce939c2df21434194f526aa6c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 22:00:55 +0100 Subject: [PATCH 135/233] Update local backend settings guide --- docs/settings.rst | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/docs/settings.rst b/docs/settings.rst index a79dfd78..88004e11 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -43,20 +43,10 @@ Music from local storage ======================== If you want use Mopidy to play music you have locally at your machine instead -of using Spotify, you need to change the backend from the default to -:mod:`mopidy.backends.local` by adding the following line to your settings -file:: - - BACKENDS = (u'mopidy.backends.local.LocalBackend',) - -You may also want to change some of the ``LOCAL_*`` settings. See -:mod:`mopidy.settings`, for a full list of available settings. - -.. note:: - - Currently, Mopidy supports using Spotify *or* local storage as a music - source. We're working on using both sources simultaneously, and will - have support for this in a future release. +of or in addition to using Spotify, you need to review and maybe change some of +the ``LOCAL_*`` settings. See :mod:`mopidy.settings`, for a full list of +available settings. Then you need to generate a tag cache for your local +music... .. _generating_a_tag_cache: @@ -66,7 +56,7 @@ Generating a tag cache Before Mopidy 0.3 the local storage backend relied purely on ``tag_cache`` files generated by the original MPD server. To remedy this the command -:command:`mopidy-scan` has been created. The program will scan your current +:command:`mopidy-scan` was created. The program will scan your current :attr:`mopidy.settings.LOCAL_MUSIC_PATH` and build a MPD compatible ``tag_cache``. From be5759e9a1c43c46d19fc32b5ef60b98445d7189 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 23:24:52 +0100 Subject: [PATCH 136/233] Make sure volume are returned as an int --- docs/changes.rst | 4 ++++ mopidy/audio/actor.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index c68db685..a0b555a6 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -18,6 +18,10 @@ v0.9.0 (in development) observed by some users on some Spotify tracks due to a change introduced in 0.8.0. See the issue for a patch that applies to 0.8.0. +- Volume returned by the MPD command `status` contained a floating point ``.0`` + suffix. This bug was introduced with the large audio outout and mixer changes + in v0.8.0. It now returns an integer again. + v0.8.0 (2012-09-20) =================== diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 53e8f723..95d6683c 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -371,7 +371,7 @@ class Audio(pykka.ThreadingActor): new_min, new_max = new old_min, old_max = old scaling = float(new_max - new_min) / (old_max - old_min) - return round(scaling * (value - old_min) + new_min) + return int(round(scaling * (value - old_min) + new_min)) def set_metadata(self, track): """ From 73cc10fffb4288b9808625f6cc30d1747d51d3ba Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 23:55:24 +0100 Subject: [PATCH 137/233] Add issue reference to changelog --- docs/changes.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index a0b555a6..f4131f73 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -18,9 +18,9 @@ v0.9.0 (in development) observed by some users on some Spotify tracks due to a change introduced in 0.8.0. See the issue for a patch that applies to 0.8.0. -- Volume returned by the MPD command `status` contained a floating point ``.0`` - suffix. This bug was introduced with the large audio outout and mixer changes - in v0.8.0. It now returns an integer again. +- :issue:`216`: Volume returned by the MPD command `status` contained a + floating point ``.0`` suffix. This bug was introduced with the large audio + outout and mixer changes in v0.8.0. It now returns an integer again. v0.8.0 (2012-09-20) From f0602b4e3bec242279b214357137ec25fa2f0fcd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 00:25:25 +0100 Subject: [PATCH 138/233] docs: Fix lots of broken module documentation --- docs/api/audio.rst | 4 +++ docs/api/backends.rst | 3 ++ docs/api/core.rst | 3 ++ docs/modules/audio/mixers/auto.rst | 6 ++++ docs/modules/audio/mixers/fake.rst | 6 ++++ docs/modules/audio/mixers/nad.rst | 6 ++++ docs/modules/backends/local.rst | 1 - docs/modules/backends/spotify.rst | 1 - docs/modules/frontends/lastfm.rst | 1 - docs/modules/frontends/mpd.rst | 12 ++++++- docs/modules/frontends/mpris.rst | 1 - docs/settings.rst | 4 +++ mopidy/audio/actor.py | 2 +- mopidy/audio/mixers/auto.py | 14 ++++++++ mopidy/audio/mixers/fake.py | 11 ++++++ mopidy/audio/mixers/nad.py | 49 ++++++++++++++++++++++++++- mopidy/backends/dummy.py | 23 +++++++++---- mopidy/backends/local/__init__.py | 22 ++++++++++++ mopidy/backends/local/actor.py | 14 -------- mopidy/backends/spotify/__init__.py | 32 ++++++++++++++++++ mopidy/backends/spotify/actor.py | 28 ---------------- mopidy/frontends/lastfm.py | 42 +++++++++++++---------- mopidy/frontends/mpd/__init__.py | 23 +++++++++++++ mopidy/frontends/mpd/actor.py | 14 -------- mopidy/frontends/mpris/__init__.py | 52 +++++++++++++++++++++++++++++ mopidy/frontends/mpris/actor.py | 40 ---------------------- 26 files changed, 286 insertions(+), 128 deletions(-) create mode 100644 docs/modules/audio/mixers/auto.rst create mode 100644 docs/modules/audio/mixers/fake.rst create mode 100644 docs/modules/audio/mixers/nad.rst diff --git a/docs/api/audio.rst b/docs/api/audio.rst index e00772fd..2b9f6cc5 100644 --- a/docs/api/audio.rst +++ b/docs/api/audio.rst @@ -4,6 +4,10 @@ Audio API ********* +.. module:: mopidy.audio + :synopsis: Thin wrapper around the parts of GStreamer we use + + The audio API is the interface we have built around GStreamer to support our specific use cases. Most backends should be able to get by with simply setting the URI of the resource they want to play, for these cases the default playback diff --git a/docs/api/backends.rst b/docs/api/backends.rst index a1aa48a0..c296fb78 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -4,6 +4,9 @@ Backend API *********** +.. module:: mopidy.backends.base + :synopsis: The API implemented by backends + The backend API is the interface that must be implemented when you create a backend. If you are working on a frontend and need to access the backend, see the :ref:`core-api`. diff --git a/docs/api/core.rst b/docs/api/core.rst index 1563b61b..eb1b9683 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -4,6 +4,9 @@ Core API ******** +.. module:: mopidy.core + :synopsis: Core API for use by frontends + The core API is the interface that is used by frontends like :mod:`mopidy.frontends.mpd`. The core layer is inbetween the frontends and the diff --git a/docs/modules/audio/mixers/auto.rst b/docs/modules/audio/mixers/auto.rst new file mode 100644 index 00000000..caf6e3ab --- /dev/null +++ b/docs/modules/audio/mixers/auto.rst @@ -0,0 +1,6 @@ +********************************************* +:mod:`mopidy.audio.mixers.auto` -- Auto mixer +********************************************* + +.. automodule:: mopidy.audio.mixers.auto + :synopsis: Mixer element which automatically selects the real mixer to use diff --git a/docs/modules/audio/mixers/fake.rst b/docs/modules/audio/mixers/fake.rst new file mode 100644 index 00000000..dcab7767 --- /dev/null +++ b/docs/modules/audio/mixers/fake.rst @@ -0,0 +1,6 @@ +********************************************* +:mod:`mopidy.audio.mixers.fake` -- Fake mixer +********************************************* + +.. automodule:: mopidy.audio.mixers.fake + :synopsis: Fake mixer for use in tests diff --git a/docs/modules/audio/mixers/nad.rst b/docs/modules/audio/mixers/nad.rst new file mode 100644 index 00000000..661dc723 --- /dev/null +++ b/docs/modules/audio/mixers/nad.rst @@ -0,0 +1,6 @@ +********************************************* +:mod:`mopidy.audio.mixers.nad` -- NAD mixer +********************************************* + +.. automodule:: mopidy.audio.mixers.nad + :synopsis: Mixer element for controlling volume on NAD amplifiers diff --git a/docs/modules/backends/local.rst b/docs/modules/backends/local.rst index 892f5a87..b4ab7d49 100644 --- a/docs/modules/backends/local.rst +++ b/docs/modules/backends/local.rst @@ -4,4 +4,3 @@ .. automodule:: mopidy.backends.local :synopsis: Backend for playing music files on local storage - :members: diff --git a/docs/modules/backends/spotify.rst b/docs/modules/backends/spotify.rst index 938d6337..e724da27 100644 --- a/docs/modules/backends/spotify.rst +++ b/docs/modules/backends/spotify.rst @@ -4,4 +4,3 @@ .. automodule:: mopidy.backends.spotify :synopsis: Backend for the Spotify music streaming service - :members: diff --git a/docs/modules/frontends/lastfm.rst b/docs/modules/frontends/lastfm.rst index a726f4a2..0dba922f 100644 --- a/docs/modules/frontends/lastfm.rst +++ b/docs/modules/frontends/lastfm.rst @@ -4,4 +4,3 @@ .. automodule:: mopidy.frontends.lastfm :synopsis: Last.fm scrobbler frontend - :members: diff --git a/docs/modules/frontends/mpd.rst b/docs/modules/frontends/mpd.rst index 0ce138a2..090ca5cd 100644 --- a/docs/modules/frontends/mpd.rst +++ b/docs/modules/frontends/mpd.rst @@ -4,7 +4,6 @@ .. automodule:: mopidy.frontends.mpd :synopsis: MPD server frontend - :members: MPD dispatcher @@ -27,6 +26,7 @@ Audio output ------------ .. automodule:: mopidy.frontends.mpd.protocol.audio_output + :synopsis: MPD protocol: audio output :members: @@ -34,6 +34,7 @@ Command list ------------ .. automodule:: mopidy.frontends.mpd.protocol.command_list + :synopsis: MPD protocol: command list :members: @@ -41,6 +42,7 @@ Connection ---------- .. automodule:: mopidy.frontends.mpd.protocol.connection + :synopsis: MPD protocol: connection :members: @@ -48,12 +50,15 @@ Current playlist ---------------- .. automodule:: mopidy.frontends.mpd.protocol.current_playlist + :synopsis: MPD protocol: current playlist :members: + Music database -------------- .. automodule:: mopidy.frontends.mpd.protocol.music_db + :synopsis: MPD protocol: music database :members: @@ -61,6 +66,7 @@ Playback -------- .. automodule:: mopidy.frontends.mpd.protocol.playback + :synopsis: MPD protocol: playback :members: @@ -68,6 +74,7 @@ Reflection ---------- .. automodule:: mopidy.frontends.mpd.protocol.reflection + :synopsis: MPD protocol: reflection :members: @@ -75,6 +82,7 @@ Status ------ .. automodule:: mopidy.frontends.mpd.protocol.status + :synopsis: MPD protocol: status :members: @@ -82,6 +90,7 @@ Stickers -------- .. automodule:: mopidy.frontends.mpd.protocol.stickers + :synopsis: MPD protocol: stickers :members: @@ -89,4 +98,5 @@ Stored playlists ---------------- .. automodule:: mopidy.frontends.mpd.protocol.stored_playlists + :synopsis: MPD protocol: stored playlists :members: diff --git a/docs/modules/frontends/mpris.rst b/docs/modules/frontends/mpris.rst index 05a6e287..2984e4c1 100644 --- a/docs/modules/frontends/mpris.rst +++ b/docs/modules/frontends/mpris.rst @@ -4,4 +4,3 @@ .. automodule:: mopidy.frontends.mpris :synopsis: MPRIS frontend - :members: diff --git a/docs/settings.rst b/docs/settings.rst index a79dfd78..b71b18ef 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -29,6 +29,8 @@ A complete ``~/.config/mopidy/settings.py`` may look as simple as this:: SPOTIFY_PASSWORD = u'mysecret' +.. _music-from-spotify: + Music from Spotify ================== @@ -39,6 +41,8 @@ Premium account's username and password into the file, like this:: SPOTIFY_PASSWORD = u'mysecret' +.. _music-from-local-storage: + Music from local storage ======================== diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 95d6683c..852d5d57 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -231,7 +231,7 @@ class Audio(pykka.ThreadingActor): Set position in milliseconds. :param position: the position in milliseconds - :type volume: int + :type position: int :rtype: :class:`True` if successful, else :class:`False` """ self._playbin.get_state() # block until state changes are done diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py index f3806eef..05294801 100644 --- a/mopidy/audio/mixers/auto.py +++ b/mopidy/audio/mixers/auto.py @@ -1,3 +1,17 @@ +"""Mixer element that automatically selects the real mixer to use. + +This is Mopidy's default mixer. + +**Dependencies:** + +- None + +**Settings:** + +- If this wasn't the default, you would set :attr:`mopidy.settings.MIXER` + to ``autoaudiomixer`` to use this mixer. +""" + import pygst pygst.require('0.10') import gst diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py index b22e731e..10710466 100644 --- a/mopidy/audio/mixers/fake.py +++ b/mopidy/audio/mixers/fake.py @@ -1,3 +1,14 @@ +"""Fake mixer for use in tests. + +**Dependencies:** + +- None + +**Settings:** + +- Set :attr:`mopidy.settings.MIXER` to ``fakemixer`` to use this mixer. +""" + import pygst pygst.require('0.10') import gobject diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index 1d65ead9..cb1266a1 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -1,3 +1,50 @@ +"""Mixer that controls volume using a NAD amplifier. + +**Dependencies:** + +- pyserial (python-serial in Debian/Ubuntu) + +- The NAD amplifier must be connected to the machine running Mopidy using a + serial cable. + +**Settings:** + +- Set :attr:`mopidy.settings.MIXER` to ``nadmixer`` to use it. You probably + also needs to add some properties to the ``MIXER`` setting. + +Supported properties includes: + +``port``: + The serial device to use, defaults to ``/dev/ttyUSB0``. This must be + set correctly for the mixer to work. + +``source``: + The source that should be selected on the amplifier, like ``aux``, + ``disc``, ``tape``, ``tuner``, etc. Leave unset if you don't want the + mixer to change it for you. + +``speakers-a``: + Set to ``on`` or ``off`` if you want the mixer to make sure that + speaker set A is turned on or off. Leave unset if you don't want the + mixer to change it for you. + +``speakers-b``: + See ``speakers-a``. + +Configuration examples:: + + # Minimum configuration, if the amplifier is available at /dev/ttyUSB0 + MIXER = u'nadmixer' + + # Minimum configuration, if the amplifier is available elsewhere + MIXER = u'nadmixer port=/dev/ttyUSB3' + + # Full configuration + MIXER = ( + u'nadmixer port=/dev/ttyUSB0 ' + u'source=aux speakers-a=on speakers-b=off') +""" + import logging import pygst @@ -76,7 +123,7 @@ class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): class NadTalker(pykka.ThreadingActor): """ - Independent thread which does the communication with the NAD amplifier + Independent thread which does the communication with the NAD amplifier. Since the communication is done in an independent thread, Mopidy won't block other requests while doing rather time consuming work like diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 94bb9b1d..51129200 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -1,3 +1,19 @@ +"""A dummy backend for use in tests. + +This backend implements the backend API in the simplest way possible. It is +used in tests of the frontends. + +The backend handles URIs starting with ``dummy:``. + +**Dependencies:** + +- None + +**Settings:** + +- None +""" + import pykka from mopidy.backends import base @@ -5,13 +21,6 @@ from mopidy.models import Playlist class DummyBackend(pykka.ThreadingActor, base.Backend): - """ - A backend which implements the backend API in the simplest way possible. - Used in tests of the frontends. - - Handles URIs starting with ``dummy:``. - """ - def __init__(self, audio): super(DummyBackend, self).__init__() diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 6f0f3770..6f049474 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -1,2 +1,24 @@ +"""A backend for playing music from a local music archive. + +This backend handles URIs starting with ``file:``. + +See :ref:`music-from-local-storage` for further instructions on using this +backend. + +**Issues:** + +https://github.com/mopidy/mopidy/issues?labels=Local+backend + +**Dependencies:** + +- None + +**Settings:** + +- :attr:`mopidy.settings.LOCAL_MUSIC_PATH` +- :attr:`mopidy.settings.LOCAL_PLAYLIST_PATH` +- :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` +""" + # flake8: noqa from .actor import LocalBackend diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index 10802722..70351ed1 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -11,20 +11,6 @@ logger = logging.getLogger(u'mopidy.backends.local') class LocalBackend(pykka.ThreadingActor, base.Backend): - """ - A backend for playing music from a local music archive. - - **Dependencies:** - - - None - - **Settings:** - - - :attr:`mopidy.settings.LOCAL_MUSIC_PATH` - - :attr:`mopidy.settings.LOCAL_PLAYLIST_PATH` - - :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` - """ - def __init__(self, audio): super(LocalBackend, self).__init__() diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 87d76c46..bb0c805b 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -1,2 +1,34 @@ +"""A backend for playing music from Spotify + +`Spotify `_ is a music streaming service. The backend +uses the official `libspotify +`_ library and the +`pyspotify `_ Python bindings for +libspotify. This backend handles URIs starting with ``spotify:``. + +See :ref:`music-from-spotify` for further instructions on using this backend. + +.. note:: + + This product uses SPOTIFY(R) CORE but is not endorsed, certified or + otherwise approved in any way by Spotify. Spotify is the registered + trade mark of the Spotify Group. + +**Issues:** + +https://github.com/mopidy/mopidy/issues?labels=Spotify+backend + +**Dependencies:** + +- libspotify >= 11, < 12 (libspotify11 package from apt.mopidy.com) +- pyspotify >= 1.7, < 1.8 (python-spotify package from apt.mopidy.com) + +**Settings:** + +- :attr:`mopidy.settings.SPOTIFY_CACHE_PATH` +- :attr:`mopidy.settings.SPOTIFY_USERNAME` +- :attr:`mopidy.settings.SPOTIFY_PASSWORD` +""" + # flake8: noqa from .actor import SpotifyBackend diff --git a/mopidy/backends/spotify/actor.py b/mopidy/backends/spotify/actor.py index 948636a2..943600fc 100644 --- a/mopidy/backends/spotify/actor.py +++ b/mopidy/backends/spotify/actor.py @@ -9,34 +9,6 @@ logger = logging.getLogger('mopidy.backends.spotify') class SpotifyBackend(pykka.ThreadingActor, base.Backend): - """ - A backend for playing music from the `Spotify `_ - music streaming service. The backend uses the official `libspotify - `_ library and the - `pyspotify `_ Python bindings for - libspotify. - - .. note:: - - This product uses SPOTIFY(R) CORE but is not endorsed, certified or - otherwise approved in any way by Spotify. Spotify is the registered - trade mark of the Spotify Group. - - **Issues:** - https://github.com/mopidy/mopidy/issues?labels=backend-spotify - - **Dependencies:** - - - libspotify >= 10, < 11 (libspotify10 package from apt.mopidy.com) - - pyspotify >= 1.5 (python-spotify package from apt.mopidy.com) - - **Settings:** - - - :attr:`mopidy.settings.SPOTIFY_CACHE_PATH` - - :attr:`mopidy.settings.SPOTIFY_USERNAME` - - :attr:`mopidy.settings.SPOTIFY_PASSWORD` - """ - # Imports inside methods are to prevent loading of __init__.py to fail on # missing spotify dependencies. diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index e7c2afdb..aaf55ec1 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -1,3 +1,27 @@ +""" +Frontend which scrobbles the music you play to your `Last.fm +`_ profile. + +.. note:: + + This frontend requires a free user account at Last.fm. + +**Dependencies:** + +- `pylast `_ >= 0.5.7 + +**Settings:** + +- :attr:`mopidy.settings.LASTFM_USERNAME` +- :attr:`mopidy.settings.LASTFM_PASSWORD` + +**Usage:** + +Make sure :attr:`mopidy.settings.FRONTENDS` includes +``mopidy.frontends.lastfm.LastfmFrontend``. By default, the setting includes +the Last.fm frontend. +""" + import logging import time @@ -18,24 +42,6 @@ API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd' class LastfmFrontend(pykka.ThreadingActor, CoreListener): - """ - Frontend which scrobbles the music you play to your `Last.fm - `_ profile. - - .. note:: - - This frontend requires a free user account at Last.fm. - - **Dependencies:** - - - `pylast `_ >= 0.5.7 - - **Settings:** - - - :attr:`mopidy.settings.LASTFM_USERNAME` - - :attr:`mopidy.settings.LASTFM_PASSWORD` - """ - def __init__(self, core): super(LastfmFrontend, self).__init__() self.lastfm = None diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index e2d2b9c7..a6cfd386 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -1,2 +1,25 @@ +"""The MPD server frontend. + +MPD stands for Music Player Daemon. MPD is an independent project and server. +Mopidy implements the MPD protocol, and is thus compatible with clients for the +original MPD server. + +**Dependencies:** + +- None + +**Settings:** + +- :attr:`mopidy.settings.MPD_SERVER_HOSTNAME` +- :attr:`mopidy.settings.MPD_SERVER_PORT` +- :attr:`mopidy.settings.MPD_SERVER_PASSWORD` + +**Usage:** + +Make sure :attr:`mopidy.settings.FRONTENDS` includes +``mopidy.frontends.mpd.MpdFrontend``. By default, the setting includes the MPD +frontend. +""" + # flake8: noqa from .actor import MpdFrontend diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index f69334b5..e136ddee 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -12,20 +12,6 @@ logger = logging.getLogger('mopidy.frontends.mpd') class MpdFrontend(pykka.ThreadingActor, CoreListener): - """ - The MPD frontend. - - **Dependencies:** - - - None - - **Settings:** - - - :attr:`mopidy.settings.MPD_SERVER_HOSTNAME` - - :attr:`mopidy.settings.MPD_SERVER_PORT` - - :attr:`mopidy.settings.MPD_SERVER_PASSWORD` - """ - def __init__(self, core): super(MpdFrontend, self).__init__() hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME) diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 93ad0795..4245f844 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -1,2 +1,54 @@ +""" +Frontend which lets you control Mopidy through the Media Player Remote +Interfacing Specification (`MPRIS `_) D-Bus +interface. + +An example of an MPRIS client is the `Ubuntu Sound Menu +`_. + +**Dependencies:** + +- D-Bus Python bindings. The package is named ``python-dbus`` in + Ubuntu/Debian. + +- ``libindicate`` Python bindings is needed to expose Mopidy in e.g. the + Ubuntu Sound Menu. The package is named ``python-indicate`` in + Ubuntu/Debian. + +- An ``.desktop`` file for Mopidy installed at the path set in + :attr:`mopidy.settings.DESKTOP_FILE`. See :ref:`install_desktop_file` for + details. + +**Settings:** + +- :attr:`mopidy.settings.DESKTOP_FILE` + +**Usage:** + +Make sure :attr:`mopidy.settings.FRONTENDS` includes +``mopidy.frontends.mpris.MprisFrontend``. By default, the setting includes the +MPRIS frontend. + +**Testing the frontend** + +To test, start Mopidy, and then run the following in a Python shell:: + + import dbus + bus = dbus.SessionBus() + player = bus.get_object('org.mpris.MediaPlayer2.mopidy', + '/org/mpris/MediaPlayer2') + +Now you can control Mopidy through the player object. Examples: + +- To get some properties from Mopidy, run:: + + props = player.GetAll('org.mpris.MediaPlayer2', + dbus_interface='org.freedesktop.DBus.Properties') + +- To quit Mopidy through D-Bus, run:: + + player.Quit(dbus_interface='org.mpris.MediaPlayer2') +""" + # flake8: noqa from .actor import MprisFrontend diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py index acca3ab7..5d8d5492 100644 --- a/mopidy/frontends/mpris/actor.py +++ b/mopidy/frontends/mpris/actor.py @@ -16,46 +16,6 @@ except ImportError as import_error: class MprisFrontend(pykka.ThreadingActor, CoreListener): - """ - Frontend which lets you control Mopidy through the Media Player Remote - Interfacing Specification (`MPRIS `_) D-Bus - interface. - - An example of an MPRIS client is the `Ubuntu Sound Menu - `_. - - **Dependencies:** - - - D-Bus Python bindings. The package is named ``python-dbus`` in - Ubuntu/Debian. - - ``libindicate`` Python bindings is needed to expose Mopidy in e.g. the - Ubuntu Sound Menu. The package is named ``python-indicate`` in - Ubuntu/Debian. - - An ``.desktop`` file for Mopidy installed at the path set in - :attr:`mopidy.settings.DESKTOP_FILE`. See :ref:`install_desktop_file` for - details. - - **Testing the frontend** - - To test, start Mopidy, and then run the following in a Python shell:: - - import dbus - bus = dbus.SessionBus() - player = bus.get_object('org.mpris.MediaPlayer2.mopidy', - '/org/mpris/MediaPlayer2') - - Now you can control Mopidy through the player object. Examples: - - - To get some properties from Mopidy, run:: - - props = player.GetAll('org.mpris.MediaPlayer2', - dbus_interface='org.freedesktop.DBus.Properties') - - - To quit Mopidy through D-Bus, run:: - - player.Quit(dbus_interface='org.mpris.MediaPlayer2') - """ - def __init__(self, core): super(MprisFrontend, self).__init__() self.core = core From 9f69c620315c4dfb7a39f689e7a63c387942e104 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 01:08:06 +0100 Subject: [PATCH 139/233] Fix typo in changelog and add another sentence --- docs/changes.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index f4131f73..541f7af9 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -20,7 +20,8 @@ v0.9.0 (in development) - :issue:`216`: Volume returned by the MPD command `status` contained a floating point ``.0`` suffix. This bug was introduced with the large audio - outout and mixer changes in v0.8.0. It now returns an integer again. + output and mixer changes in v0.8.0 and broke the MPDroid Android client. It + now returns an integer again. v0.8.0 (2012-09-20) From 519fdb9326c7d6748f622a2a1853193eaa886b98 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 01:09:36 +0100 Subject: [PATCH 140/233] Update concepts description and graphs --- docs/api/concepts.rst | 117 ++++++++++++++++++++++++++++++++--------- docs/api/frontends.rst | 2 + 2 files changed, 95 insertions(+), 24 deletions(-) diff --git a/docs/api/concepts.rst b/docs/api/concepts.rst index ae959237..5eca2349 100644 --- a/docs/api/concepts.rst +++ b/docs/api/concepts.rst @@ -1,29 +1,98 @@ .. _concepts: -********************************************** -The backend, controller, and provider concepts -********************************************** +************************* +Architecture and concepts +************************* -Backend: - The backend is mostly for convenience. It is a container that holds - references to all the controllers. -Controllers: - Each controller has responsibility for a given part of the backend - functionality. Most, but not all, controllers delegates some work to one or - more providers. The controllers are responsible for choosing the right - provider for any given task based upon i.e. the track's URI. See - :ref:`core-api` for more details. -Providers: - Anything specific to i.e. Spotify integration or local storage is contained - in the providers. To integrate with new music sources, you just add new - providers. See :ref:`backend-api` for more details. +The overall architecture of Mopidy is organized around multiple frontends and +backends. The frontends use the core API. The core actor makes multiple backends +work as one. The backends connect to various music sources. Both the core actor +and the backends use the audio actor to play audio and control audio volume. -.. digraph:: backend_relations +.. digraph:: overall_architecture - Backend -> "Current\nplaylist\ncontroller" - Backend -> "Library\ncontroller" - "Library\ncontroller" -> "Library\nproviders" - Backend -> "Playback\ncontroller" - "Playback\ncontroller" -> "Playback\nproviders" - Backend -> "Stored\nplaylists\ncontroller" - "Stored\nplaylists\ncontroller" -> "Stored\nplaylist\nproviders" + "Multiple frontends" -> Core + Core -> "Multiple backends" + Core -> Audio + "Multiple backends" -> Audio + + +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. + +.. digraph:: frontend_architecture + + "MPD\nfrontend" -> Core + "MPRIS\nfrontend" -> Core + "Last.fm\nfrontend" -> Core + + +Core +==== + +The core is organized as a set of controllers with responsiblity for separate +sets of functionality. + +The core is the single actor that the frontends send their requests to. For +every request from a frontend it calls out to one or more backends which does +the real work, and when the backends respond, the core actor is responsible for +combining the responses into a single response to the requesting frontend. + +The core actor also keeps track of the current playlist, since it doesn't +belong to a specific backend. + +See :ref:`core-api` for more details. + +.. digraph:: core_architecture + + Core -> "Current\nplaylist\ncontroller" + Core -> "Library\ncontroller" + Core -> "Playback\ncontroller" + Core -> "Stored\nplaylists\ncontroller" + + "Library\ncontroller" -> "Local backend" + "Library\ncontroller" -> "Spotify backend" + + "Playback\ncontroller" -> "Local backend" + "Playback\ncontroller" -> "Spotify backend" + "Playback\ncontroller" -> Audio + + "Stored\nplaylists\ncontroller" -> "Local backend" + "Stored\nplaylists\ncontroller" -> "Spotify backend" + +Backends +======== + +The backends are organized as a set of providers with responsiblity forseparate +sets of functionality, similar to the core actor. + +Anything specific to i.e. Spotify integration or local storage is contained in +the backends. To integrate with new music sources, you just add a new backend. +See :ref:`backend-api` for more details. + +.. digraph:: backend_architecture + + "Local backend" -> "Local\nlibrary\nprovider" -> "Local disk" + "Local backend" -> "Local\nplayback\nprovider" -> "Local disk" + "Local backend" -> "Local\nstored\nplaylists\nprovider" -> "Local disk" + "Local\nplayback\nprovider" -> Audio + + "Spotify backend" -> "Spotify\nlibrary\nprovider" -> "Spotify service" + "Spotify backend" -> "Spotify\nplayback\nprovider" -> "Spotify service" + "Spotify backend" -> "Spotify\nstored\nplaylists\nprovider" -> "Spotify service" + "Spotify\nplayback\nprovider" -> Audio + + +Audio +===== + +The audio actor is a thin wrapper around the parts of the GStreamer library we +use. In addition to playback, it's responsible for volume control through both +GStreamer's own volume mixers, and mixers we've created ourselves. If you +implement an advanced backend, you may need to implement your own playback +provider using the :ref:`audio-api`. diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index fc54a8a2..2237b4e7 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -1,3 +1,5 @@ +.. _frontend-api: + ************ Frontend API ************ From 6427f7e6bcdb89d53e62e5eefb8a4cab85ae7a06 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 08:30:28 +0100 Subject: [PATCH 141/233] Split up two-level list comprehension --- mopidy/core/library.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 37c8c522..e0df8928 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,3 +1,4 @@ +import itertools import urlparse import pykka @@ -94,5 +95,6 @@ class LibraryController(object): """ futures = [b.library.search(**query) for b in self.backends] results = pykka.get_all(futures) - return Playlist(tracks=[ - track for playlist in results for track in playlist.tracks]) + track_lists = [playlist.tracks for playlist in results] + tracks = list(itertools.chain(*track_lists)) + return Playlist(tracks=tracks) From ea912620f3c3a1251b3c7346994d3da6a0461a8d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 09:20:43 +0100 Subject: [PATCH 142/233] Formatting --- docs/api/concepts.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api/concepts.rst b/docs/api/concepts.rst index 5eca2349..c3696179 100644 --- a/docs/api/concepts.rst +++ b/docs/api/concepts.rst @@ -65,6 +65,7 @@ See :ref:`core-api` for more details. "Stored\nplaylists\ncontroller" -> "Local backend" "Stored\nplaylists\ncontroller" -> "Spotify backend" + Backends ======== From fbf642ca99df0f1be100ba3d3dfa096543aa20dc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 09:21:58 +0100 Subject: [PATCH 143/233] docs: Use dashes in all labels --- docs/changes.rst | 4 ++-- docs/settings.rst | 6 +++--- mopidy/frontends/mpris/__init__.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 541f7af9..7d608086 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -592,7 +592,7 @@ to this problem. - Local backend: - Add :command:`mopidy-scan` command to generate ``tag_cache`` files without - any help from the original MPD server. See :ref:`generating_a_tag_cache` + any help from the original MPD server. See :ref:`generating-a-tag-cache` for instructions on how to use it. - Fix support for UTF-8 encoding in tag caches. @@ -601,7 +601,7 @@ to this problem. - Add support for password authentication. See :attr:`mopidy.settings.MPD_SERVER_PASSWORD` and - :ref:`use_mpd_on_a_network` for details on how to use it. (Fixes: + :ref:`use-mpd-on-a-network` for details on how to use it. (Fixes: :issue:`41`) - Support ``setvol 50`` without quotes around the argument. Fixes volume diff --git a/docs/settings.rst b/docs/settings.rst index b71b18ef..37e1d8ed 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -63,7 +63,7 @@ You may also want to change some of the ``LOCAL_*`` settings. See have support for this in a future release. -.. _generating_a_tag_cache: +.. _generating-a-tag-cache: Generating a tag cache ---------------------- @@ -94,7 +94,7 @@ To make a ``tag_cache`` of your local music available for Mopidy: #. Start Mopidy, find the music library in a client, and play some local music! -.. _use_mpd_on_a_network: +.. _use-mpd-on-a-network: Connecting from other machines on the network ============================================= @@ -123,7 +123,7 @@ file:: LASTFM_PASSWORD = u'mysecret' -.. _install_desktop_file: +.. _install-desktop-file: Controlling Mopidy through the Ubuntu Sound Menu ================================================ diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 4245f844..38deac7a 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -16,7 +16,7 @@ An example of an MPRIS client is the `Ubuntu Sound Menu Ubuntu/Debian. - An ``.desktop`` file for Mopidy installed at the path set in - :attr:`mopidy.settings.DESKTOP_FILE`. See :ref:`install_desktop_file` for + :attr:`mopidy.settings.DESKTOP_FILE`. See :ref:`install-desktop-file` for details. **Settings:** From 6a39516d05a40e5ec95fbc71c9e5e848ead57380 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 09:22:41 +0100 Subject: [PATCH 144/233] Fix typo --- docs/api/concepts.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api/concepts.rst b/docs/api/concepts.rst index c3696179..203418de 100644 --- a/docs/api/concepts.rst +++ b/docs/api/concepts.rst @@ -69,8 +69,8 @@ See :ref:`core-api` for more details. Backends ======== -The backends are organized as a set of providers with responsiblity forseparate -sets of functionality, similar to the core actor. +The backends are organized as a set of providers with responsiblity for +separate sets of functionality, similar to the core actor. Anything specific to i.e. Spotify integration or local storage is contained in the backends. To integrate with new music sources, you just add a new backend. From c17f07e14bceb73496ed8f2e44926f22f05f4f89 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 09:25:48 +0100 Subject: [PATCH 145/233] Code review improvements --- docs/changes.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 5a17c810..8df19842 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -31,13 +31,13 @@ long time been our most requested feature. Finally, it's here! Internally, Mopidy have seen a lot of changes to pave the way for multiple backends: -- A new layer and actor, "core", have been added to our stack, inbetween the - frontends and the backends. The responsibility of this layer and actor is to - take requests from the frontends, pass them on to one or more backends, and - combining the response from the backends into a single response to the +- A new layer and actor, "core", has been added to our stack, inbetween the + frontends and the backends. The responsibility of the core layer and actor is + to take requests from the frontends, pass them on to one or more backends, + and combining the response from the backends into a single response to the requesting frontend. - The frontends no longer know anything about the backends. They just use the + Frontends no longer know anything about the backends. They just use the :ref:`core-api`. - The base playback provider have gotten sane default behavior instead of the From b352a6ed4f9aa234dac4cfb4ba123c2c6ac7e66d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 09:38:32 +0100 Subject: [PATCH 146/233] Code review improvements --- docs/changes.rst | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 8df19842..9129584c 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -40,19 +40,19 @@ backends: Frontends no longer know anything about the backends. They just use the :ref:`core-api`. -- The base playback provider have gotten sane default behavior instead of the - old empty functions. By default, the playback provider now lets GStreamer - keep track of the current track's time position. The local backend simply - uses the base playback provider without any changes. The same applies to any - future backend that just needs GStreamer to play an URI for it. +- The base playback provider has been updated with sane default behavior + instead of empty functions. By default, the playback provider now lets + GStreamer keep track of the current track's time position. The local backend + simply uses the base playback provider without any changes. The same applies + to any future backend that just needs GStreamer to play an URI for it. - The dependency graph between the core controllers and the backend providers - have been straightened out, so that we don't have any circular dependencies - or similar. The frontend, core, backend, and audio layers are now strictly - separate. The frontend layer calls on the core layer, and the core layer - calls on the backend layer. Both the core layer and the backends are allowed - to call on the audio layer. Any data flow in the opposite direction is done - by broadcasting of events to listeners, through e.g. + have been straightened out, so that we don't have any circular dependencies. + The frontend, core, backend, and audio layers are now strictly separate. The + frontend layer calls on the core layer, and the core layer calls on the + backend layer. Both the core layer and the backends are allowed to call on + the audio layer. Any data flow in the opposite direction is done by + broadcasting of events to listeners, through e.g. :class:`mopidy.core.CoreListener` and :class:`mopidy.audio.AudioListener`. - All dependencies are now explicitly passed to the constructors of the From 2e6e53b14dd96e060c5187474de64f91d36dacec Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 09:48:53 +0100 Subject: [PATCH 147/233] Remove code duplication --- mopidy/core/actor.py | 29 ++++++++++++++++++++++++----- mopidy/core/library.py | 10 +--------- mopidy/core/playback.py | 11 +---------- 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 05c085fd..0af8c3b2 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -30,25 +30,44 @@ class Core(pykka.ThreadingActor, AudioListener): def __init__(self, audio=None, backends=None): super(Core, self).__init__() - self._backends = backends + self.backends = Backends(backends) self.current_playlist = CurrentPlaylistController(core=self) - self.library = LibraryController(backends=backends, core=self) + self.library = LibraryController(backends=self.backends, core=self) self.playback = PlaybackController( - audio=audio, backends=backends, core=self) + audio=audio, backends=self.backends, core=self) self.stored_playlists = StoredPlaylistsController( - backends=backends, core=self) + backends=self.backends, core=self) @property def uri_schemes(self): """List of URI schemes we can handle""" - futures = [backend.uri_schemes for backend in self._backends] + futures = [b.uri_schemes for b in self.backends] results = pykka.get_all(futures) uri_schemes = itertools.chain(*results) return sorted(uri_schemes) def reached_end_of_stream(self): self.playback.on_end_of_track() + + +class Backends(object): + def __init__(self, backends): + self._backends = backends + + uri_schemes_by_backend = { + backend: backend.uri_schemes.get() + for backend in backends} + self.by_uri_scheme = { + uri_scheme: backend + for backend, uri_schemes in uri_schemes_by_backend.items() + for uri_scheme in uri_schemes} + + def __len__(self): + return len(self._backends) + + def __getitem__(self, key): + return self._backends[key] diff --git a/mopidy/core/library.py b/mopidy/core/library.py index e0df8928..bf14f5d3 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -11,19 +11,11 @@ class LibraryController(object): def __init__(self, backends, core): self.backends = backends - uri_schemes_by_backend = { - backend: backend.uri_schemes.get() - for backend in backends} - self.backends_by_uri_scheme = { - uri_scheme: backend - for backend, uri_schemes in uri_schemes_by_backend.items() - for uri_scheme in uri_schemes} - self.core = core def _get_backend(self, uri): uri_scheme = urlparse.urlparse(uri).scheme - return self.backends_by_uri_scheme.get(uri_scheme) + return self.backends.by_uri_scheme.get(uri_scheme) def find_exact(self, **query): """ diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 721bc2a8..74f4bebd 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -77,16 +77,7 @@ class PlaybackController(object): def __init__(self, audio, backends, core): self.audio = audio - self.backends = backends - uri_schemes_by_backend = { - backend: backend.uri_schemes.get() - for backend in backends} - self.backends_by_uri_scheme = { - uri_scheme: backend - for backend, uri_schemes in uri_schemes_by_backend.items() - for uri_scheme in uri_schemes} - self.core = core self._state = PlaybackState.STOPPED @@ -99,7 +90,7 @@ class PlaybackController(object): return None uri = self.current_cp_track.track.uri uri_scheme = urlparse.urlparse(uri).scheme - return self.backends_by_uri_scheme[uri_scheme] + return self.backends.by_uri_scheme[uri_scheme] def _get_cpid(self, cp_track): if cp_track is None: From 44186c1a03d8504aad8a68f1261538801f689c03 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 10:14:43 +0100 Subject: [PATCH 148/233] Make sure backends is a fully functional list --- mopidy/core/actor.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 0af8c3b2..e2eeb746 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -54,20 +54,12 @@ class Core(pykka.ThreadingActor, AudioListener): self.playback.on_end_of_track() -class Backends(object): +class Backends(list): def __init__(self, backends): - self._backends = backends + super(Backends, self).__init__(backends) - uri_schemes_by_backend = { - backend: backend.uri_schemes.get() - for backend in backends} - self.by_uri_scheme = { - uri_scheme: backend - for backend, uri_schemes in uri_schemes_by_backend.items() - for uri_scheme in uri_schemes} - - def __len__(self): - return len(self._backends) - - def __getitem__(self, key): - return self._backends[key] + self.by_uri_scheme = {} + for backend in backends: + uri_schemes = backend.uri_schemes.get() + for uri_scheme in uri_schemes: + self.by_uri_scheme[uri_scheme] = backend From 7ee43dd20893283d4cdc9fe4effeab86c227a492 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 10:24:07 +0100 Subject: [PATCH 149/233] Be explicit about returning None for unknown URI schemes --- 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 bf14f5d3..f7514fd8 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -15,7 +15,7 @@ class LibraryController(object): def _get_backend(self, uri): uri_scheme = urlparse.urlparse(uri).scheme - return self.backends.by_uri_scheme.get(uri_scheme) + return self.backends.by_uri_scheme.get(uri_scheme, None) def find_exact(self, **query): """ From 4a79b559d547f0602d632c8bd4b2f1b2344d485b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 10:31:35 +0100 Subject: [PATCH 150/233] Fail if two backends claims to handle the same URI schema --- mopidy/core/actor.py | 3 +++ tests/core/actor_test.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index e2eeb746..7fdaeb71 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -62,4 +62,7 @@ class Backends(list): for backend in backends: uri_schemes = backend.uri_schemes.get() for uri_scheme in uri_schemes: + assert uri_scheme not in self.by_uri_scheme, ( + 'URI scheme %s is already handled by %s' + % (uri_scheme, backend.__class__.__name__)) self.by_uri_scheme[uri_scheme] = backend diff --git a/tests/core/actor_test.py b/tests/core/actor_test.py index 95639cf8..9feddbd0 100644 --- a/tests/core/actor_test.py +++ b/tests/core/actor_test.py @@ -24,3 +24,9 @@ class CoreActorTest(unittest.TestCase): self.assertIn('dummy1', result) self.assertIn('dummy2', result) + + def test_backends_with_colliding_uri_schemes_fails(self): + self.backend2.uri_schemes.get.return_value = ['dummy1', 'dummy2'] + self.assertRaisesRegexp( + AssertionError, 'URI scheme dummy1 is already handled by Mock', + Core, audio=None, backends=[self.backend1, self.backend2]) From 1014c6e373313c642c4d72ad884a1ebbc69e8de1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 10:50:18 +0100 Subject: [PATCH 151/233] Include both involved backends in the error message --- mopidy/core/actor.py | 7 +++++-- tests/core/actor_test.py | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 7fdaeb71..482868ad 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -63,6 +63,9 @@ class Backends(list): uri_schemes = backend.uri_schemes.get() for uri_scheme in uri_schemes: assert uri_scheme not in self.by_uri_scheme, ( - 'URI scheme %s is already handled by %s' - % (uri_scheme, backend.__class__.__name__)) + 'Cannot add URI scheme %s for %s, ' + 'it is already handled by %s' + ) % ( + uri_scheme, backend.__class__.__name__, + self.by_uri_scheme[uri_scheme].__class__.__name__) self.by_uri_scheme[uri_scheme] = backend diff --git a/tests/core/actor_test.py b/tests/core/actor_test.py index 9feddbd0..8212c1da 100644 --- a/tests/core/actor_test.py +++ b/tests/core/actor_test.py @@ -26,7 +26,10 @@ class CoreActorTest(unittest.TestCase): self.assertIn('dummy2', result) def test_backends_with_colliding_uri_schemes_fails(self): + self.backend1.__class__.__name__ = 'B1' + self.backend2.__class__.__name__ = 'B2' self.backend2.uri_schemes.get.return_value = ['dummy1', 'dummy2'] self.assertRaisesRegexp( - AssertionError, 'URI scheme dummy1 is already handled by Mock', + AssertionError, + 'Cannot add URI scheme dummy1 for B2, it is already handled by B1', Core, audio=None, backends=[self.backend1, self.backend2]) From 262bd98c160267a021a1068a12d4c66746506066 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 12:23:53 +0100 Subject: [PATCH 152/233] Move Pykka version check back to import time --- mopidy/__init__.py | 18 +++++++++++++++++- mopidy/__main__.py | 14 -------------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 0b0be1a6..14c67b80 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,9 +1,25 @@ +# pylint: disable = E0611,F0401 +from distutils.version import StrictVersion as SV +# pylint: enable = E0611,F0401 import sys + +import pykka + + if not (2, 6) <= sys.version_info < (3,): - sys.exit(u'Mopidy requires Python >= 2.6, < 3') + sys.exit( + u'Mopidy requires Python >= 2.6, < 3, but found %s' % + '.'.join(map(str, sys.version_info[:3]))) + +if (isinstance(pykka.__version__, basestring) + and not SV('1.0') <= SV(pykka.__version__) < SV('2.0')): + sys.exit( + u'Mopidy requires Pykka >= 1.0, < 2, but found %s' % pykka.__version__) + __version__ = '0.8.0' + from mopidy import settings as default_settings_module from mopidy.utils.settings import SettingsProxy settings = SettingsProxy(default_settings_module) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 965cd9ba..75f847e4 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,6 +1,3 @@ -# pylint: disable = E0611,F0401 -from distutils.version import StrictVersion -# pylint: enable = E0611,F0401 import logging import optparse import os @@ -10,8 +7,6 @@ import sys import gobject gobject.threads_init() -import pykka - # Extract any non-GStreamer arguments, and leave the GStreamer arguments for # processing by GStreamer. This needs to be done before GStreamer is imported, @@ -45,7 +40,6 @@ logger = logging.getLogger('mopidy.main') def main(): - check_dependencies() signal.signal(signal.SIGTERM, process.exit_handler) loop = gobject.MainLoop() options = parse_options() @@ -73,14 +67,6 @@ def main(): process.stop_remaining_actors() -def check_dependencies(): - pykka_required = '1.0' - if StrictVersion(pykka.__version__) < StrictVersion(pykka_required): - sys.exit( - u'Mopidy requires Pykka >= %s, but found %s' % - (pykka_required, pykka.__version__)) - - def parse_options(): parser = optparse.OptionParser( version=u'Mopidy %s' % versioning.get_version()) From 15799a1ccd723383423cc9106758d1c3bdd7a2d9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 12:53:53 +0100 Subject: [PATCH 153/233] Ignore the 'could not open display' warning from GTK --- mopidy/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 14c67b80..48375ae4 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -2,6 +2,7 @@ from distutils.version import StrictVersion as SV # pylint: enable = E0611,F0401 import sys +import warnings import pykka @@ -17,6 +18,9 @@ if (isinstance(pykka.__version__, basestring) u'Mopidy requires Pykka >= 1.0, < 2, but found %s' % pykka.__version__) +warnings.filterwarnings('ignore', 'could not open display') + + __version__ = '0.8.0' From e8af2276e285858abcee98196dbb163b4e78de5a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 12:54:21 +0100 Subject: [PATCH 154/233] Log warnings instead of just printing them --- mopidy/utils/log.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 3421746d..80047680 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -6,6 +6,7 @@ from . import deps, versioning def setup_logging(verbosity_level, save_debug_log): + logging.captureWarnings(True) setup_root_logger() setup_console_logging(verbosity_level) if save_debug_log: From 9bc123693e984f2d432dbf56221cabf43d8b3e17 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 14:23:10 +0100 Subject: [PATCH 155/233] Make NAD mixer respond to interrupts during calibration --- docs/changes.rst | 7 +++++++ mopidy/audio/mixers/nad.py | 19 +++++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 025ed71e..c88027bd 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -60,6 +60,13 @@ backends: dummy/mocked lower layers easier than with the old variant, where dependencies where looked up in Pykka's actor registry. +**Changes** + +- Made the :mod:`NAD mixer ` responsive to interrupts + during amplifier calibration. It will now quit immediately, while previously + it completed the calibration first, and then quit, which could take more than + 15 seconds. + **Bug fixes** - :issue:`213`: Fix "streaming task paused, reason not-negotiated" errors diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index cb1266a1..1a807e39 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -179,7 +179,7 @@ class NadTalker(pykka.ThreadingActor): self._select_speakers() self._select_input_source() self.mute(False) - self._calibrate_volume() + self.calibrate_volume() def _get_device_model(self): model = self._ask_device('Main.Model') @@ -205,14 +205,21 @@ class NadTalker(pykka.ThreadingActor): else: self._check_and_set('Main.Mute', 'Off') - def _calibrate_volume(self): + def calibrate_volume(self, current_nad_volume=None): # The NAD C 355BEE amplifier has 40 different volume levels. We have no # way of asking on which level we are. Thus, we must calibrate the # mixer by decreasing the volume 39 times. - logger.info(u'NAD amplifier: Calibrating by setting volume to 0') - self._nad_volume = self.VOLUME_LEVELS - self.set_volume(0) - logger.info(u'NAD amplifier: Done calibrating') + if current_nad_volume is None: + current_nad_volume = self.VOLUME_LEVELS + if current_nad_volume == self.VOLUME_LEVELS: + logger.info(u'NAD amplifier: Calibrating by setting volume to 0') + self._nad_volume = current_nad_volume + if self._decrease_volume(): + current_nad_volume -= 1 + if current_nad_volume == 0: + logger.info(u'NAD amplifier: Done calibrating') + else: + self.actor_ref.proxy().calibrate_volume(current_nad_volume) def set_volume(self, volume): # Increase or decrease the amplifier volume until it matches the given From a5df718276f1f021138fab4c169c4c88412e2eba Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 19:21:42 +0100 Subject: [PATCH 156/233] docs: Sync front page with README --- docs/index.rst | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 0af510d0..bdd8e4c1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2,13 +2,19 @@ Mopidy ****** -Mopidy is a music server which can play music from `Spotify -`_ or from your local hard drive. To search for music -in Spotify's vast archive, manage playlists, and play music, you can use most -`MPD clients `_. MPD clients are available for most -platforms, including Windows, Mac OS X, Linux, Android, and iOS. +Mopidy is a music server which can play music both from your local hard drive +and from `Spotify `_. Searches returns results from +both your local hard drive and from Spotify, and you can mix tracks from both +sources in your play queue. Your Spotify playlists are also available for use, +though we don't support modifying them yet. -To install Mopidy, start out by reading :ref:`installation`. +To control your music server, you can use the Ubuntu Sound Menu on the machine +running Mopidy, any device on the same network which supports the DLNA media +controller spec (with the help of Rygel in addition to Mopidy), or any `MPD +client `_. MPD clients are available for most platforms, +including Windows, Mac OS X, Linux, Android and iOS. + +To install Mopidy, start by reading :ref:`installation`. If you get stuck, we usually hang around at ``#mopidy`` at `irc.freenode.net `_. If you stumble into a bug or got a feature request, @@ -22,6 +28,7 @@ Project resources - `Documentation `_ - `Source code `_ - `Issue tracker `_ +- `CI server `_ - IRC: ``#mopidy`` at `irc.freenode.net `_ @@ -39,6 +46,7 @@ User documentation licenses changes + Reference documentation ======================= @@ -48,6 +56,7 @@ Reference documentation api/index modules/index + Development documentation ========================= @@ -56,10 +65,10 @@ Development documentation development + Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` - From 0a8dc743a51d682f456f1f27ff1740787e946478 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 30 Oct 2012 09:31:16 +0100 Subject: [PATCH 157/233] Fix logging on Python 2.6 (fixes #220) --- mopidy/utils/log.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 80047680..bb966a1d 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -6,11 +6,13 @@ from . import deps, versioning def setup_logging(verbosity_level, save_debug_log): - logging.captureWarnings(True) setup_root_logger() setup_console_logging(verbosity_level) if save_debug_log: setup_debug_logging_to_file() + if hasattr(logging, 'captureWarnings'): + # New in Python 2.7 + logging.captureWarnings(True) logger = logging.getLogger('mopidy.utils.log') logger.info(u'Starting Mopidy %s', versioning.get_version()) logger.info(u'%(name)s: %(version)s', deps.platform_info()) From bbda85462d19466a83f9db30a64747352b82f386 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 20:45:50 +0100 Subject: [PATCH 158/233] Docstring formatting --- mopidy/models.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 8eaa4ee5..a8edfde2 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -203,14 +203,14 @@ class Track(ImmutableObject): class Playlist(ImmutableObject): """ - :param uri: playlist URI - :type uri: string - :param name: playlist name - :type name: string - :param tracks: playlist's tracks - :type tracks: list of :class:`Track` elements - :param last_modified: playlist's modification time - :type last_modified: :class:`datetime.datetime` + :param uri: playlist URI + :type uri: string + :param name: playlist name + :type name: string + :param tracks: playlist's tracks + :type tracks: list of :class:`Track` elements + :param last_modified: playlist's modification time + :type last_modified: :class:`datetime.datetime` """ #: The playlist URI. Read-only. From d1a42d95f1802c02a8633b70095cfbff541e2aa4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 30 Oct 2012 09:56:26 +0100 Subject: [PATCH 159/233] Add Album.date attribute --- docs/changes.rst | 3 +++ mopidy/models.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index c88027bd..94779cc0 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -67,6 +67,9 @@ backends: it completed the calibration first, and then quit, which could take more than 15 seconds. +- Added :attr:`mopidy.models.Album.date` attribute. It has the same format as + the existing :attr:`mopidy.models.Track.date`. + **Bug fixes** - :issue:`213`: Fix "streaming task paused, reason not-negotiated" errors diff --git a/mopidy/models.py b/mopidy/models.py index a8edfde2..77561fe3 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -120,6 +120,8 @@ class Album(ImmutableObject): :type artists: list of :class:`Artist` :param num_tracks: number of tracks in album :type num_tracks: integer + :param date: album release date (YYYY or YYYY-MM-DD) + :type date: string :param musicbrainz_id: MusicBrainz ID :type musicbrainz_id: string """ @@ -136,6 +138,9 @@ class Album(ImmutableObject): #: The number of tracks in the album. Read-only. num_tracks = 0 + #: The album release date. Read-only. + date = None + #: The MusicBrainz ID of the album. Read-only. musicbrainz_id = None From 53184e62a03eafbc52577621201f8b1f3759a7e8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 20:46:28 +0100 Subject: [PATCH 160/233] Make all Spotify data objects always have URI set --- mopidy/backends/spotify/translator.py | 48 +++++++++++++++------------ 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 104029f5..8bc135b2 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -1,6 +1,6 @@ import logging -from spotify import Link, SpotifyError +from spotify import Link from mopidy import settings from mopidy.models import Artist, Album, Track, Playlist @@ -11,22 +11,27 @@ logger = logging.getLogger('mopidy.backends.spotify') class SpotifyTranslator(object): @classmethod def to_mopidy_artist(cls, spotify_artist): + if spotify_artist is None: + return + uri = str(Link.from_artist(spotify_artist)) if not spotify_artist.is_loaded(): - return Artist(name=u'[loading...]') - return Artist( - uri=str(Link.from_artist(spotify_artist)), - name=spotify_artist.name() - ) + return Artist(uri=uri, name=u'[loading...]') + return Artist(uri=uri, name=spotify_artist.name()) @classmethod def to_mopidy_album(cls, spotify_album): - if spotify_album is None or not spotify_album.is_loaded(): - return Album(name=u'[loading...]') + if spotify_album is None: + return + uri = str(Link.from_album(spotify_album)) + if not spotify_album.is_loaded(): + return Album(uri=uri, name=u'[loading...]') # TODO pyspotify got much more data on albums than this - return Album(name=spotify_album.name()) + return Album(uri=uri, name=spotify_album.name()) @classmethod def to_mopidy_track(cls, spotify_track): + if spotify_track is None: + return uri = str(Link.from_track(spotify_track, 0)) if not spotify_track.is_loaded(): return Track(uri=uri, name=u'[loading...]') @@ -48,17 +53,16 @@ class SpotifyTranslator(object): @classmethod def to_mopidy_playlist(cls, spotify_playlist): - if not spotify_playlist.is_loaded(): - return Playlist(name=u'[loading...]') - if spotify_playlist.type() != 'playlist': + if spotify_playlist is None or spotify_playlist.type() != 'playlist': return - try: - return Playlist( - uri=str(Link.from_playlist(spotify_playlist)), - name=spotify_playlist.name(), - # FIXME if check on link is a hackish workaround for is_local - tracks=[cls.to_mopidy_track(t) for t in spotify_playlist - if str(Link.from_track(t, 0))], - ) - except SpotifyError, e: - logger.warning(u'Failed translating Spotify playlist: %s', e) + uri = str(Link.from_playlist(spotify_playlist)) + if not spotify_playlist.is_loaded(): + return Playlist(uri=uri, name=u'[loading...]') + return Playlist( + uri=uri, + name=spotify_playlist.name(), + tracks=[ + cls.to_mopidy_track(spotify_track) + for spotify_track in spotify_playlist + if not spotify_track.is_local()], + ) From e792fcd3b934b9c8d4ef794db59df70b2c4667a5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 30 Oct 2012 10:07:11 +0100 Subject: [PATCH 161/233] Include release year and artist on Spotify albums --- docs/changes.rst | 2 ++ mopidy/backends/spotify/translator.py | 13 +++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 94779cc0..dcf08795 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -70,6 +70,8 @@ backends: - Added :attr:`mopidy.models.Album.date` attribute. It has the same format as the existing :attr:`mopidy.models.Track.date`. +- The Spotify backend now includes release year and artist on albums. + **Bug fixes** - :issue:`213`: Fix "streaming task paused, reason not-negotiated" errors diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 8bc135b2..b424e4b1 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -25,8 +25,11 @@ class SpotifyTranslator(object): uri = str(Link.from_album(spotify_album)) if not spotify_album.is_loaded(): return Album(uri=uri, name=u'[loading...]') - # TODO pyspotify got much more data on albums than this - return Album(uri=uri, name=spotify_album.name()) + return Album( + uri=uri, + name=spotify_album.name(), + artists=[cls.to_mopidy_artist(spotify_album.artist())], + date=spotify_album.year()) @classmethod def to_mopidy_track(cls, spotify_track): @@ -48,8 +51,7 @@ class SpotifyTranslator(object): track_no=spotify_track.index(), date=date, length=spotify_track.duration(), - bitrate=settings.SPOTIFY_BITRATE, - ) + bitrate=settings.SPOTIFY_BITRATE) @classmethod def to_mopidy_playlist(cls, spotify_playlist): @@ -64,5 +66,4 @@ class SpotifyTranslator(object): tracks=[ cls.to_mopidy_track(spotify_track) for spotify_track in spotify_playlist - if not spotify_track.is_local()], - ) + if not spotify_track.is_local()]) From 1207700a1550a8f3193e5105695761574a5befb5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 30 Oct 2012 10:11:18 +0100 Subject: [PATCH 162/233] Convert Spotify translator to plain functions --- mopidy/backends/spotify/library.py | 5 +- mopidy/backends/spotify/session_manager.py | 8 +- mopidy/backends/spotify/translator.py | 112 ++++++++++----------- 3 files changed, 58 insertions(+), 67 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index e237a04a..bf057bee 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -6,7 +6,7 @@ from spotify import Link, SpotifyError from mopidy.backends import base from mopidy.models import Track, Playlist -from .translator import SpotifyTranslator +from . import translator logger = logging.getLogger('mopidy.backends.spotify') @@ -24,8 +24,7 @@ class SpotifyTrack(Track): if self._track: return self._track elif self._spotify_track.is_loaded(): - self._track = SpotifyTranslator.to_mopidy_track( - self._spotify_track) + self._track = translator.to_mopidy_track(self._spotify_track) return self._track else: return self._unloaded_track diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 983f3861..23b99d48 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -8,9 +8,9 @@ from mopidy import settings from mopidy.models import Playlist from mopidy.utils import process, versioning +from . import translator from .container_manager import SpotifyContainerManager from .playlist_manager import SpotifyPlaylistManager -from .translator import SpotifyTranslator logger = logging.getLogger('mopidy.backends.spotify') @@ -141,8 +141,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): logger.debug(u'Still getting data; skipped refresh of playlists') return playlists = map( - SpotifyTranslator.to_mopidy_playlist, - self.session.playlist_container()) + translator.to_mopidy_playlist, self.session.playlist_container()) playlists = filter(None, playlists) self.backend.stored_playlists.playlists = playlists logger.info(u'Loaded %d Spotify playlist(s)', len(playlists)) @@ -154,8 +153,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): # TODO Consider launching a second search if results.total_tracks() # is larger than len(results.tracks()) playlist = Playlist(tracks=[ - SpotifyTranslator.to_mopidy_track(t) - for t in results.tracks()]) + translator.to_mopidy_track(t) for t in results.tracks()]) queue.put(playlist) self.connected.wait() self.session.search( diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index b424e4b1..4ad92fe9 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -1,69 +1,63 @@ -import logging - from spotify import Link from mopidy import settings from mopidy.models import Artist, Album, Track, Playlist -logger = logging.getLogger('mopidy.backends.spotify') + +def to_mopidy_artist(spotify_artist): + if spotify_artist is None: + return + uri = str(Link.from_artist(spotify_artist)) + if not spotify_artist.is_loaded(): + return Artist(uri=uri, name=u'[loading...]') + return Artist(uri=uri, name=spotify_artist.name()) -class SpotifyTranslator(object): - @classmethod - def to_mopidy_artist(cls, spotify_artist): - if spotify_artist is None: - return - uri = str(Link.from_artist(spotify_artist)) - if not spotify_artist.is_loaded(): - return Artist(uri=uri, name=u'[loading...]') - return Artist(uri=uri, name=spotify_artist.name()) +def to_mopidy_album(spotify_album): + if spotify_album is None: + return + uri = str(Link.from_album(spotify_album)) + if not spotify_album.is_loaded(): + return Album(uri=uri, name=u'[loading...]') + return Album( + uri=uri, + name=spotify_album.name(), + artists=[to_mopidy_artist(spotify_album.artist())], + date=spotify_album.year()) - @classmethod - def to_mopidy_album(cls, spotify_album): - if spotify_album is None: - return - uri = str(Link.from_album(spotify_album)) - if not spotify_album.is_loaded(): - return Album(uri=uri, name=u'[loading...]') - return Album( - uri=uri, - name=spotify_album.name(), - artists=[cls.to_mopidy_artist(spotify_album.artist())], - date=spotify_album.year()) - @classmethod - def to_mopidy_track(cls, spotify_track): - if spotify_track is None: - return - uri = str(Link.from_track(spotify_track, 0)) - if not spotify_track.is_loaded(): - return Track(uri=uri, name=u'[loading...]') - spotify_album = spotify_track.album() - if spotify_album is not None and spotify_album.is_loaded(): - date = spotify_album.year() - else: - date = None - return Track( - uri=uri, - name=spotify_track.name(), - artists=[cls.to_mopidy_artist(a) for a in spotify_track.artists()], - album=cls.to_mopidy_album(spotify_track.album()), - track_no=spotify_track.index(), - date=date, - length=spotify_track.duration(), - bitrate=settings.SPOTIFY_BITRATE) +def to_mopidy_track(spotify_track): + if spotify_track is None: + return + uri = str(Link.from_track(spotify_track, 0)) + if not spotify_track.is_loaded(): + return Track(uri=uri, name=u'[loading...]') + spotify_album = spotify_track.album() + if spotify_album is not None and spotify_album.is_loaded(): + date = spotify_album.year() + else: + date = None + return Track( + uri=uri, + name=spotify_track.name(), + artists=[to_mopidy_artist(a) for a in spotify_track.artists()], + album=to_mopidy_album(spotify_track.album()), + track_no=spotify_track.index(), + date=date, + length=spotify_track.duration(), + bitrate=settings.SPOTIFY_BITRATE) - @classmethod - def to_mopidy_playlist(cls, spotify_playlist): - if spotify_playlist is None or spotify_playlist.type() != 'playlist': - return - uri = str(Link.from_playlist(spotify_playlist)) - if not spotify_playlist.is_loaded(): - return Playlist(uri=uri, name=u'[loading...]') - return Playlist( - uri=uri, - name=spotify_playlist.name(), - tracks=[ - cls.to_mopidy_track(spotify_track) - for spotify_track in spotify_playlist - if not spotify_track.is_local()]) + +def to_mopidy_playlist(spotify_playlist): + if spotify_playlist is None or spotify_playlist.type() != 'playlist': + return + uri = str(Link.from_playlist(spotify_playlist)) + if not spotify_playlist.is_loaded(): + return Playlist(uri=uri, name=u'[loading...]') + return Playlist( + uri=uri, + name=spotify_playlist.name(), + tracks=[ + to_mopidy_track(spotify_track) + for spotify_track in spotify_playlist + if not spotify_track.is_local()]) From d60bb57f5f77b9adc59a374836157013e59b7818 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 30 Oct 2012 10:18:40 +0100 Subject: [PATCH 163/233] Test new Album.date attribute --- tests/models_test.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/models_test.py b/tests/models_test.py index a3c9cc96..004c0a28 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -164,6 +164,12 @@ class AlbumTest(unittest.TestCase): self.assertEqual(album.num_tracks, num_tracks) self.assertRaises(AttributeError, setattr, album, 'num_tracks', None) + def test_date(self): + date = '1977-01-01' + album = Album(date=date) + self.assertEqual(album.date, date) + self.assertRaises(AttributeError, setattr, album, 'date', None) + def test_musicbrainz_id(self): mb_id = u'mb-id' album = Album(musicbrainz_id=mb_id) @@ -229,6 +235,13 @@ class AlbumTest(unittest.TestCase): self.assertEqual(album1, album2) self.assertEqual(hash(album1), hash(album2)) + def test_eq_date(self): + date = '1977-01-01' + album1 = Album(date=date) + album2 = Album(date=date) + self.assertEqual(album1, album2) + self.assertEqual(hash(album1), hash(album2)) + def test_eq_musibrainz_id(self): album1 = Album(musicbrainz_id=u'id') album2 = Album(musicbrainz_id=u'id') @@ -276,6 +289,12 @@ class AlbumTest(unittest.TestCase): self.assertNotEqual(album1, album2) self.assertNotEqual(hash(album1), hash(album2)) + def test_ne_date(self): + album1 = Album(date='1977-01-01') + album2 = Album(date='1977-01-02') + self.assertNotEqual(album1, album2) + self.assertNotEqual(hash(album1), hash(album2)) + def test_ne_musicbrainz_id(self): album1 = Album(musicbrainz_id=u'id1') album2 = Album(musicbrainz_id=u'id2') From 26e0f9f69424d0991f1ef179fd931c19782f6933 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 30 Oct 2012 15:15:44 +0100 Subject: [PATCH 164/233] docs: Homebrew now includes gst-python --- docs/installation/gstreamer.rst | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/docs/installation/gstreamer.rst b/docs/installation/gstreamer.rst index 42685ad0..38dbb86c 100644 --- a/docs/installation/gstreamer.rst +++ b/docs/installation/gstreamer.rst @@ -39,26 +39,11 @@ repository:: Installing GStreamer on OS X ============================ -.. note:: - - We have been working with `Homebrew `_ to - make all the GStreamer packages easily installable on OS X using Homebrew. - We've gotten most of our packages included, but the Homebrew guys aren't - very happy to include Python specific packages into Homebrew, even though - they are not installable by pip. If you're interested, see the discussion - in `Homebrew's issue #1612 - `_ for details. - -The following is currently the shortest path to installing GStreamer with -Python bindings on OS X using Homebrew. +We have been working with `Homebrew `_ for a +to make all the GStreamer packages easily installable on OS X. #. Install `Homebrew `_. -#. Download our Homebrew formula for ``gst-python``:: - - curl -o $(brew --prefix)/Library/Formula/gst-python.rb \ - https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/gst-python.rb - #. Install the required packages:: brew install gst-python gst-plugins-good gst-plugins-ugly From 19787f2850423938e33c1d3b19c3d03337ab7d25 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 30 Oct 2012 15:33:01 +0100 Subject: [PATCH 165/233] Make nosetests only look in tests/ for tests to run Without this, it will also look in mopidy/ for tests, and some modules there may raise exceptions on import time because of missing dependencies, like dbus not being available on OS X. --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index bce0a6e2..f9894674 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,3 +4,4 @@ verbosity = 1 cover-package = mopidy cover-inclusive = 1 cover-html = 1 +tests = tests From b53a82dbbaa70b67d72f87c8ba268699ae692ca8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 24 Oct 2012 22:35:36 +0200 Subject: [PATCH 166/233] Fix 'not-negotiated' errors on some Spotify tracks (fixes #213) --- docs/changes.rst | 10 ++++++++++ mopidy/audio/__init__.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index bd90111e..bea524e8 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,6 +5,16 @@ Changes This change log is used to track all major changes to Mopidy. +v0.8.1 (in development) +======================= + +**Bug fixes** + +- :issue:`213`: Fix "streaming task paused, reason not-negotiated" errors + observed by some users on some Spotify tracks due to a change introduced in + 0.8.0. See the issue for a patch that applies to 0.8.0. + + v0.8.0 (2012-09-20) =================== diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index df5efb92..d630a0f0 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -70,6 +70,21 @@ class Audio(ThreadingActor): fakesink = gst.element_factory_make('fakesink') self._playbin.set_property('video-sink', fakesink) + self._playbin.connect('notify::source', self._on_new_source) + + def _on_new_source(self, element, pad): + uri = element.get_property('uri') + if not uri or not uri.startswith('appsrc://'): + return + + # These caps matches the audio data provided by libspotify + default_caps = gst.Caps( + 'audio/x-raw-int, endianness=(int)1234, channels=(int)2, ' + 'width=(int)16, depth=(int)16, signed=(boolean)true, ' + 'rate=(int)44100') + source = element.get_property('source') + source.set_property('caps', default_caps) + def _teardown_playbin(self): self._playbin.set_state(gst.STATE_NULL) From f1e2cff3e0ee44c1d00ddbf0f97b34a60eec5b3f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 26 Oct 2012 22:32:06 +0200 Subject: [PATCH 167/233] Update to work with Pykka 1.0 --- docs/changes.rst | 4 ++++ docs/installation/index.rst | 2 +- mopidy/backends/dummy/__init__.py | 2 +- mopidy/backends/local/__init__.py | 2 +- mopidy/backends/spotify/__init__.py | 2 +- mopidy/utils/network.py | 2 +- requirements/core.txt | 2 +- tests/utils/network/connection_test.py | 4 ++-- 8 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index bea524e8..2926adf9 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -8,6 +8,10 @@ This change log is used to track all major changes to Mopidy. v0.8.1 (in development) ======================= +**Dependencies** + +- Pykka >= 1.0 is now required. + **Bug fixes** - :issue:`213`: Fix "streaming task paused, reason not-negotiated" errors diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 66b920f8..c58ba9dd 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -26,7 +26,7 @@ dependencies installed. - Python >= 2.6, < 3 - - Pykka >= 0.12.3:: + - Pykka >= 1.0:: sudo pip install -U pykka diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy/__init__.py index 3ada0052..8eb9029c 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy/__init__.py @@ -14,7 +14,7 @@ class DummyBackend(ThreadingActor, base.Backend): """ def __init__(self, *args, **kwargs): - super(DummyBackend, self).__init__(*args, **kwargs) + super(DummyBackend, self).__init__() self.current_playlist = core.CurrentPlaylistController(backend=self) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index db86e56f..6488d97c 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -32,7 +32,7 @@ class LocalBackend(ThreadingActor, base.Backend): """ def __init__(self, *args, **kwargs): - super(LocalBackend, self).__init__(*args, **kwargs) + super(LocalBackend, self).__init__() self.current_playlist = core.CurrentPlaylistController(backend=self) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 1feb1c65..3811458d 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -47,7 +47,7 @@ class SpotifyBackend(ThreadingActor, base.Backend): from .playback import SpotifyPlaybackProvider from .stored_playlists import SpotifyStoredPlaylistsProvider - super(SpotifyBackend, self).__init__(*args, **kwargs) + super(SpotifyBackend, self).__init__() self.current_playlist = core.CurrentPlaylistController(backend=self) diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 7d97daf8..ed81684e 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -250,7 +250,7 @@ class Connection(object): return True try: - self.actor_ref.send_one_way({'received': data}) + self.actor_ref.tell({'received': data}) except ActorDeadError: self.stop(u'Actor is dead.') diff --git a/requirements/core.txt b/requirements/core.txt index 8f9da622..7f83e251 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -1 +1 @@ -Pykka >= 0.12.3 +Pykka >= 1.0 diff --git a/tests/utils/network/connection_test.py b/tests/utils/network/connection_test.py index 96ddb833..0ca86a7f 100644 --- a/tests/utils/network/connection_test.py +++ b/tests/utils/network/connection_test.py @@ -383,14 +383,14 @@ class ConnectionTest(unittest.TestCase): self.assertTrue(network.Connection.recv_callback( self.mock, sentinel.fd, gobject.IO_IN)) - self.mock.actor_ref.send_one_way.assert_called_once_with( + self.mock.actor_ref.tell.assert_called_once_with( {'received': 'data'}) def test_recv_callback_handles_dead_actors(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.recv.return_value = 'data' self.mock.actor_ref = Mock() - self.mock.actor_ref.send_one_way.side_effect = pykka.ActorDeadError() + self.mock.actor_ref.tell.side_effect = pykka.ActorDeadError() self.assertTrue(network.Connection.recv_callback( self.mock, sentinel.fd, gobject.IO_IN)) From 5f23b5eafee4be9fb63a14b7a17e11a38091a42c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 30 Sep 2012 23:39:14 +0200 Subject: [PATCH 168/233] Check Pykka version on startup --- mopidy/__init__.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 26e5b904..db7106f0 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,6 +1,19 @@ +# pylint: disable = E0611,F0401 +from distutils.version import StrictVersion as SV +# pylint: enable = E0611,F0401 import sys + +import pykka + if not (2, 6) <= sys.version_info < (3,): - sys.exit(u'Mopidy requires Python >= 2.6, < 3') + sys.exit( + u'Mopidy requires Python >= 2.6, < 3, but found %s' % + '.'.join(map(str, sys.version_info[:3]))) + +if (isinstance(pykka.__version__, basestring) + and not SV('1.0') <= SV(pykka.__version__) < SV('2.0')): + sys.exit( + u'Mopidy requires Pykka >= 1.0, < 2, but found %s' % pykka.__version__) import os import platform From 6d55dae212ed3f5f64d25470ba6b197795ce8bd5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 30 Oct 2012 15:15:44 +0100 Subject: [PATCH 169/233] docs: Homebrew now includes gst-python --- docs/installation/gstreamer.rst | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/docs/installation/gstreamer.rst b/docs/installation/gstreamer.rst index 42685ad0..38dbb86c 100644 --- a/docs/installation/gstreamer.rst +++ b/docs/installation/gstreamer.rst @@ -39,26 +39,11 @@ repository:: Installing GStreamer on OS X ============================ -.. note:: - - We have been working with `Homebrew `_ to - make all the GStreamer packages easily installable on OS X using Homebrew. - We've gotten most of our packages included, but the Homebrew guys aren't - very happy to include Python specific packages into Homebrew, even though - they are not installable by pip. If you're interested, see the discussion - in `Homebrew's issue #1612 - `_ for details. - -The following is currently the shortest path to installing GStreamer with -Python bindings on OS X using Homebrew. +We have been working with `Homebrew `_ for a +to make all the GStreamer packages easily installable on OS X. #. Install `Homebrew `_. -#. Download our Homebrew formula for ``gst-python``:: - - curl -o $(brew --prefix)/Library/Formula/gst-python.rb \ - https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/gst-python.rb - #. Install the required packages:: brew install gst-python gst-plugins-good gst-plugins-ugly From b5af038a026a61e19299534ea93ca6394380d822 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 23:24:52 +0100 Subject: [PATCH 170/233] Make sure volume are returned as an int --- docs/changes.rst | 4 ++++ mopidy/utils/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index 2926adf9..5d58b503 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -18,6 +18,10 @@ v0.8.1 (in development) observed by some users on some Spotify tracks due to a change introduced in 0.8.0. See the issue for a patch that applies to 0.8.0. +- :issue:`216`: Volume returned by the MPD command `status` contained a + floating point ``.0`` suffix. This bug was introduced with the large audio + outout and mixer changes in v0.8.0. It now returns an integer again. + v0.8.0 (2012-09-20) =================== diff --git a/mopidy/utils/__init__.py b/mopidy/utils/__init__.py index aacc2e85..7d1a6dd6 100644 --- a/mopidy/utils/__init__.py +++ b/mopidy/utils/__init__.py @@ -24,7 +24,7 @@ def rescale(v, old=None, new=None): new_min, new_max = new old_min, old_max = old scaling = float(new_max - new_min) / (old_max - old_min) - return round(scaling * (v - old_min) + new_min) + return int(round(scaling * (v - old_min) + new_min)) def import_module(name): From 056cbf16ecf31ab6bd642f922275be353fd288f1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 30 Oct 2012 16:31:58 +0100 Subject: [PATCH 171/233] Update changelog for 0.8.1 --- docs/changes.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 5d58b503..5ab8ebd3 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,8 +5,11 @@ Changes This change log is used to track all major changes to Mopidy. -v0.8.1 (in development) -======================= +v0.8.1 (2012-10-30) +=================== + +A small maintenance release to fix a bug introduced in 0.8.0 and update Mopidy +to work with Pykka 1.0. **Dependencies** From 5d5f0cc6e358006ec424545a852c94546c680279 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 30 Oct 2012 17:55:25 +0100 Subject: [PATCH 172/233] Bump version number to v0.8.1 --- mopidy/__init__.py | 2 +- tests/version_test.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index db7106f0..6436522e 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -21,7 +21,7 @@ from subprocess import PIPE, Popen import glib -__version__ = '0.8.0' +__version__ = '0.8.1' DATA_PATH = os.path.join(str(glib.get_user_data_dir()), 'mopidy') CACHE_PATH = os.path.join(str(glib.get_user_cache_dir()), 'mopidy') diff --git a/tests/version_test.py b/tests/version_test.py index c3eb00c1..0608a143 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -28,8 +28,9 @@ class VersionTest(unittest.TestCase): self.assert_(SV('0.7.0') < SV('0.7.1')) self.assert_(SV('0.7.1') < SV('0.7.2')) self.assert_(SV('0.7.2') < SV('0.7.3')) - self.assert_(SV('0.7.3') < SV(__version__)) - self.assert_(SV(__version__) < SV('0.8.1')) + self.assert_(SV('0.7.3') < SV('0.8.0')) + self.assert_(SV('0.8.0') < SV(__version__)) + self.assert_(SV(__version__) < SV('0.8.2')) def test_get_platform_contains_platform(self): self.assertIn(platform.platform(), get_platform()) From 89db62bc9efac50d4ac737065ba3ee47d9030935 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 10:55:19 +0100 Subject: [PATCH 173/233] Revert "Make nosetests only look in tests/ for tests to run" This reverts commit 19787f2850423938e33c1d3b19c3d03337ab7d25. --- setup.cfg | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index f9894674..bce0a6e2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,4 +4,3 @@ verbosity = 1 cover-package = mopidy cover-inclusive = 1 cover-html = 1 -tests = tests From dd42e5684b4170b7d0e454fcbbe629c14d486b28 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 10:49:53 +0100 Subject: [PATCH 174/233] Use 'except ... as ...' --- mopidy/backends/local/stored_playlists.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index 1cb03425..a5841d8d 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -32,8 +32,8 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): for uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): try: tracks.append(self.backend.library.lookup(uri)) - except LookupError, e: - logger.error('Playlist item could not be added: %s', e) + except LookupError as ex: + logger.error('Playlist item could not be added: %s', ex) playlist = Playlist(tracks=tracks, name=name) # FIXME playlist name needs better handling From 855e57a74a9bfec734712c005b79bc8c5b69b9b2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 10:50:41 +0100 Subject: [PATCH 175/233] Use os.path.splitext to strip of file extension --- mopidy/backends/local/stored_playlists.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index a5841d8d..921fa40c 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -27,7 +27,7 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): logger.info('Loading playlists from %s', self._folder) for m3u in glob.glob(os.path.join(self._folder, '*.m3u')): - name = os.path.basename(m3u)[:-len('.m3u')] + name = os.path.splitext(os.path.basename(m3u))[0] tracks = [] for uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): try: From a679a472125b94923bc97d08131935cb1c6c74bb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 11:11:04 +0100 Subject: [PATCH 176/233] Minor test updates --- tests/backends/base/stored_playlists.py | 3 +++ tests/backends/local/stored_playlists_test.py | 24 ++++++++++--------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 57096fd3..5d01996d 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -3,6 +3,7 @@ import shutil import tempfile import mock +import pykka from mopidy import audio, core, settings from mopidy.models import Playlist @@ -22,6 +23,8 @@ class StoredPlaylistsControllerTest(object): self.stored = self.core.stored_playlists def tearDown(self): + pykka.ActorRegistry.stop_all() + if os.path.exists(settings.LOCAL_PLAYLIST_PATH): shutil.rmtree(settings.LOCAL_PLAYLIST_PATH) diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index 4dc5ecdb..188eb589 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -11,8 +11,8 @@ from tests.backends.base.stored_playlists import ( from tests.backends.local import generate_song -class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest, - unittest.TestCase): +class LocalStoredPlaylistsControllerTest( + StoredPlaylistsControllerTest, unittest.TestCase): backend_class = LocalBackend @@ -28,13 +28,13 @@ class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest, self.stored.save(Playlist(name='test2')) self.assert_(os.path.exists(path)) - def test_deleted_playlist_get_removed(self): + def test_deleted_playlist_is_removed(self): playlist = self.stored.create('test') self.stored.delete(playlist) path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') self.assert_(not os.path.exists(path)) - def test_renamed_playlist_gets_moved(self): + def test_renamed_playlist_is_moved(self): playlist = self.stored.create('test') file1 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') file2 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2.m3u') @@ -43,7 +43,7 @@ class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest, self.assert_(not os.path.exists(file1)) self.assert_(os.path.exists(file2)) - def test_playlist_contents_get_written_to_disk(self): + def test_playlist_contents_is_written_to_disk(self): track = Track(uri=generate_song(1)) uri = track.uri[len('file://'):] playlist = Playlist(tracks=[track], name='test') @@ -58,15 +58,17 @@ class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest, def test_playlists_are_loaded_at_startup(self): track = Track(uri=path_to_uri(path_to_data_dir('uri2'))) - playlist = Playlist(tracks=[track], name='test') - + playlist = self.stored.create('test') + playlist = playlist.copy(tracks=[track]) self.stored.save(playlist) - self.backend = self.backend_class.start(audio=self.audio).proxy() + backend = self.backend_class(audio=self.audio) - self.assert_(self.stored.playlists) - self.assertEqual('test', self.stored.playlists[0].name) - self.assertEqual(track.uri, self.stored.playlists[0].tracks[0].uri) + self.assert_(backend.stored_playlists.playlists) + self.assertEqual( + playlist.name, backend.stored_playlists.playlists[0].name) + self.assertEqual( + track.uri, backend.stored_playlists.playlists[0].tracks[0].uri) @unittest.SkipTest def test_santitising_of_playlist_filenames(self): From 0ddbb4e28a64d410f312945b59f87d1764a95090 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 20:25:58 +0100 Subject: [PATCH 177/233] Make core.stored_playlists.playlists read-only (#217) --- docs/changes.rst | 7 +++++++ mopidy/core/stored_playlists.py | 7 +------ tests/backends/base/stored_playlists.py | 8 +++++--- tests/frontends/mpd/protocol/stored_playlists_test.py | 8 ++++---- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 0203a89f..7fa59a3c 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -56,6 +56,13 @@ backends: dummy/mocked lower layers easier than with the old variant, where dependencies where looked up in Pykka's actor registry. +- The stored playlists part of the core API have been revised a bit: + + - :attr:`mopidy.core.StoredPlaylistsController.playlists` no longer supports + assignment to it. The `playlists` property on the backend layer still does, + and all functionality is maintained by assigning to the playlists + collections at the backend level. + **Changes** - Made the :mod:`NAD mixer ` responsive to interrupts diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index 9de1545f..a3d52023 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -15,17 +15,12 @@ class StoredPlaylistsController(object): """ Currently stored playlists. - Read/write. List of :class:`mopidy.models.Playlist`. + Read-only. List of :class:`mopidy.models.Playlist`. """ futures = [b.stored_playlists.playlists for b in self.backends] results = pykka.get_all(futures) return list(itertools.chain(*results)) - @playlists.setter # noqa - def playlists(self, playlists): - # TODO Support multiple backends - self.backends[0].stored_playlists.playlists = playlists - def create(self, name): """ Create a new playlist. diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 5d01996d..fca13b93 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -65,12 +65,13 @@ class StoredPlaylistsControllerTest(object): def test_get_by_name_returns_unique_match(self): playlist = Playlist(name='b') - self.stored.playlists = [Playlist(name='a'), playlist] + self.backend.stored_playlists.playlists = [ + Playlist(name='a'), playlist] self.assertEqual(playlist, self.stored.get(name='b')) def test_get_by_name_returns_first_of_multiple_matches(self): playlist = Playlist(name='b') - self.stored.playlists = [ + self.backend.stored_playlists.playlists = [ playlist, Playlist(name='a'), Playlist(name='b')] try: self.stored.get(name='b') @@ -79,7 +80,8 @@ class StoredPlaylistsControllerTest(object): self.assertEqual(u'"name=b" match multiple playlists', e[0]) def test_get_by_name_raises_keyerror_if_no_match(self): - self.stored.playlists = [Playlist(name='a'), Playlist(name='b')] + self.backend.stored_playlists.playlists = [ + Playlist(name='a'), Playlist(name='b')] try: self.stored.get(name='c') self.fail(u'Should raise LookupError if no match') diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index 8cfcb338..346cd37f 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -7,7 +7,7 @@ from tests.frontends.mpd import protocol class StoredPlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist(self): - self.core.stored_playlists.playlists = [ + self.backend.stored_playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] self.sendRequest(u'listplaylist "name"') @@ -19,7 +19,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertEqualResponse(u'ACK [50@0] {listplaylist} No such playlist') def test_listplaylistinfo(self): - self.core.stored_playlists.playlists = [ + self.backend.stored_playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] self.sendRequest(u'listplaylistinfo "name"') @@ -35,7 +35,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists(self): last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) - self.core.stored_playlists.playlists = [ + self.backend.stored_playlists.playlists = [ Playlist(name='a', last_modified=last_modified)] self.sendRequest(u'listplaylists') @@ -47,7 +47,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): def test_load_known_playlist_appends_to_current_playlist(self): self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) - self.core.stored_playlists.playlists = [ + self.backend.stored_playlists.playlists = [ Playlist(name='A-list', tracks=[ Track(uri='c'), Track(uri='d'), Track(uri='e')])] From e2474da1efa4de0425903500e3dcdec1ec3c6e9e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 09:42:35 +0100 Subject: [PATCH 178/233] Make core.stored_playlists.create() support multibackend (#217) --- mopidy/core/stored_playlists.py | 16 +++++++++++++--- tests/core/stored_playlists_test.py | 22 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index a3d52023..55b0bdce 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -21,16 +21,26 @@ class StoredPlaylistsController(object): results = pykka.get_all(futures) return list(itertools.chain(*results)) - def create(self, name): + def create(self, name, uri_scheme=None): """ Create a new playlist. + If ``uri_scheme`` matches an URI scheme handled by a current backend, + that backend is asked to create the playlist. If ``uri_scheme`` is + :class:`None` or doesn't match a current backend, the first backend is + asked to create the playlist. + :param name: name of the new playlist :type name: string + :param uri_scheme: use the backend matching the URI scheme + :type uri_scheme: string :rtype: :class:`mopidy.models.Playlist` """ - # TODO Support multiple backends - return self.backends[0].stored_playlists.create(name).get() + if uri_scheme in self.backends.by_uri_scheme: + backend = self.backends.by_uri_scheme[uri_scheme] + else: + backend = self.backends[0] + return backend.stored_playlists.create(name).get() def delete(self, playlist): """ diff --git a/tests/core/stored_playlists_test.py b/tests/core/stored_playlists_test.py index d92b89c0..87c90137 100644 --- a/tests/core/stored_playlists_test.py +++ b/tests/core/stored_playlists_test.py @@ -37,5 +37,27 @@ class StoredPlaylistsTest(unittest.TestCase): self.assertIn(self.pl2a, result) self.assertIn(self.pl2b, result) + def test_create_without_uri_scheme_uses_first_backend(self): + playlist = Playlist() + self.sp1.create().get.return_value = playlist + self.sp1.reset_mock() + + result = self.core.stored_playlists.create('foo') + + self.assertEqual(playlist, result) + self.sp1.create.assert_called_once_with('foo') + self.assertFalse(self.sp2.create.called) + + def test_create_with_uri_scheme_selects_the_matching_backend(self): + playlist = Playlist() + self.sp2.create().get.return_value = playlist + self.sp2.reset_mock() + + result = self.core.stored_playlists.create('foo', uri_scheme='dummy2') + + self.assertEqual(playlist, result) + self.assertFalse(self.sp1.create.called) + self.sp2.create.assert_called_once_with('foo') + # TODO The rest of the stored playlists API is pending redesign before # we'll update it to support multiple backends. From 8cc1896b9df66e66970724b002ac6cbfc9cfbfc2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 09:47:31 +0100 Subject: [PATCH 179/233] Make core.stored_playlists.lookup() support multibackend (#217) --- mopidy/core/stored_playlists.py | 13 +++++++++---- tests/core/stored_playlists_test.py | 12 ++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index 55b0bdce..3525ff67 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -1,4 +1,5 @@ import itertools +import urlparse import pykka @@ -85,14 +86,18 @@ class StoredPlaylistsController(object): def lookup(self, uri): """ Lookup playlist with given URI in both the set of stored playlists and - in any other playlist sources. + in any other playlist sources. Returns :class:`None` if not found. :param uri: playlist URI :type uri: string - :rtype: :class:`mopidy.models.Playlist` + :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ - # TODO Support multiple backends - return self.backends[0].stored_playlists.lookup(uri).get() + uri_scheme = urlparse.urlparse(uri).scheme + backend = self.backends.by_uri_scheme.get(uri_scheme, None) + if backend: + return backend.stored_playlists.lookup(uri).get() + else: + return None def refresh(self): """ diff --git a/tests/core/stored_playlists_test.py b/tests/core/stored_playlists_test.py index 87c90137..aeb22e1a 100644 --- a/tests/core/stored_playlists_test.py +++ b/tests/core/stored_playlists_test.py @@ -59,5 +59,17 @@ class StoredPlaylistsTest(unittest.TestCase): self.assertFalse(self.sp1.create.called) self.sp2.create.assert_called_once_with('foo') + def test_lookup_selects_the_dummy1_backend(self): + self.core.stored_playlists.lookup('dummy1:a') + + self.sp1.lookup.assert_called_once_with('dummy1:a') + self.assertFalse(self.sp2.lookup.called) + + def test_lookup_selects_the_dummy2_backend(self): + self.core.stored_playlists.lookup('dummy2:a') + + self.assertFalse(self.sp1.lookup.called) + self.sp2.lookup.assert_called_once_with('dummy2:a') + # TODO The rest of the stored playlists API is pending redesign before # we'll update it to support multiple backends. From fd88b974e859825fec5d59d5c73797b775fc5029 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 10:10:53 +0100 Subject: [PATCH 180/233] Make core.stored_playlists.refresh() support multibackend (#217) --- mopidy/core/stored_playlists.py | 19 ++++++++++++++++--- tests/core/stored_playlists_test.py | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index 3525ff67..b34b8bc1 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -99,12 +99,25 @@ class StoredPlaylistsController(object): else: return None - def refresh(self): + def refresh(self, uri_scheme=None): """ Refresh the stored playlists in :attr:`playlists`. + + If ``uri_scheme`` is :class:`None`, all backends are asked to refresh. + If ``uri_scheme`` is an URI scheme handled by a backend, only that + backend is asked to refresh. If ``uri_scheme`` doesn't match any + current backend, nothing happens. + + :param uri_scheme: limit to the backend matching the URI scheme + :type uri_scheme: string """ - # TODO Support multiple backends - return self.backends[0].stored_playlists.refresh().get() + if uri_scheme is None: + futures = [b.stored_playlists.refresh() for b in self.backends] + pykka.get_all(futures) + else: + if uri_scheme in self.backends.by_uri_scheme: + backend = self.backends.by_uri_scheme[uri_scheme] + backend.stored_playlists.refresh().get() def rename(self, playlist, new_name): """ diff --git a/tests/core/stored_playlists_test.py b/tests/core/stored_playlists_test.py index aeb22e1a..2e90416e 100644 --- a/tests/core/stored_playlists_test.py +++ b/tests/core/stored_playlists_test.py @@ -71,5 +71,23 @@ class StoredPlaylistsTest(unittest.TestCase): self.assertFalse(self.sp1.lookup.called) self.sp2.lookup.assert_called_once_with('dummy2:a') + def test_refresh_without_uri_scheme_refreshes_all_backends(self): + self.core.stored_playlists.refresh() + + self.sp1.refresh.assert_called_once_with() + self.sp2.refresh.assert_called_once_with() + + def test_refresh_with_uri_scheme_refreshes_matching_backend(self): + self.core.stored_playlists.refresh(uri_scheme='dummy2') + + self.assertFalse(self.sp1.refresh.called) + self.sp2.refresh.assert_called_once_with() + + def test_refresh_with_unknown_uri_scheme_refreshes_nothing(self): + self.core.stored_playlists.refresh(uri_scheme='foobar') + + self.assertFalse(self.sp1.refresh.called) + self.assertFalse(self.sp2.refresh.called) + # TODO The rest of the stored playlists API is pending redesign before # we'll update it to support multiple backends. From d8378e9284f8c42d31c29310ff1466c098e93d74 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 11:12:07 +0100 Subject: [PATCH 181/233] Set URI on local playlists when reading from disk (#217) --- mopidy/backends/local/stored_playlists.py | 8 +++++--- tests/backends/local/stored_playlists_test.py | 5 +++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index 921fa40c..49d6edba 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -6,6 +6,7 @@ import shutil from mopidy import settings from mopidy.backends import base from mopidy.models import Playlist +from mopidy.utils import path from .translator import parse_m3u @@ -27,14 +28,15 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): logger.info('Loading playlists from %s', self._folder) for m3u in glob.glob(os.path.join(self._folder, '*.m3u')): + uri = path.path_to_uri(m3u) name = os.path.splitext(os.path.basename(m3u))[0] tracks = [] - for uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): + for track_uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): try: - tracks.append(self.backend.library.lookup(uri)) + tracks.append(self.backend.library.lookup(track_uri)) except LookupError as ex: logger.error('Playlist item could not be added: %s', ex) - playlist = Playlist(tracks=tracks, name=name) + playlist = Playlist(uri=uri, name=name, tracks=tracks) # FIXME playlist name needs better handling # FIXME tracks should come from lib. lookup diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index 188eb589..d1d6989a 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -57,6 +57,8 @@ class LocalStoredPlaylistsControllerTest( self.assertEqual(uri, contents.strip()) def test_playlists_are_loaded_at_startup(self): + playlist_path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') + track = Track(uri=path_to_uri(path_to_data_dir('uri2'))) playlist = self.stored.create('test') playlist = playlist.copy(tracks=[track]) @@ -65,6 +67,9 @@ class LocalStoredPlaylistsControllerTest( backend = self.backend_class(audio=self.audio) self.assert_(backend.stored_playlists.playlists) + self.assertEqual( + path_to_uri(playlist_path), + backend.stored_playlists.playlists[0].uri) self.assertEqual( playlist.name, backend.stored_playlists.playlists[0].name) self.assertEqual( From 51aab4f138ab019e892c16ffafacdb732278a29d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 11:39:13 +0100 Subject: [PATCH 182/233] Make local stored playlists set and use URIs (#217) --- mopidy/backends/local/stored_playlists.py | 14 ++++- tests/backends/base/stored_playlists.py | 30 ++++++---- tests/backends/local/stored_playlists_test.py | 55 +++++++++---------- 3 files changed, 57 insertions(+), 42 deletions(-) diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index 49d6edba..ef7cc92d 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -20,7 +20,9 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): self.refresh() def lookup(self, uri): - pass # TODO + for playlist in self._playlists: + if playlist.uri == uri: + return playlist def refresh(self): playlists = [] @@ -46,7 +48,9 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): self.playlists = playlists def create(self, name): - playlist = Playlist(name=name) + file_path = os.path.join(self._folder, name + '.m3u') + uri = path.path_to_uri(file_path) + playlist = Playlist(uri=uri, name=name) self.save(playlist) return playlist @@ -74,9 +78,10 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): shutil.move(src, dst) def save(self, playlist): - file_path = os.path.join(self._folder, playlist.name + '.m3u') + assert playlist.uri, 'Cannot save playlist without URI' # FIXME this should be a save_m3u function, not inside save + file_path = playlist.uri[len('file://'):] with open(file_path, 'w') as file_handle: for track in playlist.tracks: if track.uri.startswith('file://'): @@ -84,4 +89,7 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): else: file_handle.write(track.uri + '\n') + original_playlist = self.lookup(playlist.uri) + if original_playlist is not None: + self._playlists.remove(original_playlist) self._playlists.append(playlist) diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index fca13b93..209aad0a 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -30,11 +30,15 @@ class StoredPlaylistsControllerTest(object): settings.runtime.clear() - def test_create(self): + def test_create_returns_playlist_with_name_set(self): playlist = self.stored.create('test') self.assertEqual(playlist.name, 'test') - def test_create_in_playlists(self): + def test_create_returns_playlist_with_uri_set(self): + playlist = self.stored.create('test') + self.assert_(playlist.uri) + + def test_create_adds_playlist_to_playlists_collection(self): playlist = self.stored.create('test') self.assert_(self.stored.playlists) self.assertIn(playlist, self.stored.playlists) @@ -88,9 +92,12 @@ class StoredPlaylistsControllerTest(object): except LookupError as e: self.assertEqual(u'"name=c" match no playlists', e[0]) - @unittest.SkipTest - def test_lookup(self): - pass + def test_lookup_finds_playlist_by_uri(self): + original_playlist = self.stored.create('test') + + looked_up_playlist = self.stored.lookup(original_playlist.uri) + + self.assertEqual(original_playlist, looked_up_playlist) @unittest.SkipTest def test_refresh(self): @@ -106,11 +113,14 @@ class StoredPlaylistsControllerTest(object): test = lambda: self.stored.get(name='test2') self.assertRaises(LookupError, test) - def test_save(self): - # FIXME should we handle playlists without names? - playlist = Playlist(name='test') - self.stored.save(playlist) - self.assertIn(playlist, self.stored.playlists) + def test_save_replaces_playlist_with_updated_playlist(self): + playlist1 = self.stored.create('test1') + self.assertIn(playlist1, self.stored.playlists) + + playlist2 = playlist1.copy(name='test2') + self.stored.save(playlist2) + self.assertNotIn(playlist1, self.stored.playlists) + self.assertIn(playlist2, self.stored.playlists) @unittest.SkipTest def test_playlist_with_unknown_track(self): diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index d1d6989a..446d87f1 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -2,7 +2,7 @@ import os from mopidy import settings from mopidy.backends.local import LocalBackend -from mopidy.models import Playlist, Track +from mopidy.models import Track from mopidy.utils.path import path_to_uri from tests import unittest, path_to_data_dir @@ -23,38 +23,43 @@ class LocalStoredPlaylistsControllerTest( self.assert_(os.path.exists(path)) def test_saved_playlist_is_persisted(self): - path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2.m3u') - self.assert_(not os.path.exists(path)) - self.stored.save(Playlist(name='test2')) - self.assert_(os.path.exists(path)) + path1 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') + path2 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2.m3u') + + playlist = self.stored.create('test') + + self.assertFalse(os.path.exists(path2)) + + self.stored.rename(playlist, 'test2') + + self.assertFalse(os.path.exists(path1)) + self.assertTrue(os.path.exists(path2)) def test_deleted_playlist_is_removed(self): - playlist = self.stored.create('test') - self.stored.delete(playlist) path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') - self.assert_(not os.path.exists(path)) - def test_renamed_playlist_is_moved(self): + self.assertFalse(os.path.exists(path)) + playlist = self.stored.create('test') - file1 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') - file2 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2.m3u') - self.assert_(not os.path.exists(file2)) - self.stored.rename(playlist, 'test2') - self.assert_(not os.path.exists(file1)) - self.assert_(os.path.exists(file2)) + + self.assertTrue(os.path.exists(path)) + + self.stored.delete(playlist) + + self.assertFalse(os.path.exists(path)) def test_playlist_contents_is_written_to_disk(self): track = Track(uri=generate_song(1)) - uri = track.uri[len('file://'):] - playlist = Playlist(tracks=[track], name='test') - path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') - + track_path = track.uri[len('file://'):] + playlist = self.stored.create('test') + playlist_path = playlist.uri[len('file://'):] + playlist = playlist.copy(tracks=[track]) self.stored.save(playlist) - with open(path) as playlist_file: + with open(playlist_path) as playlist_file: contents = playlist_file.read() - self.assertEqual(uri, contents.strip()) + self.assertEqual(track_path, contents.strip()) def test_playlists_are_loaded_at_startup(self): playlist_path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') @@ -82,11 +87,3 @@ class LocalStoredPlaylistsControllerTest( @unittest.SkipTest def test_playlist_folder_is_createad(self): pass - - @unittest.SkipTest - def test_create_sets_playlist_uri(self): - pass - - @unittest.SkipTest - def test_save_sets_playlist_uri(self): - pass From d03881f173e78cf02abc4037f1b5634d24cee281 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 15:19:04 +0100 Subject: [PATCH 183/233] Require stored_playlists.save() to return the updated playlist (#217) --- docs/changes.rst | 4 ++++ mopidy/core/stored_playlists.py | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 7fa59a3c..4d58ff4e 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -63,6 +63,10 @@ backends: and all functionality is maintained by assigning to the playlists collections at the backend level. + - :meth:`mopidy.core.StoredPlaylistsController.save` now returns the saved + playlist. The returned playlist may differ from the saved playlist, and + should thus be used instead of the saved playlist. + **Changes** - Made the :mod:`NAD mixer ` responsive to interrupts diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index b34b8bc1..8f87db58 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -136,8 +136,15 @@ class StoredPlaylistsController(object): """ Save the playlist to the set of stored playlists. + 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 returned + playlist instead. + :param playlist: the playlist :type playlist: :class:`mopidy.models.Playlist` + :rtype: :class:`mopidy.models.Playlist` """ # TODO Support multiple backends return self.backends[0].stored_playlists.save(playlist).get() From 06bcad2db9554c0a7c0bf8217dd3c1451fa2f243 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 15:21:02 +0100 Subject: [PATCH 184/233] Make local.stored_playlists.save() capable of renaming playlists (#217) --- docs/changes.rst | 2 +- mopidy/backends/local/stored_playlists.py | 70 +++++++++++-------- tests/backends/base/stored_playlists.py | 4 +- tests/backends/local/stored_playlists_test.py | 12 ++-- 4 files changed, 52 insertions(+), 36 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 4d58ff4e..285af6b3 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -65,7 +65,7 @@ backends: - :meth:`mopidy.core.StoredPlaylistsController.save` now returns the saved playlist. The returned playlist may differ from the saved playlist, and - should thus be used instead of the saved playlist. + should thus be used instead of the playlist passed to ``save()``. **Changes** diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index ef7cc92d..621d37bf 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -10,6 +10,7 @@ from mopidy.utils import path from .translator import parse_m3u + logger = logging.getLogger(u'mopidy.backends.local') @@ -19,6 +20,25 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): self._folder = settings.LOCAL_PLAYLIST_PATH self.refresh() + # TODO playlist names needs safer handling using a slug function + + def create(self, name): + file_path = os.path.join(self._folder, name + '.m3u') + uri = path.path_to_uri(file_path) + playlist = Playlist(uri=uri, name=name) + self.save(playlist) + return playlist + + def delete(self, playlist): + if playlist not in self._playlists: + return + + self._playlists.remove(playlist) + filename = os.path.join(self._folder, playlist.name + '.m3u') + + if os.path.exists(filename): + os.remove(filename) + def lookup(self, uri): for playlist in self._playlists: if playlist.uri == uri: @@ -40,30 +60,10 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): logger.error('Playlist item could not be added: %s', ex) playlist = Playlist(uri=uri, name=name, tracks=tracks) - # FIXME playlist name needs better handling - # FIXME tracks should come from lib. lookup - playlists.append(playlist) self.playlists = playlists - def create(self, name): - file_path = os.path.join(self._folder, name + '.m3u') - uri = path.path_to_uri(file_path) - playlist = Playlist(uri=uri, name=name) - self.save(playlist) - return playlist - - def delete(self, playlist): - if playlist not in self._playlists: - return - - self._playlists.remove(playlist) - filename = os.path.join(self._folder, playlist.name + '.m3u') - - if os.path.exists(filename): - os.remove(filename) - def rename(self, playlist, name): if playlist not in self._playlists: return @@ -80,16 +80,30 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def save(self, playlist): assert playlist.uri, 'Cannot save playlist without URI' - # FIXME this should be a save_m3u function, not inside save + old_playlist = self.lookup(playlist.uri) + + if old_playlist and playlist.name != old_playlist.name: + src = os.path.join(self._folder, old_playlist.name + '.m3u') + dst = os.path.join(self._folder, playlist.name + '.m3u') + shutil.move(src, dst) + playlist = playlist.copy(uri=path.path_to_uri(dst)) + + self._save_m3u(playlist) + + if old_playlist is not None: + index = self._playlists.index(old_playlist) + self._playlists[index] = playlist + else: + self._playlists.append(playlist) + + return playlist + + def _save_m3u(self, playlist): file_path = playlist.uri[len('file://'):] with open(file_path, 'w') as file_handle: for track in playlist.tracks: if track.uri.startswith('file://'): - file_handle.write(track.uri[len('file://'):] + '\n') + uri = track.uri[len('file://'):] else: - file_handle.write(track.uri + '\n') - - original_playlist = self.lookup(playlist.uri) - if original_playlist is not None: - self._playlists.remove(original_playlist) - self._playlists.append(playlist) + uri = track.uri + file_handle.write(uri + '\n') diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 209aad0a..83c243f3 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -113,12 +113,12 @@ class StoredPlaylistsControllerTest(object): test = lambda: self.stored.get(name='test2') self.assertRaises(LookupError, test) - def test_save_replaces_playlist_with_updated_playlist(self): + def test_save_replaces_stored_playlist_with_updated_playlist(self): playlist1 = self.stored.create('test1') self.assertIn(playlist1, self.stored.playlists) playlist2 = playlist1.copy(name='test2') - self.stored.save(playlist2) + playlist2 = self.stored.save(playlist2) self.assertNotIn(playlist1, self.stored.playlists) self.assertIn(playlist2, self.stored.playlists) diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index 446d87f1..7025c402 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -23,14 +23,16 @@ class LocalStoredPlaylistsControllerTest( self.assert_(os.path.exists(path)) def test_saved_playlist_is_persisted(self): - path1 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') + path1 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test1.m3u') path2 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2.m3u') - playlist = self.stored.create('test') + playlist = self.stored.create('test1') + self.assertTrue(os.path.exists(path1)) self.assertFalse(os.path.exists(path2)) - self.stored.rename(playlist, 'test2') + playlist = playlist.copy(name='test2') + playlist = self.stored.save(playlist) self.assertFalse(os.path.exists(path1)) self.assertTrue(os.path.exists(path2)) @@ -54,7 +56,7 @@ class LocalStoredPlaylistsControllerTest( playlist = self.stored.create('test') playlist_path = playlist.uri[len('file://'):] playlist = playlist.copy(tracks=[track]) - self.stored.save(playlist) + playlist = self.stored.save(playlist) with open(playlist_path) as playlist_file: contents = playlist_file.read() @@ -67,7 +69,7 @@ class LocalStoredPlaylistsControllerTest( track = Track(uri=path_to_uri(path_to_data_dir('uri2'))) playlist = self.stored.create('test') playlist = playlist.copy(tracks=[track]) - self.stored.save(playlist) + playlist = self.stored.save(playlist) backend = self.backend_class(audio=self.audio) From f9f6f9394dbdeec8a858e9ab82089d16a6893a98 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 15:25:39 +0100 Subject: [PATCH 185/233] Remove stored_playlists.rename() (#217) --- docs/changes.rst | 3 +++ mopidy/backends/base.py | 8 -------- mopidy/backends/local/stored_playlists.py | 13 ------------- mopidy/core/stored_playlists.py | 13 ------------- tests/backends/base/stored_playlists.py | 10 ---------- 5 files changed, 3 insertions(+), 44 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 285af6b3..9c2bea62 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -67,6 +67,9 @@ backends: playlist. The returned playlist may differ from the saved playlist, and should thus be used instead of the playlist passed to ``save()``. + - :meth:`mopidy.core.StoredPlaylistsController.rename` has been removed, + since renaming can be done with ``save()``. + **Changes** - Made the :mod:`NAD mixer ` responsive to interrupts diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index 7ae2c3dc..e4c40a92 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -204,14 +204,6 @@ class BaseStoredPlaylistsProvider(object): """ raise NotImplementedError - def rename(self, playlist, new_name): - """ - See :meth:`mopidy.core.StoredPlaylistsController.rename`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - def save(self, playlist): """ See :meth:`mopidy.core.StoredPlaylistsController.save`. diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index 621d37bf..6d12dd46 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -64,19 +64,6 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): self.playlists = playlists - def rename(self, playlist, name): - if playlist not in self._playlists: - return - - src = os.path.join(self._folder, playlist.name + '.m3u') - dst = os.path.join(self._folder, name + '.m3u') - - renamed = playlist.copy(name=name) - index = self._playlists.index(playlist) - self._playlists[index] = renamed - - shutil.move(src, dst) - def save(self, playlist): assert playlist.uri, 'Cannot save playlist without URI' diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index 8f87db58..af88d86b 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -119,19 +119,6 @@ class StoredPlaylistsController(object): backend = self.backends.by_uri_scheme[uri_scheme] backend.stored_playlists.refresh().get() - def rename(self, playlist, new_name): - """ - Rename playlist. - - :param playlist: the playlist - :type playlist: :class:`mopidy.models.Playlist` - :param new_name: the new name - :type new_name: string - """ - # TODO Support multiple backends - return self.backends[0].stored_playlists.rename( - playlist, new_name).get() - def save(self, playlist): """ Save the playlist to the set of stored playlists. diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 83c243f3..ba907b13 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -103,16 +103,6 @@ class StoredPlaylistsControllerTest(object): def test_refresh(self): pass - def test_rename(self): - playlist = self.stored.create('test') - self.stored.rename(playlist, 'test2') - self.stored.get(name='test2') - - def test_rename_unknown_playlist(self): - self.stored.rename(Playlist(), 'test2') - test = lambda: self.stored.get(name='test2') - self.assertRaises(LookupError, test) - def test_save_replaces_stored_playlist_with_updated_playlist(self): playlist1 = self.stored.create('test1') self.assertIn(playlist1, self.stored.playlists) From 3d05f3c65f26e04fda43e57406ab761de2b896f3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 16:37:07 +0100 Subject: [PATCH 186/233] Change stored_playlists.delete() to accepting an URI (#217) --- docs/changes.rst | 7 ++++++- mopidy/backends/base.py | 2 +- mopidy/backends/local/stored_playlists.py | 15 +++++++++------ mopidy/core/stored_playlists.py | 17 +++++++++++------ tests/backends/base/stored_playlists.py | 11 +++++++---- tests/backends/local/stored_playlists_test.py | 5 +---- tests/core/stored_playlists_test.py | 18 ++++++++++++++++++ 7 files changed, 53 insertions(+), 22 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 9c2bea62..1427c867 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -56,13 +56,18 @@ backends: dummy/mocked lower layers easier than with the old variant, where dependencies where looked up in Pykka's actor registry. -- The stored playlists part of the core API have been revised a bit: +- The stored playlists part of the core API have been revised to be more + focused around the playlist URI, and some redundant functionality have been + removed: - :attr:`mopidy.core.StoredPlaylistsController.playlists` no longer supports assignment to it. The `playlists` property on the backend layer still does, and all functionality is maintained by assigning to the playlists collections at the backend level. + - :meth:`mopidy.core.StoredPlaylistsController.delete` now accepts an URI, + and not a playlist object. + - :meth:`mopidy.core.StoredPlaylistsController.save` now returns the saved playlist. The returned playlist may differ from the saved playlist, and should thus be used instead of the playlist passed to ``save()``. diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index e4c40a92..95cd93c4 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -180,7 +180,7 @@ class BaseStoredPlaylistsProvider(object): """ raise NotImplementedError - def delete(self, playlist): + def delete(self, uri): """ See :meth:`mopidy.core.StoredPlaylistsController.delete`. diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index 6d12dd46..139cfac8 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -29,15 +29,13 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): self.save(playlist) return playlist - def delete(self, playlist): - if playlist not in self._playlists: + def delete(self, uri): + playlist = self.lookup(uri) + if not playlist: return self._playlists.remove(playlist) - filename = os.path.join(self._folder, playlist.name + '.m3u') - - if os.path.exists(filename): - os.remove(filename) + self._delete_m3u(playlist) def lookup(self, uri): for playlist in self._playlists: @@ -94,3 +92,8 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): else: uri = track.uri file_handle.write(uri + '\n') + + def _delete_m3u(self, playlist): + file_path = playlist.uri[len('file://'):] + if os.path.exists(file_path): + os.remove(file_path) diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index af88d86b..16dffdb0 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -43,15 +43,20 @@ class StoredPlaylistsController(object): backend = self.backends[0] return backend.stored_playlists.create(name).get() - def delete(self, playlist): + def delete(self, uri): """ - Delete playlist. + Delete playlist identified by the URI. - :param playlist: the playlist to delete - :type playlist: :class:`mopidy.models.Playlist` + If the URI doesn't match the URI schemes handled by the current + backends, nothing happens. + + :param uri: URI of the playlist to delete + :type uri: string """ - # TODO Support multiple backends - return self.backends[0].stored_playlists.delete(playlist).get() + uri_scheme = urlparse.urlparse(uri).scheme + backend = self.backends.by_uri_scheme.get(uri_scheme, None) + if backend: + return backend.stored_playlists.delete(uri).get() def get(self, **criteria): """ diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index ba907b13..2b5469ac 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -47,12 +47,15 @@ class StoredPlaylistsControllerTest(object): self.assert_(not self.stored.playlists) def test_delete_non_existant_playlist(self): - self.stored.delete(Playlist()) + self.stored.delete('file:///unknown/playlist') - def test_delete_playlist(self): + def test_delete_playlist_removes_it_from_the_collection(self): playlist = self.stored.create('test') - self.stored.delete(playlist) - self.assert_(not self.stored.playlists) + self.assertIn(playlist, self.stored.playlists) + + self.stored.delete(playlist.uri) + + self.assertNotIn(playlist, self.stored.playlists) def test_get_without_criteria(self): test = self.stored.get diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index 7025c402..987c0788 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -39,15 +39,12 @@ class LocalStoredPlaylistsControllerTest( def test_deleted_playlist_is_removed(self): path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') - self.assertFalse(os.path.exists(path)) playlist = self.stored.create('test') - self.assertTrue(os.path.exists(path)) - self.stored.delete(playlist) - + self.stored.delete(playlist.uri) self.assertFalse(os.path.exists(path)) def test_playlist_contents_is_written_to_disk(self): diff --git a/tests/core/stored_playlists_test.py b/tests/core/stored_playlists_test.py index 2e90416e..39914766 100644 --- a/tests/core/stored_playlists_test.py +++ b/tests/core/stored_playlists_test.py @@ -59,6 +59,24 @@ class StoredPlaylistsTest(unittest.TestCase): self.assertFalse(self.sp1.create.called) self.sp2.create.assert_called_once_with('foo') + def test_delete_selects_the_dummy1_backend(self): + self.core.stored_playlists.delete('dummy1:a') + + self.sp1.delete.assert_called_once_with('dummy1:a') + self.assertFalse(self.sp2.delete.called) + + def test_delete_selects_the_dummy2_backend(self): + self.core.stored_playlists.delete('dummy2:a') + + self.assertFalse(self.sp1.delete.called) + self.sp2.delete.assert_called_once_with('dummy2:a') + + def test_delete_with_unknown_uri_scheme_does_nothing(self): + self.core.stored_playlists.delete('unknown:a') + + self.assertFalse(self.sp1.delete.called) + self.assertFalse(self.sp2.delete.called) + def test_lookup_selects_the_dummy1_backend(self): self.core.stored_playlists.lookup('dummy1:a') From 6c49a7fc525599fc2a4f5cdb2c7b4c8905751b2b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 16:52:34 +0100 Subject: [PATCH 187/233] Make core.stored_playlists.save() support multibackend (#217) --- mopidy/core/stored_playlists.py | 32 ++++++++++++++++++------- tests/core/stored_playlists_test.py | 37 +++++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index 16dffdb0..c404a408 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -31,6 +31,9 @@ class StoredPlaylistsController(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** + by creating new instances of :class:`mopidy.models.Playlist`. + :param name: name of the new playlist :type name: string :param uri_scheme: use the backend matching the URI scheme @@ -128,15 +131,28 @@ class StoredPlaylistsController(object): """ Save the playlist to the set of stored playlists. - 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 returned - playlist instead. + For a playlist to be saveable, it must have the ``uri`` attribute set. + You should 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 + returned playlist instead. + + If the playlist's URI isn't set or doesn't match the URI scheme of a + current backend, nothing is done and :class:`None` is returned. :param playlist: the playlist :type playlist: :class:`mopidy.models.Playlist` - :rtype: :class:`mopidy.models.Playlist` + :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ - # TODO Support multiple backends - return self.backends[0].stored_playlists.save(playlist).get() + if playlist.uri is None: + return + uri_scheme = urlparse.urlparse(playlist.uri).scheme + if uri_scheme not in self.backends.by_uri_scheme: + return + backend = self.backends.by_uri_scheme[uri_scheme] + return backend.stored_playlists.save(playlist).get() diff --git a/tests/core/stored_playlists_test.py b/tests/core/stored_playlists_test.py index 39914766..b0d48512 100644 --- a/tests/core/stored_playlists_test.py +++ b/tests/core/stored_playlists_test.py @@ -107,5 +107,38 @@ class StoredPlaylistsTest(unittest.TestCase): self.assertFalse(self.sp1.refresh.called) self.assertFalse(self.sp2.refresh.called) - # TODO The rest of the stored playlists API is pending redesign before - # we'll update it to support multiple backends. + def test_save_selects_the_dummy1_backend(self): + playlist = Playlist(uri='dummy1:a') + self.sp1.save().get.return_value = playlist + self.sp1.reset_mock() + + result = self.core.stored_playlists.save(playlist) + + self.assertEqual(playlist, result) + self.sp1.save.assert_called_once_with(playlist) + self.assertFalse(self.sp2.save.called) + + def test_save_selects_the_dummy2_backend(self): + playlist = Playlist(uri='dummy2:a') + self.sp2.save().get.return_value = playlist + self.sp2.reset_mock() + + result = self.core.stored_playlists.save(playlist) + + self.assertEqual(playlist, result) + self.assertFalse(self.sp1.save.called) + self.sp2.save.assert_called_once_with(playlist) + + def test_save_does_nothing_if_playlist_uri_is_unset(self): + result = self.core.stored_playlists.save(Playlist()) + + self.assertIsNone(result) + self.assertFalse(self.sp1.save.called) + self.assertFalse(self.sp2.save.called) + + def test_save_does_nothing_if_playlist_uri_has_unknown_scheme(self): + result = self.core.stored_playlists.save(Playlist(uri='foobar:a')) + + self.assertIsNone(result) + self.assertFalse(self.sp1.save.called) + self.assertFalse(self.sp2.save.called) From af04808941dc7ef7e5c99e818dfbaa4158b2488a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 00:29:28 +0100 Subject: [PATCH 188/233] Make Travis use IRC notice notifications without joining the channel --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index 6120e2de..bbba0a94 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,3 +17,5 @@ notifications: - "irc.freenode.org#mopidy" on_success: change on_failure: change + use_notice: true + skip_join: true From a5b454acc0087197963ddc7874866abd11582a45 Mon Sep 17 00:00:00 2001 From: Fred Hatfull Date: Wed, 31 Oct 2012 23:45:13 -0700 Subject: [PATCH 189/233] Fixes support for MPD find/search by filename Extends `find_exact` and `search` in mopidy.backends.local.library to support the `filename` query field. This field can get passed in from the MPD frontend and would break with a `LookupError` when used. This patch fixes the issue and introduces two new tests to cover the added functionality. --- mopidy/backends/local/library.py | 4 ++-- tests/backends/base/library.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 600bfaaa..db37edb3 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -59,7 +59,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) - elif field == 'uri': + elif field == 'uri' or field == 'filename': result_tracks = filter(uri_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) @@ -93,7 +93,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) - elif field == 'uri': + elif field == 'uri' or field == 'filename': result_tracks = filter(uri_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index cc2a0004..b7510dbb 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -79,6 +79,15 @@ class LibraryControllerTest(object): result = self.library.find_exact(album=['album2']) self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + def test_find_exact_filename(self): + track_1_filename = 'file://' + path_to_data_dir('uri1') + result = self.library.find_exact(filename=track_1_filename) + self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + + track_2_filename = 'file://' + path_to_data_dir('uri2') + result = self.library.find_exact(filename=track_2_filename) + self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + def test_find_exact_wrong_type(self): test = lambda: self.library.find_exact(wrong=['test']) self.assertRaises(LookupError, test) @@ -137,6 +146,13 @@ class LibraryControllerTest(object): result = self.library.search(uri=['RI2']) self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + def test_search_filename(self): + result = self.library.search(filename=['RI1']) + self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + + result = self.library.search(filename=['RI2']) + self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + def test_search_any(self): result = self.library.search(any=['Tist1']) self.assertEqual(result, Playlist(tracks=self.tracks[:1])) From 58c190f12b925034182e4e219824f5dc8cf00005 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 12:10:17 +0100 Subject: [PATCH 190/233] Fix grammar (#217) --- docs/changes.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 1427c867..ef0d3bcd 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -56,9 +56,8 @@ backends: dummy/mocked lower layers easier than with the old variant, where dependencies where looked up in Pykka's actor registry. -- The stored playlists part of the core API have been revised to be more - focused around the playlist URI, and some redundant functionality have been - removed: +- The stored playlists part of the core API has been revised to be more focused + around the playlist URI, and some redundant functionality has been removed: - :attr:`mopidy.core.StoredPlaylistsController.playlists` no longer supports assignment to it. The `playlists` property on the backend layer still does, From 078cc72fffbf72a30f2f30969d7dfe033c5ac8fb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 12:12:01 +0100 Subject: [PATCH 191/233] Remove undocumented return from core.stored_playlists.delete() (#217) --- mopidy/core/stored_playlists.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index c404a408..8c04d5ad 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -57,9 +57,9 @@ class StoredPlaylistsController(object): :type uri: string """ uri_scheme = urlparse.urlparse(uri).scheme - backend = self.backends.by_uri_scheme.get(uri_scheme, None) - if backend: - return backend.stored_playlists.delete(uri).get() + if uri_scheme in self.backends.by_uri_scheme: + backend = self.backends.by_uri_scheme[uri_scheme] + backend.stored_playlists.delete(uri).get() def get(self, **criteria): """ From 8c9a3d6df232faf1f61f2aeb6cf827f506e50743 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 12:46:29 +0100 Subject: [PATCH 192/233] Slugify local playlist names to make them safe to use in paths (#217) --- mopidy/backends/local/stored_playlists.py | 23 +++++++++--- tests/backends/base/stored_playlists.py | 16 ++++----- tests/backends/local/stored_playlists_test.py | 36 ++++++++++++++----- 3 files changed, 54 insertions(+), 21 deletions(-) diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index 139cfac8..3d488655 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -1,7 +1,9 @@ import glob import logging import os +import re import shutil +import unicodedata from mopidy import settings from mopidy.backends import base @@ -20,9 +22,8 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): self._folder = settings.LOCAL_PLAYLIST_PATH self.refresh() - # TODO playlist names needs safer handling using a slug function - def create(self, name): + name = self._slugify(name) file_path = os.path.join(self._folder, name + '.m3u') uri = path.path_to_uri(file_path) playlist = Playlist(uri=uri, name=name) @@ -68,10 +69,11 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): old_playlist = self.lookup(playlist.uri) if old_playlist and playlist.name != old_playlist.name: + new_name = self._slugify(playlist.name) src = os.path.join(self._folder, old_playlist.name + '.m3u') - dst = os.path.join(self._folder, playlist.name + '.m3u') + dst = os.path.join(self._folder, new_name + '.m3u') shutil.move(src, dst) - playlist = playlist.copy(uri=path.path_to_uri(dst)) + playlist = playlist.copy(uri=path.path_to_uri(dst), name=new_name) self._save_m3u(playlist) @@ -97,3 +99,16 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): file_path = playlist.uri[len('file://'):] if os.path.exists(file_path): os.remove(file_path) + + def _slugify(self, value): + """ + Converts to lowercase, removes non-word characters (alphanumerics and + underscores) and converts spaces to hyphens. Also strips leading and + trailing whitespace. + + This function is based on Django's slugify implementation. + """ + value = unicodedata.normalize('NFKD', value) + value = value.encode('ascii', 'ignore').decode('ascii') + value = re.sub('[^\w\s-]', '', value).strip().lower() + return re.sub('[-\s]+', '-', value) diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 2b5469ac..267a025c 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -31,15 +31,15 @@ class StoredPlaylistsControllerTest(object): settings.runtime.clear() def test_create_returns_playlist_with_name_set(self): - playlist = self.stored.create('test') + playlist = self.stored.create(u'test') self.assertEqual(playlist.name, 'test') def test_create_returns_playlist_with_uri_set(self): - playlist = self.stored.create('test') + playlist = self.stored.create(u'test') self.assert_(playlist.uri) def test_create_adds_playlist_to_playlists_collection(self): - playlist = self.stored.create('test') + playlist = self.stored.create(u'test') self.assert_(self.stored.playlists) self.assertIn(playlist, self.stored.playlists) @@ -50,7 +50,7 @@ class StoredPlaylistsControllerTest(object): self.stored.delete('file:///unknown/playlist') def test_delete_playlist_removes_it_from_the_collection(self): - playlist = self.stored.create('test') + playlist = self.stored.create(u'test') self.assertIn(playlist, self.stored.playlists) self.stored.delete(playlist.uri) @@ -66,7 +66,7 @@ class StoredPlaylistsControllerTest(object): self.assertRaises(LookupError, test) def test_get_with_right_criteria(self): - playlist1 = self.stored.create('test') + playlist1 = self.stored.create(u'test') playlist2 = self.stored.get(name='test') self.assertEqual(playlist1, playlist2) @@ -96,7 +96,7 @@ class StoredPlaylistsControllerTest(object): self.assertEqual(u'"name=c" match no playlists', e[0]) def test_lookup_finds_playlist_by_uri(self): - original_playlist = self.stored.create('test') + original_playlist = self.stored.create(u'test') looked_up_playlist = self.stored.lookup(original_playlist.uri) @@ -107,10 +107,10 @@ class StoredPlaylistsControllerTest(object): pass def test_save_replaces_stored_playlist_with_updated_playlist(self): - playlist1 = self.stored.create('test1') + playlist1 = self.stored.create(u'test1') self.assertIn(playlist1, self.stored.playlists) - playlist2 = playlist1.copy(name='test2') + playlist2 = playlist1.copy(name=u'test2') playlist2 = self.stored.save(playlist2) self.assertNotIn(playlist1, self.stored.playlists) self.assertIn(playlist2, self.stored.playlists) diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index 987c0788..cd1ecd3c 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -18,22 +18,40 @@ class LocalStoredPlaylistsControllerTest( def test_created_playlist_is_persisted(self): path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') - self.assert_(not os.path.exists(path)) - self.stored.create('test') - self.assert_(os.path.exists(path)) + self.assertFalse(os.path.exists(path)) + + self.stored.create(u'test') + self.assertTrue(os.path.exists(path)) + + def test_create_slugifies_playlist_name(self): + path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test-foo-bar.m3u') + self.assertFalse(os.path.exists(path)) + + playlist = self.stored.create(u'test FOO baR') + self.assertEqual(u'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(settings.LOCAL_PLAYLIST_PATH, 'test-foo-bar.m3u') + self.assertFalse(os.path.exists(path)) + + playlist = self.stored.create(u'../../test FOO baR') + self.assertEqual(u'test-foo-bar', playlist.name) + self.assertTrue(os.path.exists(path)) def test_saved_playlist_is_persisted(self): path1 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test1.m3u') - path2 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2.m3u') + path2 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2-foo-bar.m3u') - playlist = self.stored.create('test1') + playlist = self.stored.create(u'test1') self.assertTrue(os.path.exists(path1)) self.assertFalse(os.path.exists(path2)) - playlist = playlist.copy(name='test2') + playlist = playlist.copy(name=u'test2 FOO baR') playlist = self.stored.save(playlist) + self.assertEqual(u'test2-foo-bar', playlist.name) self.assertFalse(os.path.exists(path1)) self.assertTrue(os.path.exists(path2)) @@ -41,7 +59,7 @@ class LocalStoredPlaylistsControllerTest( path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') self.assertFalse(os.path.exists(path)) - playlist = self.stored.create('test') + playlist = self.stored.create(u'test') self.assertTrue(os.path.exists(path)) self.stored.delete(playlist.uri) @@ -50,7 +68,7 @@ class LocalStoredPlaylistsControllerTest( def test_playlist_contents_is_written_to_disk(self): track = Track(uri=generate_song(1)) track_path = track.uri[len('file://'):] - playlist = self.stored.create('test') + playlist = self.stored.create(u'test') playlist_path = playlist.uri[len('file://'):] playlist = playlist.copy(tracks=[track]) playlist = self.stored.save(playlist) @@ -64,7 +82,7 @@ class LocalStoredPlaylistsControllerTest( playlist_path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') track = Track(uri=path_to_uri(path_to_data_dir('uri2'))) - playlist = self.stored.create('test') + playlist = self.stored.create(u'test') playlist = playlist.copy(tracks=[track]) playlist = self.stored.save(playlist) From 82f5b376da73c06c4023572c27be60117e300eb5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 14:03:09 +0100 Subject: [PATCH 193/233] Validate the stored playlist file paths --- mopidy/backends/local/stored_playlists.py | 73 +++++++++++++++++------ 1 file changed, 54 insertions(+), 19 deletions(-) diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index 3d488655..9e5b72c6 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -19,16 +19,14 @@ logger = logging.getLogger(u'mopidy.backends.local') class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def __init__(self, *args, **kwargs): super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs) - self._folder = settings.LOCAL_PLAYLIST_PATH + self._path = settings.LOCAL_PLAYLIST_PATH self.refresh() def create(self, name): name = self._slugify(name) - file_path = os.path.join(self._folder, name + '.m3u') - uri = path.path_to_uri(file_path) + uri = path.path_to_uri(self._get_m3u_path(name)) playlist = Playlist(uri=uri, name=name) - self.save(playlist) - return playlist + return self.save(playlist) def delete(self, uri): playlist = self.lookup(uri) @@ -36,7 +34,7 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): return self._playlists.remove(playlist) - self._delete_m3u(playlist) + self._delete_m3u(playlist.uri) def lookup(self, uri): for playlist in self._playlists: @@ -44,21 +42,24 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): return playlist def refresh(self): + logger.info('Loading playlists from %s', self._path) + playlists = [] - logger.info('Loading playlists from %s', self._folder) - - for m3u in glob.glob(os.path.join(self._folder, '*.m3u')): + for m3u in glob.glob(os.path.join(self._path, '*.m3u')): uri = path.path_to_uri(m3u) name = os.path.splitext(os.path.basename(m3u))[0] + tracks = [] for track_uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): try: + # TODO We must use core.library.lookup() to support tracks + # from other backends tracks.append(self.backend.library.lookup(track_uri)) except LookupError as ex: logger.error('Playlist item could not be added: %s', ex) - playlist = Playlist(uri=uri, name=name, tracks=tracks) + playlist = Playlist(uri=uri, name=name, tracks=tracks) playlists.append(playlist) self.playlists = playlists @@ -69,11 +70,8 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): old_playlist = self.lookup(playlist.uri) if old_playlist and playlist.name != old_playlist.name: - new_name = self._slugify(playlist.name) - src = os.path.join(self._folder, old_playlist.name + '.m3u') - dst = os.path.join(self._folder, new_name + '.m3u') - shutil.move(src, dst) - playlist = playlist.copy(uri=path.path_to_uri(dst), name=new_name) + playlist = playlist.copy(name=self._slugify(playlist.name)) + playlist = self._rename_m3u(playlist) self._save_m3u(playlist) @@ -85,21 +83,58 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): return playlist + def _get_m3u_path(self, name): + name = self._slugify(name) + file_path = os.path.join(self._path, name + '.m3u') + self._validate_file_path(file_path) + return file_path + def _save_m3u(self, playlist): - file_path = playlist.uri[len('file://'):] + file_path = path.uri_to_path(playlist.uri) + self._validate_file_path(file_path) with open(file_path, 'w') as file_handle: for track in playlist.tracks: if track.uri.startswith('file://'): - uri = track.uri[len('file://'):] + uri = path.uri_to_path(track.uri) else: uri = track.uri file_handle.write(uri + '\n') - def _delete_m3u(self, playlist): - file_path = playlist.uri[len('file://'):] + def _delete_m3u(self, uri): + file_path = path.uri_to_path(uri) + self._validate_file_path(file_path) if os.path.exists(file_path): os.remove(file_path) + def _rename_m3u(self, playlist): + src_file_path = path.uri_to_path(playlist.uri) + self._validate_file_path(src_file_path) + + dst_file_path = self._get_m3u_path(playlist.name) + self._validate_file_path(dst_file_path) + + shutil.move(src_file_path, dst_file_path) + + return playlist.copy(uri=path.path_to_uri(dst_file_path)) + + def _validate_file_path(self, file_path): + assert not file_path.endswith(os.sep), ( + 'File path %s cannot end with a path separator' % file_path) + + # Expand symlinks + real_base_path = os.path.realpath(self._path) + real_file_path = os.path.realpath(file_path) + + # Use dir of file for prefix comparision, so we don't accept + # /tmp/foo.m3u as being inside /tmp/foo, simply because they have a + # common prefix, /tmp/foo, which matches the base path, /tmp/foo. + real_dir_path = os.path.dirname(real_file_path) + + # Check if dir of file is the base path or a subdir + common_prefix = os.path.commonprefix([real_base_path, real_dir_path]) + assert common_prefix == real_base_path, ( + 'File path %s must be in %s' % (real_file_path, real_base_path)) + def _slugify(self, value): """ Converts to lowercase, removes non-word characters (alphanumerics and From 3fe856c6ba3e6cd1444919c3eec0060d104e9079 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 14:03:38 +0100 Subject: [PATCH 194/233] Mark regexp strings as raw to please pylint --- mopidy/backends/local/stored_playlists.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index 9e5b72c6..9615461c 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -145,5 +145,5 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): """ value = unicodedata.normalize('NFKD', value) value = value.encode('ascii', 'ignore').decode('ascii') - value = re.sub('[^\w\s-]', '', value).strip().lower() - return re.sub('[-\s]+', '-', value) + value = re.sub(r'[^\w\s-]', '', value).strip().lower() + return re.sub(r'[-\s]+', '-', value) From c291c9c83e6429981ebc38714adc4eb23fb383af Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 19:36:11 +0100 Subject: [PATCH 195/233] Style fix --- mopidy/backends/local/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index db37edb3..09484ed0 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -59,7 +59,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) - elif field == 'uri' or field == 'filename': + elif field in ('uri', 'filename'): result_tracks = filter(uri_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) From 3ce986f61986670152f417946f5c6034b9613132 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 19:36:24 +0100 Subject: [PATCH 196/233] Update changelog with search by filename --- docs/changes.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 0203a89f..f3b2a25d 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -68,6 +68,8 @@ backends: - The Spotify backend now includes release year and artist on albums. +- Added support for search by filename to local backend. + v0.8.1 (2012-10-30) =================== From 590270546b68c9db1e30da8450de1fabee4af707 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 20:15:20 +0100 Subject: [PATCH 197/233] Style fix --- mopidy/backends/local/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 09484ed0..9abdf7ed 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -93,7 +93,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) - elif field == 'uri' or field == 'filename': + elif field in ('uri', 'filename'): result_tracks = filter(uri_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) From 548dd186cfb77f043357873cdec157caa08f193c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 21:58:24 +0100 Subject: [PATCH 198/233] Don't include actor URN in MPD debug log --- mopidy/frontends/mpd/session.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py index b5368a08..5d535f75 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/frontends/mpd/session.py @@ -25,17 +25,14 @@ class MpdSession(network.LineProtocol): self.send_lines([u'OK MPD %s' % protocol.VERSION]) def on_line_received(self, line): - logger.debug( - u'Request from [%s]:%s to %s: %s', - self.host, self.port, self.actor_urn, line) + logger.debug(u'Request from [%s]:%s: %s', self.host, self.port, line) response = self.dispatcher.handle_request(line) if not response: return logger.debug( - u'Response to [%s]:%s from %s: %s', - self.host, self.port, self.actor_urn, + u'Response to [%s]:%s: %s', self.host, self.port, formatting.indent(self.terminator.join(response))) self.send_lines(response) From 60112897d20d2b321a3fc819dad1a6bc4fe0c4e4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 22:27:53 +0100 Subject: [PATCH 199/233] MPD: Support listplaylist{,info} without quotes around spaceless playlist name (fixes #218) --- docs/changes.rst | 5 +++++ .../frontends/mpd/protocol/stored_playlists.py | 2 ++ .../mpd/protocol/stored_playlists_test.py | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index f3b2a25d..34e155c9 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -70,6 +70,11 @@ backends: - Added support for search by filename to local backend. +**Bug fixes** + +- :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now + accepts unquotes playlist names if they don't contain spaces. + v0.8.1 (2012-10-30) =================== diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index ed1c38ab..17e5abf7 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -5,6 +5,7 @@ from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.translator import playlist_to_mpd_format +@handle_request(r'^listplaylist (?P\S+)$') @handle_request(r'^listplaylist "(?P[^"]+)"$') def listplaylist(context, name): """ @@ -27,6 +28,7 @@ def listplaylist(context, name): raise MpdNoExistError(u'No such playlist', command=u'listplaylist') +@handle_request(r'^listplaylistinfo (?P\S+)$') @handle_request(r'^listplaylistinfo "(?P[^"]+)"$') def listplaylistinfo(context, name): """ diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index 8cfcb338..ae99fe2a 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -14,6 +14,14 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'file: file:///dev/urandom') self.assertInResponse(u'OK') + def test_listplaylist_without_quotes(self): + self.core.stored_playlists.playlists = [ + Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] + + self.sendRequest(u'listplaylist name') + self.assertInResponse(u'file: file:///dev/urandom') + self.assertInResponse(u'OK') + def test_listplaylist_fails_if_no_playlist_is_found(self): self.sendRequest(u'listplaylist "name"') self.assertEqualResponse(u'ACK [50@0] {listplaylist} No such playlist') @@ -28,6 +36,16 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertNotInResponse(u'Pos: 0') self.assertInResponse(u'OK') + def test_listplaylistinfo_without_quotes(self): + self.core.stored_playlists.playlists = [ + Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] + + self.sendRequest(u'listplaylistinfo name') + self.assertInResponse(u'file: file:///dev/urandom') + self.assertInResponse(u'Track: 0') + self.assertNotInResponse(u'Pos: 0') + self.assertInResponse(u'OK') + def test_listplaylistinfo_fails_if_no_playlist_is_found(self): self.sendRequest(u'listplaylistinfo "name"') self.assertEqualResponse( From 0d16af97a5638450814ab7687e3147d4786115ed Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 22:52:17 +0100 Subject: [PATCH 200/233] Document 'audio' constructor arg to playback providers --- mopidy/backends/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index 7ae2c3dc..9f6de405 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -70,6 +70,8 @@ class BaseLibraryProvider(object): class BasePlaybackProvider(object): """ + :param audio: the audio actor + :type audio: actor proxy to an instance of :class:`mopidy.audio.Audio` :param backend: the backend :type backend: :class:`mopidy.backends.base.Backend` """ From 0dd4aba1436d3d329d76127138831f70479e13b0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 23:03:29 +0100 Subject: [PATCH 201/233] Move slugify to mopidy.utils.formatting --- mopidy/backends/local/stored_playlists.py | 23 ++++------------------- mopidy/utils/formatting.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index 9615461c..5f9a18a1 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -1,14 +1,12 @@ import glob import logging import os -import re import shutil -import unicodedata from mopidy import settings from mopidy.backends import base from mopidy.models import Playlist -from mopidy.utils import path +from mopidy.utils import formatting, path from .translator import parse_m3u @@ -23,7 +21,7 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): self.refresh() def create(self, name): - name = self._slugify(name) + name = formatting.slugify(name) uri = path.path_to_uri(self._get_m3u_path(name)) playlist = Playlist(uri=uri, name=name) return self.save(playlist) @@ -70,7 +68,7 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): old_playlist = self.lookup(playlist.uri) if old_playlist and playlist.name != old_playlist.name: - playlist = playlist.copy(name=self._slugify(playlist.name)) + playlist = playlist.copy(name=formatting.slugify(playlist.name)) playlist = self._rename_m3u(playlist) self._save_m3u(playlist) @@ -84,7 +82,7 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): return playlist def _get_m3u_path(self, name): - name = self._slugify(name) + name = formatting.slugify(name) file_path = os.path.join(self._path, name + '.m3u') self._validate_file_path(file_path) return file_path @@ -134,16 +132,3 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): common_prefix = os.path.commonprefix([real_base_path, real_dir_path]) assert common_prefix == real_base_path, ( 'File path %s must be in %s' % (real_file_path, real_base_path)) - - def _slugify(self, value): - """ - Converts to lowercase, removes non-word characters (alphanumerics and - underscores) and converts spaces to hyphens. Also strips leading and - trailing whitespace. - - This function is based on Django's slugify implementation. - """ - value = unicodedata.normalize('NFKD', value) - value = value.encode('ascii', 'ignore').decode('ascii') - value = re.sub(r'[^\w\s-]', '', value).strip().lower() - return re.sub(r'[-\s]+', '-', value) diff --git a/mopidy/utils/formatting.py b/mopidy/utils/formatting.py index 46459959..9091bc2a 100644 --- a/mopidy/utils/formatting.py +++ b/mopidy/utils/formatting.py @@ -1,3 +1,7 @@ +import re +import unicodedata + + def indent(string, places=4, linebreak='\n'): lines = string.split(linebreak) if len(lines) == 1: @@ -6,3 +10,17 @@ def indent(string, places=4, linebreak='\n'): for line in lines: result += linebreak + ' ' * places + line return result + + +def slugify(value): + """ + Converts to lowercase, removes non-word characters (alphanumerics and + underscores) and converts spaces to hyphens. Also strips leading and + trailing whitespace. + + This function is based on Django's slugify implementation. + """ + value = unicodedata.normalize('NFKD', value) + value = value.encode('ascii', 'ignore').decode('ascii') + value = re.sub(r'[^\w\s-]', '', value).strip().lower() + return re.sub(r'[-\s]+', '-', value) From b110e6a478bbd35e05aaa0e0e4e13c6fb6166969 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 23:10:18 +0100 Subject: [PATCH 202/233] Move file path is in base dir checker to mopidy.utils.path --- mopidy/backends/local/stored_playlists.py | 28 ++++------------------- mopidy/utils/path.py | 19 +++++++++++++++ 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index 5f9a18a1..04406c32 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -84,12 +84,12 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def _get_m3u_path(self, name): name = formatting.slugify(name) file_path = os.path.join(self._path, name + '.m3u') - self._validate_file_path(file_path) + path.check_file_path_is_inside_base_dir(file_path, self._path) return file_path def _save_m3u(self, playlist): file_path = path.uri_to_path(playlist.uri) - self._validate_file_path(file_path) + path.check_file_path_is_inside_base_dir(file_path, self._path) with open(file_path, 'w') as file_handle: for track in playlist.tracks: if track.uri.startswith('file://'): @@ -100,35 +100,17 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def _delete_m3u(self, uri): file_path = path.uri_to_path(uri) - self._validate_file_path(file_path) + path.check_file_path_is_inside_base_dir(file_path, self._path) if os.path.exists(file_path): os.remove(file_path) def _rename_m3u(self, playlist): src_file_path = path.uri_to_path(playlist.uri) - self._validate_file_path(src_file_path) + path.check_file_path_is_inside_base_dir(src_file_path, self._path) dst_file_path = self._get_m3u_path(playlist.name) - self._validate_file_path(dst_file_path) + path.check_file_path_is_inside_base_dir(dst_file_path, self._path) shutil.move(src_file_path, dst_file_path) return playlist.copy(uri=path.path_to_uri(dst_file_path)) - - def _validate_file_path(self, file_path): - assert not file_path.endswith(os.sep), ( - 'File path %s cannot end with a path separator' % file_path) - - # Expand symlinks - real_base_path = os.path.realpath(self._path) - real_file_path = os.path.realpath(file_path) - - # Use dir of file for prefix comparision, so we don't accept - # /tmp/foo.m3u as being inside /tmp/foo, simply because they have a - # common prefix, /tmp/foo, which matches the base path, /tmp/foo. - real_dir_path = os.path.dirname(real_file_path) - - # Check if dir of file is the base path or a subdir - common_prefix = os.path.commonprefix([real_base_path, real_dir_path]) - assert common_prefix == real_base_path, ( - 'File path %s must be in %s' % (real_file_path, real_base_path)) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index eef0c2db..1092534f 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -102,6 +102,25 @@ def find_files(path): yield filename +def check_file_path_is_inside_base_dir(file_path, base_path): + assert not file_path.endswith(os.sep), ( + 'File path %s cannot end with a path separator' % file_path) + + # Expand symlinks + real_base_path = os.path.realpath(base_path) + real_file_path = os.path.realpath(file_path) + + # Use dir of file for prefix comparision, so we don't accept + # /tmp/foo.m3u as being inside /tmp/foo, simply because they have a + # common prefix, /tmp/foo, which matches the base path, /tmp/foo. + real_dir_path = os.path.dirname(real_file_path) + + # Check if dir of file is the base path or a subdir + common_prefix = os.path.commonprefix([real_base_path, real_dir_path]) + assert common_prefix == real_base_path, ( + 'File path %s must be in %s' % (real_file_path, real_base_path)) + + # FIXME replace with mock usage in tests. class Mtime(object): def __init__(self): From d985b8be380dadec850d9c5fb29c086aa115ae0b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 23:28:19 +0100 Subject: [PATCH 203/233] Fix plchanges so it returns nothing when nothing has changed --- docs/changes.rst | 3 +++ .../mpd/protocol/current_playlist.py | 2 +- .../mpd/protocol/current_playlist_test.py | 24 ++++++++++++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 34e155c9..39ddc251 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -75,6 +75,9 @@ backends: - :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now accepts unquotes playlist names if they don't contain spaces. +- The MPD command ``plchanges`` always returned the entire playlist. It now + returns an empty response when the client has seen the latest version. + v0.8.1 (2012-10-30) =================== diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 429af2cc..5a88d41b 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -307,7 +307,7 @@ 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.current_playlist.version: + if int(version) < context.core.current_playlist.version.get(): return translator.tracks_to_mpd_format( context.core.current_playlist.cp_tracks.get()) diff --git a/tests/frontends/mpd/protocol/current_playlist_test.py b/tests/frontends/mpd/protocol/current_playlist_test.py index a64b08ea..bd58cf2d 100644 --- a/tests/frontends/mpd/protocol/current_playlist_test.py +++ b/tests/frontends/mpd/protocol/current_playlist_test.py @@ -364,7 +364,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.sendRequest(u'playlistsearch any "needle"') self.assertEqualResponse(u'ACK [0@0] {} Not implemented') - def test_plchanges(self): + def test_plchanges_with_lower_version_returns_changes(self): self.core.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) @@ -374,6 +374,28 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'Title: c') self.assertInResponse(u'OK') + def test_plchanges_with_equal_version_returns_nothing(self): + self.core.current_playlist.append( + [Track(name='a'), Track(name='b'), Track(name='c')]) + + self.assertEqual(self.core.current_playlist.version.get(), 1) + self.sendRequest(u'plchanges "1"') + self.assertNotInResponse(u'Title: a') + self.assertNotInResponse(u'Title: b') + self.assertNotInResponse(u'Title: c') + self.assertInResponse(u'OK') + + def test_plchanges_with_greater_version_returns_nothing(self): + self.core.current_playlist.append( + [Track(name='a'), Track(name='b'), Track(name='c')]) + + self.assertEqual(self.core.current_playlist.version.get(), 1) + self.sendRequest(u'plchanges "2"') + self.assertNotInResponse(u'Title: a') + self.assertNotInResponse(u'Title: b') + self.assertNotInResponse(u'Title: c') + self.assertInResponse(u'OK') + def test_plchanges_with_minus_one_returns_entire_playlist(self): self.core.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) From 4aee340b77a717e16549bfbbef77e3e45f20c791 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 3 Nov 2012 17:52:13 +0100 Subject: [PATCH 204/233] Add flake8 and pylint to test requirements --- requirements/tests.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements/tests.txt b/requirements/tests.txt index e24edd3c..20aff929 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -1,6 +1,8 @@ coverage +flake8 mock >= 0.7 nose +pylint tox unittest2 yappi From f08fba954e9e266e5f98067cdde9d6e94c14c771 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 3 Nov 2012 22:03:26 +0100 Subject: [PATCH 205/233] Update to work with current develop --- tests/frontends/mpd/protocol/stored_playlists_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index 8cfe237c..c8db3f8f 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -15,7 +15,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_listplaylist_without_quotes(self): - self.core.stored_playlists.playlists = [ + self.backend.stored_playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] self.sendRequest(u'listplaylist name') @@ -37,7 +37,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_listplaylistinfo_without_quotes(self): - self.core.stored_playlists.playlists = [ + self.backend.stored_playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] self.sendRequest(u'listplaylistinfo name') From f6e42f0f2d931533a9cdcab749feceeb5fa98982 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 4 Nov 2012 12:01:03 +0100 Subject: [PATCH 206/233] Update recommended libspotify and pyspotify version --- mopidy/backends/spotify/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index bb0c805b..28813bc2 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -20,8 +20,8 @@ https://github.com/mopidy/mopidy/issues?labels=Spotify+backend **Dependencies:** -- libspotify >= 11, < 12 (libspotify11 package from apt.mopidy.com) -- pyspotify >= 1.7, < 1.8 (python-spotify package from apt.mopidy.com) +- libspotify >= 12, < 13 (libspotify12 package from apt.mopidy.com) +- pyspotify >= 1.8, < 1.9 (python-spotify package from apt.mopidy.com) **Settings:** From ddbf7b7c40ba71018be75a1ab46b1350d6309a4b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 4 Nov 2012 21:44:25 +0100 Subject: [PATCH 207/233] docs: Include GStreamer and libspotify installation instructions in ToC --- docs/index.rst | 2 ++ docs/installation/index.rst | 6 ------ 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index bdd8e4c1..c9d2577b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -39,6 +39,8 @@ User documentation :maxdepth: 3 installation/index + installation/gstreamer + installation/libspotify settings running clients/index diff --git a/docs/installation/index.rst b/docs/installation/index.rst index c58ba9dd..c84dcf01 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -12,12 +12,6 @@ versions. Requirements ============ -.. toctree:: - :hidden: - - gstreamer - libspotify - If you install Mopidy from the APT archive, as described below, APT will take care of all the dependencies for you. Otherwise, make sure you got the required dependencies installed. From 870d5db135c87ca7d9ff1fcfab2dc9cb1d4a3780 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 4 Nov 2012 21:54:56 +0100 Subject: [PATCH 208/233] docs: Fix a broken link. Reduce amount of redirects --- docs/changes.rst | 2 +- docs/clients/mpd.rst | 2 +- docs/development.rst | 11 ++++++----- docs/index.rst | 8 ++++---- docs/installation/libspotify.rst | 4 ++-- mopidy/settings.py | 2 +- 6 files changed, 15 insertions(+), 14 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index ea5a1530..473c7e37 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -1143,7 +1143,7 @@ Mopidy is working and usable. 0.1.0a0 is an alpha release, which basicly means we will still change APIs, add features, etc. before the final 0.1.0 release. But the software is usable as is, so we release it. Please give it a try and give us feedback, either at our IRC channel or through the `issue tracker -`_. Thanks! +`_. Thanks! **Changes** diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index c7dc3799..6949e506 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -281,7 +281,7 @@ Tested version: The `MPoD `_ iPhone/iPod Touch app can be installed from the `iTunes Store -`_. +`_. Users have reported varying success in using MPoD together with Mopidy. Thus, we've tested a fresh install of MPoD 1.5.1 with Mopidy as of revision e7ed28d diff --git a/docs/development.rst b/docs/development.rst index eae211b9..1fd419d0 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -3,7 +3,7 @@ Development *********** Development of Mopidy is coordinated through the IRC channel ``#mopidy`` at -``irc.freenode.net`` and through `GitHub `_. +``irc.freenode.net`` and through `GitHub `_. Release schedule @@ -90,7 +90,8 @@ Code style Commit guidelines ================= -- We follow the development process described at http://nvie.com/git-model. +- We follow the development process described at + `nvie.com `_. - Keep commits small and on topic. @@ -133,13 +134,13 @@ To run tests with test coverage statistics:: nosetests --with-coverage For more documentation on testing, check out the `nose documentation -`_. +`_. Continuous integration ====================== -Mopidy uses the free service `Travis CI `_ +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 @@ -205,7 +206,7 @@ playlists. Writing documentation ===================== -To write documentation, we use `Sphinx `_. See their +To write documentation, we use `Sphinx `_. See their site for lots of documentation on how to use Sphinx. To generate HTML or LaTeX from the documentation files, you need some additional dependencies. diff --git a/docs/index.rst b/docs/index.rst index c9d2577b..c86c3f0d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,16 +19,16 @@ To install Mopidy, start by reading :ref:`installation`. If you get stuck, we usually hang around at ``#mopidy`` at `irc.freenode.net `_. If you stumble into a bug or got a feature request, please create an issue in the `issue tracker -`_. +`_. Project resources ================= - `Documentation `_ -- `Source code `_ -- `Issue tracker `_ -- `CI server `_ +- `Source code `_ +- `Issue tracker `_ +- `CI server `_ - IRC: ``#mopidy`` at `irc.freenode.net `_ diff --git a/docs/installation/libspotify.rst b/docs/installation/libspotify.rst index 223e4ed7..042034e7 100644 --- a/docs/installation/libspotify.rst +++ b/docs/installation/libspotify.rst @@ -3,8 +3,8 @@ libspotify installation *********************** Mopidy uses `libspotify -`_ for playing music from -the Spotify music service. To use :mod:`mopidy.backends.spotify` you must +`_ for playing music +from the Spotify music service. To use :mod:`mopidy.backends.spotify` you must install libspotify and `pyspotify `_. .. note:: diff --git a/mopidy/settings.py b/mopidy/settings.py index c1f35887..fbc71f0e 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -26,7 +26,7 @@ BACKENDS = ( #: The log format used for informational logging. #: -#: See http://docs.python.org/library/logging.html#formatter-objects for +#: See http://docs.python.org/2/library/logging.html#formatter-objects for #: details on the format. CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(message)s' From f715b4927d1573fe96a69d2bd6a08731371466bf Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 4 Nov 2012 21:56:34 +0100 Subject: [PATCH 209/233] docs: Remove links from README --- README.rst | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/README.rst b/README.rst index a7df7692..352251fb 100644 --- a/README.rst +++ b/README.rst @@ -5,19 +5,18 @@ Mopidy .. image:: https://secure.travis-ci.org/mopidy/mopidy.png?branch=develop Mopidy is a music server which can play music both from your local hard drive -and from `Spotify `_. Searches returns results from -both your local hard drive and from Spotify, and you can mix tracks from both -sources in your play queue. Your Spotify playlists are also available for use, -though we don't support modifying them yet. +and from Spotify. Searches returns results from both your local hard drive and +from Spotify, and you can mix tracks from both sources in your play queue. Your +Spotify playlists are also available for use, though we don't support modifying +them yet. To control your music server, you can use the Ubuntu Sound Menu on the machine running Mopidy, any device on the same network which supports the DLNA media -controller spec (with the help of Rygel in addition to Mopidy), or any `MPD -client `_. MPD clients are available for most platforms, -including Windows, Mac OS X, Linux, Android and iOS. +controller spec (with the help of Rygel in addition to Mopidy), or any MPD +client. MPD clients are available for most platforms, including Windows, Mac OS +X, Linux, Android and iOS. -To install Mopidy, check out -`the installation docs `_. +To get started with Mopidy, check out `the docs `_. - `Documentation `_ - `Source code `_ From 76684ddb50f94dcdac1d138b8b8605d2056c43ff Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 4 Nov 2012 22:07:22 +0100 Subject: [PATCH 210/233] docs: Add links from intro to relevant pages --- docs/clients/dlna.rst | 15 +++++++++++++++ docs/clients/mpd.rst | 8 +++++--- docs/clients/mpris.rst | 15 +++++++++++++++ docs/index.rst | 21 +++++++++++---------- docs/modules/backends/local.rst | 2 ++ docs/modules/backends/spotify.rst | 2 ++ 6 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 docs/clients/dlna.rst create mode 100644 docs/clients/mpris.rst diff --git a/docs/clients/dlna.rst b/docs/clients/dlna.rst new file mode 100644 index 00000000..e1eeddd2 --- /dev/null +++ b/docs/clients/dlna.rst @@ -0,0 +1,15 @@ +.. _dlna-clients: + +************ +DLNA clients +************ + +TODO + + +.. _rygel: + +Exposing Mopidy over DLNA using Rygel +===================================== + +TODO diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 6949e506..17282d8c 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -1,6 +1,8 @@ -************************ -MPD client compatability -************************ +.. _mpd-clients: + +*********** +MPD clients +*********** This is a list of MPD clients we either know works well with Mopidy, or that we know won't work well. For a more exhaustive list of MPD clients, see diff --git a/docs/clients/mpris.rst b/docs/clients/mpris.rst new file mode 100644 index 00000000..b57cd0b9 --- /dev/null +++ b/docs/clients/mpris.rst @@ -0,0 +1,15 @@ +.. _mpris-clients: + +************* +MPRIS clients +************* + +TODO + + +.. _ubuntu-sound-menu: + +Ubuntu Sound Menu +================= + +TODO diff --git a/docs/index.rst b/docs/index.rst index c86c3f0d..2dc29590 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2,17 +2,18 @@ Mopidy ****** -Mopidy is a music server which can play music both from your local hard drive -and from `Spotify `_. Searches returns results from -both your local hard drive and from Spotify, and you can mix tracks from both -sources in your play queue. Your Spotify playlists are also available for use, -though we don't support modifying them yet. +Mopidy is a music server which can play music both from your :ref:`local hard +drive ` and from :ref:`Spotify `. Searches +returns results from both your local hard drive and from Spotify, and you can +mix tracks from both sources in your play queue. Your Spotify playlists are +also available for use, though we don't support modifying them yet. -To control your music server, you can use the Ubuntu Sound Menu on the machine -running Mopidy, any device on the same network which supports the DLNA media -controller spec (with the help of Rygel in addition to Mopidy), or any `MPD -client `_. MPD clients are available for most platforms, -including Windows, Mac OS X, Linux, Android and iOS. +To control your music server, you can use the :ref:`Ubuntu Sound Menu +` on the machine running Mopidy, any device on the same +network which supports the :ref:`DLNA ` media controller spec +(with the help of :ref:`Rygel ` in addition to Mopidy), or any :ref:`MPD +client `. MPD clients are available for most platforms, including +Windows, Mac OS X, Linux, Android and iOS. To install Mopidy, start by reading :ref:`installation`. diff --git a/docs/modules/backends/local.rst b/docs/modules/backends/local.rst index b4ab7d49..9ac93bc8 100644 --- a/docs/modules/backends/local.rst +++ b/docs/modules/backends/local.rst @@ -1,3 +1,5 @@ +.. _local-backend: + ********************************************* :mod:`mopidy.backends.local` -- Local backend ********************************************* diff --git a/docs/modules/backends/spotify.rst b/docs/modules/backends/spotify.rst index e724da27..b410f272 100644 --- a/docs/modules/backends/spotify.rst +++ b/docs/modules/backends/spotify.rst @@ -1,3 +1,5 @@ +.. _spotify-backend: + ************************************************* :mod:`mopidy.backends.spotify` -- Spotify backend ************************************************* From 331603cc35a299d37a2fe84b2c91bcd9aad69569 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 5 Nov 2012 18:07:15 +0100 Subject: [PATCH 211/233] docs: Port Raspberry Pi how tos from wiki --- docs/_static/raspberry-pi-by-jwrodgers.jpg | Bin 0 -> 52681 bytes docs/index.rst | 1 + docs/installation/raspberrypi.rst | 265 +++++++++++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 docs/_static/raspberry-pi-by-jwrodgers.jpg create mode 100644 docs/installation/raspberrypi.rst diff --git a/docs/_static/raspberry-pi-by-jwrodgers.jpg b/docs/_static/raspberry-pi-by-jwrodgers.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d093bb8836a30f2d166c202bdf37ec3c06a910d7 GIT binary patch literal 52681 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 is possible, although there are a few +significant drawbacks to doing so. This document is intended to help you get +Mopidy running on your Raspberry Pi and to document the progress made and +issues surrounding running Mopidy on the Raspberry Pi. + +Mopidy will not currently run with Spotify support on the foundation-provided +`Raspbian `_ distribution. See :ref:`not-raspbian` for +details. However, Mopidy should run with Spotify support on any ARM Debian +image that has hardware floating-point support **disabled**. + +.. image:: /_static/raspberry-pi-by-jwrodgers.jpg + :width: 640 + :height: 427 + + +.. _raspi-squeeze: + +How to for Debian 6 (Squeeze) +============================= + +The following guide illustrates how to get Mopidy running on a minimal Debian +squeeze distribution. The image used can be downloaded at +http://www.linuxsystems.it/2012/06/debian-wheezy-raspberry-pi-minimal-image/. +This image is a very minimal distribution and does not include many common +packages you might be used to having access to. If you find yourself trying to +complete instructions here and getting ``command not found``, try using +``apt-get`` to install the relevant packages! + +1. Flash the OS image to your SD card. See + http://elinux.org/RPi_Easy_SD_Card_Setup for help. + +2. If you have an SD card that's >2 GB, resize the disk image to use some more + space (we'll need a bit more to install some packages and stuff). See + http://elinux.org/RPi_Resize_Flash_Partitions#Manually_resizing_the_SD_card_on_Raspberry_Pi + for help. + +3. To even get to the point where we can start installing software let's + install ``sudo`` and create a user account with ``sudo`` rights so we don't + have to do everything on the ``root`` account:: + + apt-get install sudo + adduser + adduser sudo + + Log in to your Raspberry Pi again with your new user account instead of the + ``root`` account. + +4. Enable the Raspberry Pi's sound drivers: + + - To enable the Raspberry Pi's sound driver:: + + sudo modprobe snd_bcm2835 + + - To load the sound driver at boot time:: + + echo "snd_bcm2835" | sudo tee /etc/modules + +5. Let's get the Raspberry Pi up-to-date: + + - Get some tools that we need to download and run the ``rpi-update`` + script:: + + sudo apt-get install ca-certificates git-core binutils + + - Download ``rpi-update`` from Github:: + + sudo wget https://raw.github.com/Hexxeh/rpi-update/master/rpi-update + + - Move ``rpi-update`` to an appropriate location:: + + sudo mv rpi-update /usr/local/bin/rpi-update + + - Make ``rpi-update`` executable:: + + sudo chmod +x /usr/local/bin/rpi-update + + - Finally! Update your firmware:: + + sudo rpi-update + + - After firmware updating finishes, reboot your Raspberry Pi:: + + sudo reboot + +6. Install some software that we'll need to get up and running:: + + sudo apt-get install python2.7 python-dev python-pip + + This will take a little while to download and install. + +7. Start installing Mopidy's dependencies (from :ref:`installation`):: + + sudo pip install pykka + sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \ + gstreamer0.10-plugins-ugly gstreamer-tools + +8. Install libspotify and pyspotify. Note that these two pieces of software + are rather tightly coupled; thus, it's important to make sure you have two + compatible versions installed. At the time of writing, pyspotify 1.8.1 and + libspotify 12 are the most recent stable versions of these software + components. + + - Download libspotify for ARMv5:: + + wget https://developer.spotify.com/download/libspotify/libspotify-12.1.51-Linux-armv5-release.tar.gz + tar xvfz libspotify-12.1.51-Linux-armv5-release.tar.gz + cd libspotify-12.1.51-Linux-armv5-release + sudo make install + sudo ldconfig + + - Now install pyspotify:: + + sudo pip install pyspotify==1.8.1 + +9. jackd2, which should be installed at this point, seems to cause some + problems. Let's install jackd1, as it seems to work a little bit better:: + + sudo apt-get install jackd1 + +10. Add your user to the ``audio`` group:: + + sudo adduser audio + +11. Finally! Install Mopidy:: + + sudo pip install mopidy + +You may encounter some issues with your audio configuration where sound does +not play. If that happens, edit your ``/etc/asound.conf`` to read something like:: + + pcm.mmap0 { + type mmap_emul; + slave { + pcm "hw:0,0"; + } + } + + pcm.!default { + type plug; + slave { + pcm mmap0; + } + } + + +.. _raspi-wheezy: + +How to for Debian 7 (Wheezy) +============================ + +This is a very similar system to Debian 6.0 above, but with a bit newer +software packages, as Wheezy is going to be the next release of Debian. + +1. Download the latest wheezy disk image from + http://downloads.raspberrypi.org/images/debian/7/. I used the one dated + 2012-08-08. + +2. Flash the OS image to your SD card. See + http://elinux.org/RPi_Easy_SD_Card_Setup for help. + +3. If you have an SD card that's >2 GB, you don't have to resize the file + systems on another computer. Just boot up your Raspberry Pi with the + unaltered partions, and it will boot right into the ``raspi-config`` tool, + which will let you grow the root file system to fill the SD card. This tool + will also allow you do other useful stuff, like turning on the SSH server. + +4. As opposed to on Squeeze, ``sudo`` comes preinstalled. You can login to the + default user using username ``pi`` and password ``raspberry``. To become + root, just enter ``sudo -i``. + +5. As opposed to on Squeeze, the correct sound driver comes preinstalled. + +6. As opposed to on Squeeze, your kernel and GPU firmware is rather up to date + when running Wheezy. + +7. To avoid a couple of potential problems with Mopidy, turn on IPv6 support: + + - Load the IPv6 kernel module now:: + + sudo modprobe ipv6 + + - Add ``ipv6`` to ``/etc/modules`` to ensure the IPv6 kernel module is + loaded on boot:: + + echo ipv6 | sudo tee /etc/modules + +8. Installing Mopidy and its dependencies from `apt.mopidy.com + `_, as described in :ref:`installation`. In short:: + + wget -q -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add - + sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list + sudo apt-get update + sudo apt-get install mopidy + +9. Opposed to on Squeeze, there is no need to add your user to the ``audio`` + group, as the ``pi`` user already is a member of that group. + +10. Since I have a HDMI cable connected, but want the sound on the analog sound + connector, I have to run:: + + amixer cset numid=3 1 + + to force it to use analog output. ``1`` means analog, ``0`` means auto, and + is the default, while ``2`` means HDMI. You can test sound output + independent of Mopidy by running:: + + aplay /usr/share/sounds/alsa/Front_Center.wav + + To make the change to analog output stick, you can add the ``amixer`` command + to e.g. ``/etc/rc.local``, which will be executed when the system is + booting. + + +Known Issues +============ + +Audio Quality +------------- + +The Raspberry Pi's audio quality can be sub-par through the analog output. This +is known and unlikely to be fixed as including any higher-quality hardware +would increase the cost of the board. If you experience crackling/hissing or +skipping audio, you may want to try a USB sound card. Additionally, you could +lower your default ALSA sampling rate to 22KHz, though this will lead to a +substantial decrease in sound quality. + + +.. _not-raspbian: + +Why Not Raspbian? +----------------- + +Mopidy with Spotify support is currently unavailable on the recommended +`Raspbian `_ Debian distribution that the Raspberry Pi +foundation has made available. This is due to Raspbian's hardware +floating-point support. The Raspberry Pi comes with a co-processor designed +specifically for floating-point computations (commonly called an FPU). Taking +advantage of the FPU can speed up many computations significantly over +software-emulated floating point routines. Most of Mopidy's dependencies are +open-source and have been (or can be) compiled to support the ``armhf`` +architecture. However, there is one component of Mopidy's stack which is +closed-source and crucial to Mopidy's Spotify support: libspotify. + +The ARM distributions of libspotify available on `Spotify's developer website +`_ are compiled for the ``armel`` architecture, +which has software floating-point support. ``armel`` and ``armhf`` software +cannot be mixed, and pyspotify links with libspotify as C extensions. Thus, +Mopidy will not run with Spotify support on ``armhf`` distributions. + +If the Spotify folks ever release builds of libspotify with ``armhf`` support, +Mopidy *should* work on Raspbian. + + +Support +======= + +If you had trouble with the above or got Mopidy working a different way on +RaspberryPi, please send us a pull request to update this page with your new +information. As usual, the folks at ``#mopidy`` on ``irc.freenode.net`` may be +able to help with any problems encountered. From f69fc4f9762b984d0604da6a1166f3f5d6ef9530 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 5 Nov 2012 22:42:47 +0100 Subject: [PATCH 212/233] docs: How to use Mopidy and Rygel with UPnP clients --- README.rst | 7 +- docs/clients/dlna.rst | 15 ---- docs/clients/upnp.rst | 117 +++++++++++++++++++++++++++++++ docs/index.rst | 7 +- docs/modules/frontends/mpris.rst | 2 + 5 files changed, 125 insertions(+), 23 deletions(-) delete mode 100644 docs/clients/dlna.rst create mode 100644 docs/clients/upnp.rst diff --git a/README.rst b/README.rst index 352251fb..c7eea228 100644 --- a/README.rst +++ b/README.rst @@ -11,10 +11,9 @@ Spotify playlists are also available for use, though we don't support modifying them yet. To control your music server, you can use the Ubuntu Sound Menu on the machine -running Mopidy, any device on the same network which supports the DLNA media -controller spec (with the help of Rygel in addition to Mopidy), or any MPD -client. MPD clients are available for most platforms, including Windows, Mac OS -X, Linux, Android and iOS. +running Mopidy, any device on the same network which can control UPnP +MediaRenderers, or any MPD client. MPD clients are available for most +platforms, including Windows, Mac OS X, Linux, Android and iOS. To get started with Mopidy, check out `the docs `_. diff --git a/docs/clients/dlna.rst b/docs/clients/dlna.rst deleted file mode 100644 index e1eeddd2..00000000 --- a/docs/clients/dlna.rst +++ /dev/null @@ -1,15 +0,0 @@ -.. _dlna-clients: - -************ -DLNA clients -************ - -TODO - - -.. _rygel: - -Exposing Mopidy over DLNA using Rygel -===================================== - -TODO diff --git a/docs/clients/upnp.rst b/docs/clients/upnp.rst new file mode 100644 index 00000000..567fb04f --- /dev/null +++ b/docs/clients/upnp.rst @@ -0,0 +1,117 @@ +.. _upnp-clients: + +************ +UPnP clients +************ + +`UPnP `_ is a set of +specifications for media sharing, playing, remote control, etc, across a home +network. The specs are supported by a lot of consumer devices (like +smartphones, TVs, Xbox, and PlayStation) that are often labeled as being `DLNA +`_ compatible or certified. + +The DLNA guidelines and UPnP specifications defines several device roles, of +which Mopidy may play two: + +DLNA Digital Media Server (DMS) / UPnP AV MediaServer: + + A MediaServer provides a library of media and is capable of streaming that + media to a MediaRenderer. If Mopidy was a MediaServer, you could browse and + play Mopidy's music on a TV, smartphone, or tablet supporting UPnP. Mopidy + does not currently support this, but we may in the future. :issue:`52` is + the relevant wishlist issue. + +DLNA Digital Media Renderer (DMR) / UPnP AV MediaRenderer: + + A MediaRenderer is asked by some remote controller to play some + given media, typically served by a MediaServer. If Mopidy was a + MediaRenderer, you could use e.g. your smartphone or tablet to make Mopidy + play media. Mopidy *does already* have experimental support for being a + MediaRenderer with the help of Rygel, as you can read more about below. + + +.. _rygel: + +How to make Mopidy available as an UPnP MediaRenderer +===================================================== + +With the help of `the Rygel project `_ Mopidy can +be made available as an UPnP MediaRenderer. Rygel will interface with Mopidy's +:ref:`MPRIS frontend `, and make Mopidy available as a +MediaRenderer on the local network. Since this depends on the MPRIS frontend, +which again depends on D-Bus being available, this will only work on Linux, and +not OS X. MPRIS/D-Bus is only available to other applications on the same host, +so Rygel must be running on the same machine as Mopidy. + +1. Start Mopidy and make sure the :ref:`MPRIS frontend ` is + working. It is activated by default, but you may miss dependencies or be + using OS X, in which case it will not work. Check the console output when + Mopidy is started for any errors related to the MPRIS frontend. If you're + unsure it is working, there are instructions for how to test it on the + :ref:`MPRIS frontend ` page. + +2. Install Rygel. On Debian/Ubuntu:: + + sudo apt-get install rygel + +3. Enable Rygel's MPRIS plugin. On Debian/Ubuntu, edit ``/etc/rygel.conf``, + find the ``[MPRIS]`` section, and change ``enabled=false`` to + ``enabled=true``. + +4. Start Rygel by running:: + + rygel + + Example output:: + + $ rygel + Rygel-Message: New plugin 'MediaExport' available + Rygel-Message: New plugin 'org.mpris.MediaPlayer2.spotify' available + Rygel-Message: New plugin 'org.mpris.MediaPlayer2.mopidy' available + + Note that in the above example, both the official Spotify client and Mopidy + is running and made available through Rygel. + + +The UPnP-Inspector client +========================= + +`UPnP-Inspector `_ is a +graphical analyzer and debugging tool for UPnP services. It will detect any +UPnP devices on your network, and show these in a tree structure. This is not a +tool for your everyday music listening while relaxing on the couch, but it may +be of use for testing that your setup works correctly. + +1. Install UPnP-Inspector. On Debian/Ubuntu:: + + sudo apt-get install upnp-inspector + +2. Run it:: + + upnp-inspector + +3. Assuming that Mopidy is running with a working MPRIS frontend, and that + Rygel is running on the same machine, Mopidy should now appear in + UPnP-Inspector's device list. + +4. If you expand the tree item saying ``Mopidy + (MediaRenderer:2)`` or similiar, and then the sub element named + ``AVTransport:2`` or similar, you'll find a list of commands you can invoke. + E.g. if you double-click the ``Pause`` command, you'll get a new window + where you can press an ``Invoke`` button, and then Mopidy should be paused. + +Note that if you have a firewall on the host running Mopidy and Rygel, and you +want this to be exposed to the rest of your local network, you need to open up +your firewall for UPnP traffic. UPnP use UDP port 1900 as well as some +dynamically assigned ports. I've only verified that this procedure works across +the network by temporarily disabling the firewall on the the two hosts +involved, so I'll leave any firewall configuration as an exercise to the +reader. + + +Other clients +============= + +For a long list of UPnP clients for all possible platforms, see Wikipedia's +`List of UPnP AV media servers and clients +`_. diff --git a/docs/index.rst b/docs/index.rst index d9cba72d..0f5ed164 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -10,10 +10,9 @@ also available for use, though we don't support modifying them yet. To control your music server, you can use the :ref:`Ubuntu Sound Menu ` on the machine running Mopidy, any device on the same -network which supports the :ref:`DLNA ` media controller spec -(with the help of :ref:`Rygel ` in addition to Mopidy), or any :ref:`MPD -client `. MPD clients are available for most platforms, including -Windows, Mac OS X, Linux, Android and iOS. +network which can control UPnP MediaRenderers (see :ref:`upnp-clients`), or any +:ref:`MPD client `. MPD clients are available for most platforms, +including Windows, Mac OS X, Linux, Android, and iOS. To install Mopidy, start by reading :ref:`installation`. diff --git a/docs/modules/frontends/mpris.rst b/docs/modules/frontends/mpris.rst index 2984e4c1..e0ec63da 100644 --- a/docs/modules/frontends/mpris.rst +++ b/docs/modules/frontends/mpris.rst @@ -1,3 +1,5 @@ +.. _mpris-frontend: + *********************************************** :mod:`mopidy.frontends.mpris` -- MPRIS frontend *********************************************** From 6088868d6b71b8844dbe28a5d3a657f79f2869ed Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 08:58:15 +0100 Subject: [PATCH 213/233] docs: Update Raspberry Pi how to for Squeeze to use APT --- docs/installation/raspberrypi.rst | 124 +++++++++++++----------------- 1 file changed, 55 insertions(+), 69 deletions(-) diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index 6b682471..9e13f583 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -24,33 +24,48 @@ How to for Debian 6 (Squeeze) ============================= The following guide illustrates how to get Mopidy running on a minimal Debian -squeeze distribution. The image used can be downloaded at -http://www.linuxsystems.it/2012/06/debian-wheezy-raspberry-pi-minimal-image/. -This image is a very minimal distribution and does not include many common -packages you might be used to having access to. If you find yourself trying to -complete instructions here and getting ``command not found``, try using -``apt-get`` to install the relevant packages! +squeeze distribution. -1. Flash the OS image to your SD card. See +1. The image used can be downloaded at + http://www.linuxsystems.it/2012/06/debian-wheezy-raspberry-pi-minimal-image/. + This image is a very minimal distribution and does not include many common + packages you might be used to having access to. If you find yourself trying + to complete instructions here and getting ``command not found``, try using + ``apt-get`` to install the relevant packages! + +2. Flash the OS image to your SD card. See http://elinux.org/RPi_Easy_SD_Card_Setup for help. -2. If you have an SD card that's >2 GB, resize the disk image to use some more +3. If you have an SD card that's >2 GB, resize the disk image to use some more space (we'll need a bit more to install some packages and stuff). See http://elinux.org/RPi_Resize_Flash_Partitions#Manually_resizing_the_SD_card_on_Raspberry_Pi for help. -3. To even get to the point where we can start installing software let's - install ``sudo`` and create a user account with ``sudo`` rights so we don't - have to do everything on the ``root`` account:: +4. To even get to the point where we can start installing software let's create + a new user and give it sudo access. - apt-get install sudo - adduser - adduser sudo + - Install ``sudo``:: - Log in to your Raspberry Pi again with your new user account instead of the - ``root`` account. + apt-get install sudo -4. Enable the Raspberry Pi's sound drivers: + - Create a user account:: + + adduser + + - Give the user sudo access by adding it to the ``sudo`` group so we don't + have to do everything on the ``root`` account:: + + adduser sudo + + - While we're at it, give your user access to the sound card by adding it to + the audio group:: + + adduser audio + + - Log in to your Raspberry Pi again with your new user account instead of + the ``root`` account. + +5. Enable the Raspberry Pi's sound drivers: - To enable the Raspberry Pi's sound driver:: @@ -60,7 +75,7 @@ complete instructions here and getting ``command not found``, try using echo "snd_bcm2835" | sudo tee /etc/modules -5. Let's get the Raspberry Pi up-to-date: +6. Let's get the Raspberry Pi up-to-date: - Get some tools that we need to download and run the ``rpi-update`` script:: @@ -87,51 +102,22 @@ complete instructions here and getting ``command not found``, try using sudo reboot -6. Install some software that we'll need to get up and running:: +7. Installing Mopidy and its dependencies from `apt.mopidy.com + `_, as described in :ref:`installation`. In short:: - sudo apt-get install python2.7 python-dev python-pip + wget -q -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add - + sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list + sudo apt-get update + sudo apt-get install mopidy - This will take a little while to download and install. - -7. Start installing Mopidy's dependencies (from :ref:`installation`):: - - sudo pip install pykka - sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \ - gstreamer0.10-plugins-ugly gstreamer-tools - -8. Install libspotify and pyspotify. Note that these two pieces of software - are rather tightly coupled; thus, it's important to make sure you have two - compatible versions installed. At the time of writing, pyspotify 1.8.1 and - libspotify 12 are the most recent stable versions of these software - components. - - - Download libspotify for ARMv5:: - - wget https://developer.spotify.com/download/libspotify/libspotify-12.1.51-Linux-armv5-release.tar.gz - tar xvfz libspotify-12.1.51-Linux-armv5-release.tar.gz - cd libspotify-12.1.51-Linux-armv5-release - sudo make install - sudo ldconfig - - - Now install pyspotify:: - - sudo pip install pyspotify==1.8.1 - -9. jackd2, which should be installed at this point, seems to cause some +8. jackd2, which should be installed at this point, seems to cause some problems. Let's install jackd1, as it seems to work a little bit better:: sudo apt-get install jackd1 -10. Add your user to the ``audio`` group:: - - sudo adduser audio - -11. Finally! Install Mopidy:: - - sudo pip install mopidy - You may encounter some issues with your audio configuration where sound does -not play. If that happens, edit your ``/etc/asound.conf`` to read something like:: +not play. If that happens, edit your ``/etc/asound.conf`` to read something +like:: pcm.mmap0 { type mmap_emul; @@ -173,6 +159,9 @@ software packages, as Wheezy is going to be the next release of Debian. default user using username ``pi`` and password ``raspberry``. To become root, just enter ``sudo -i``. + Opposed to on Squeeze, there is no need to add your user to the ``audio`` + group, as the ``pi`` user already is a member of that group. + 5. As opposed to on Squeeze, the correct sound driver comes preinstalled. 6. As opposed to on Squeeze, your kernel and GPU firmware is rather up to date @@ -197,23 +186,20 @@ software packages, as Wheezy is going to be the next release of Debian. sudo apt-get update sudo apt-get install mopidy -9. Opposed to on Squeeze, there is no need to add your user to the ``audio`` - group, as the ``pi`` user already is a member of that group. +9. Since I have a HDMI cable connected, but want the sound on the analog sound + connector, I have to run:: -10. Since I have a HDMI cable connected, but want the sound on the analog sound - connector, I have to run:: + amixer cset numid=3 1 - amixer cset numid=3 1 + to force it to use analog output. ``1`` means analog, ``0`` means auto, and + is the default, while ``2`` means HDMI. You can test sound output + independent of Mopidy by running:: - to force it to use analog output. ``1`` means analog, ``0`` means auto, and - is the default, while ``2`` means HDMI. You can test sound output - independent of Mopidy by running:: + aplay /usr/share/sounds/alsa/Front_Center.wav - aplay /usr/share/sounds/alsa/Front_Center.wav - - To make the change to analog output stick, you can add the ``amixer`` command - to e.g. ``/etc/rc.local``, which will be executed when the system is - booting. + To make the change to analog output stick, you can add the ``amixer`` command + to e.g. ``/etc/rc.local``, which will be executed when the system is + booting. Known Issues From 0a2fd5fe2b89258a16f144d14680d054ed01fad8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 09:07:57 +0100 Subject: [PATCH 214/233] docs: Turn on IPv6 in Raspi on Squeeze --- docs/installation/raspberrypi.rst | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index 9e13f583..a8175ac8 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -102,7 +102,18 @@ squeeze distribution. sudo reboot -7. Installing Mopidy and its dependencies from `apt.mopidy.com +7. To avoid a couple of potential problems with Mopidy, turn on IPv6 support: + + - Load the IPv6 kernel module now:: + + sudo modprobe ipv6 + + - Add ``ipv6`` to ``/etc/modules`` to ensure the IPv6 kernel module is + loaded on boot:: + + echo ipv6 | sudo tee /etc/modules + +8. Installing Mopidy and its dependencies from `apt.mopidy.com `_, as described in :ref:`installation`. In short:: wget -q -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add - @@ -110,7 +121,7 @@ squeeze distribution. sudo apt-get update sudo apt-get install mopidy -8. jackd2, which should be installed at this point, seems to cause some +9. jackd2, which should be installed at this point, seems to cause some problems. Let's install jackd1, as it seems to work a little bit better:: sudo apt-get install jackd1 From deccce6237572bb617d3f732451fa29667911d58 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 09:20:24 +0100 Subject: [PATCH 215/233] docs: Formatting --- docs/installation/raspberrypi.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index a8175ac8..eaec48cd 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -257,6 +257,6 @@ Support ======= If you had trouble with the above or got Mopidy working a different way on -RaspberryPi, please send us a pull request to update this page with your new +Raspberry Pi, please send us a pull request to update this page with your new information. As usual, the folks at ``#mopidy`` on ``irc.freenode.net`` may be able to help with any problems encountered. From 8ccfc3e56e2c61c310dd4bfe7e37f11a7b04ac3f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 10:17:34 +0100 Subject: [PATCH 216/233] docs: How to use the Ubuntu Sound Menu with Mopidy --- docs/_static/ubuntu-sound-menu.png | Bin 0 -> 89927 bytes docs/clients/mpris.rst | 55 +++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 docs/_static/ubuntu-sound-menu.png diff --git a/docs/_static/ubuntu-sound-menu.png b/docs/_static/ubuntu-sound-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..9362f6f49ca2bdd9818899d806f2a99103087efc GIT binary patch literal 89927 zcmYJaV{m0rw>5lX+v?c1(a8xqPRF)wtD_U!PCB-2+h)fd+j#rA&%NI_s&?(FRkeSt zz1Ey#&QW8AE6PhE!Q;aN005*PQox@807M4>0JaMY{%_@s>7w)B3(i4G(-{Cj!1&Jt z2FS?51pr6@KY-s=Jl4;3-2;eJmxPa}C(kyxZ6W@W3jS6UOWyvYtRjM`DDqw#@Bo4J zT}837v~*PjO;H3jo{lmfm*h7(NmuH{!%}+N@tg5__U(3ebpB3m>)MmsX;sf%$8k)S-NCDr+oahDfK5MH_l(je55g8z#_?{6q$R0Iq<}$`Q+OS(Jhy$`z+wxp% z+;O&6HV2y=h`uw`vm2?Zm?=M~vzZx0O~FO-HEisx&Iv|`F<3RXu0$$*T!{daIlP{* zQJPkh=>LU1C9W0H!^74VQFu!%2ab%Fn6IE?_XyD9u`(TE?L6i}d;exvn5!~lUl92t9*%c z=Wkyk43?F&2q((B*=?E9@fhW%z}rgv-(IiqF(ZoB8qA1255VOX$_2z%9?Y=?dknWS zJOuj1@@n#IR@*J79k9-{`%OB!c1}(#xFdO?Je_TA4nRs&3jh)Br7Bz9wr+WePO84) zD4WnhR9s#(+x1S*+ut`*i*sw=Vv+h>^EKu-_}h8IAW*V|jbD?-FsK)omZ%bG0i%b` zTU%Xc%k~}@O)IGky4AF+n~SKCmZln*Wf4aBUc6+3ytdoT2!X?~M2vnrY*s2vxiiT3 z_M5GikLPRq!!f#TR;DCu(8tB)rNw^>_aRT`wN`vRp@9&!Roo3*w%@cgH2cKlh(+jTbFKGak70sd@AEr^uHDgV6`IE7_pFOA9y?|0e$ zXP1}dllB?-r(V_1ZxeKl8@z0So7q@&?Ml$iem5#|pY`=AED*Tk9F z>5$9E*Mc+u4}+bYYu1>7n4X(A^|Gh0-Petd(_w;9fJiK{!+`Hjj`z&a3KOOARU?0M z(}YUqi2v=cFF}LX)w-^huXFR7YHo63TyD3LqpZhxg8s2W0bSo zCj@SDILyL#0V$YQThIMs{#_s2+pj;M^RAg5_G5+LFh(YG_+(9s9#s`~o@#o8*Yx;v zt}MOpMwvts>gq)NCt09wCJrvHI@yZSfbe_RB3L5KGZBZ=!f^jVxI5A*!Rd~N@mAIv z`wY)V5`0*Cmj2fkLHjyC7$Cl@*-XxOzjo-1E5;7*k}cC#Vgl{Ud2wVqOOTKRrZ=2N zq%W#!t<%z{eUZgU;CG4Nc|zf%??az=F2iH2!0UY?Q)^b$%bak8EsM`~2~&^HF#T8e z1u-+uaC4T&Hs#9v$I8|t12IKfhM|M%QiNAH2I&A&CKopsceEYe!*Is7)pl1?)!nLb z4DVNG?4VIy)7i4%$7BK4g!<#-gCiLvpm9KLab;;Hm;1PsJXYXywJ?_Fd{+4D@X{Qc ziIBCERs%={2CmeoE}<>?WG>`87WH=l5Wsqnh0J#yV7uXta_?%p+3I){m0luNlNDF6Q%`OQqjrh^%{oGxDmqh36dJJMRXh8+J_(l? zK}xlrtbihX8^oQXz0>skBJTcC(bVCZ8XgZhznlKL&-A(+mc)p%^?H_L$v(q(ETjIo zd3%g_i2kR~6=RRTD-u`hMRZ=0&9Ca?&-*vGkXXT^ zNet$W%Emnygff(jr^>Fj6Uk1d8GE^ah*fF5B8LL5f{~_0H z#$75H=&?l-`e%6K8;!}Fy?E3$s9c_>-Js(g=NUmJu+`(>2_f&ZFOQEt`fk64AWHJL`F-y0`_}J}A{B1cM2<_b#nXhJGn5^x-I7y3F50h}N7PoZ z9iI01sdF5r8hOc=#QC8OF^xxJEbXkcnqE1PlbBE5W-{;=8kelq)qjXHz1yOE0y8nfV#eZCsY2Cg55yIH>e72v74{+PMS!qLH5_JCl5n1t4X6JoY zoKr1%*$EQ>=t#Xd&2(7eT+l*C@a|X=w6V{5_~lUwPq!zJyiyjPl*g(+fWE_qP>B1j za+d(463;quBPseH?CC;Q9+AW%I2i=Ad_KnJ@_gwS&abR3=zpCm3)B!jvO2}UfLIGO zI)#!szEhLcL)83<@jHZQiaTFlHY*UdJ@ZZ&nGzQGn(@C>3tLi{;y6|p+{ngFQnFcZ z^>UE|HSsU0E7seaRdi_R~UFI=}n>KZ=BtwR)h<^#?! zISfpXtP22)6Qxn9~%WhRCT*u#dm>zq}f0hHI*^f~o z?YnJNnMM&^as37*QSio^vmdv1bbn4bYp3kEpUtd1jyNgBtQ576EwB%5(epPf0z}4_ z)t6`CFh-~nvLAlXRBmsLOyWZ{^*^q-4fG{ukSxDpA*H9F64O)Z~ly49@XcXNLed=eD%r9cG~YZ*e@> z0Rbsu(m{$ioqj>PeE_Q^%MKC0I7%V;+vQ$L(JR+)_e|mKL8G}ry-fJvKp{rE<78xG z!xQmk`Plzyv`<~{IZ_+h!_3y~QNZU&vgb>|pZyO>l&I3`=~9hCjzC*o9c#Q4lWuoE zhs&@=vdY2h)n*#EVH;N(O}9%sU!N$UmRy`%z!b9p)|m|6Lc+T3dQ*Bp6h5cbT76N74_bhV9C?9J z9i&K9$iZ0cFyS+ShkJ{EyX%q98gZ`s8XVGuefR5zg1a-Ina%&XikRPKvL<&+^pXGo z2*~gBI(y1(y@XNs@%+H)X&StdqAmzr-DJ+;Bm8=K(HAaC20XuZi%yseKlHURZ*RwM z-SE=eNTWz1Mu5D!b7@o0ZyH_>NQfb$+&sv-Z|(vg^u&%)4D zhkeIig$+(*6}ElKWUZvC3hbIQ+~zz?V%Gg@h}6x8@nwyLJWM@q9x@0LRYOoIL2)N<4322dM07T;D z^JRIZr=nU~$*7`XhX3ifZS@?K}s1jcyoQnXMcMd?SQRaZ|ZlzFd{IxD4PEBessQK zTKMXOzkWKERUE80&&F<*crb;w)1SBkefxT<`cop*Y4UjGabxB031p=@M^pq#-2bi!U;lB55gZN$rh z^hC1m>U=-qOe&E47%c%TzbO8%JxdK@HKV-3#T`{Em=J+j3)B%p0SpH6Z27$r(W_OB z|5i3Mx~St0D8Sokb>3K2$uG8y0@Hdr!SM6C5#qP+x(u6;#Q+43sLI)9uu#Qoze=dH z?&LLSY?Ox(L@po`=?0y_bBU8Cw&wvWWKIj*WkdvoXX^N zbiC#J3ns`@hM^EVzRdWUEtbab=_?5RZEb#x$=!O7tgf#17ksxMLsQ2r>Qq8&#N5_s z7>YiC+fb?BvPoOSxN}NVt5Wr+$M4nT()F?6|YjR6Z_&g1KBEAvVI@qZC{`Q2S%W%6&y_S@5&dZmu3 zuW<%s&T=R=fmnbGouFYqBtQ|<^)zYhOS6+FTRLSajcD9-k?DCE$(*qDy!>iA(>iF5 zI>+B*V4dHulPO&a0sxpn;f8K@VbI*51T2!9qd-g-qE34W&#BcsZ__-?LHt!sS6Vj$@Kh8OPx#d?ePyvcKXy&3ZuSV zhMBQ3IU3SE3hH;dba3Ps9n!qF1;zI?rJZ)4HqRTl)SRx%Y5$rtW5#g400y?ItCyZG zhxKUU*ViGJs{wj0u{1D5jrX(MnV!oE<=f?@rO&U;hLb4bZ2yO_ zhmOy#@ECzVzhawgw0({*nX1tP5_BJT=kNU7KE=m-_gkt|Cre9Ai<)KxGlie-WoGzp z)&0GRX1)eB7wQ#0|2CZjCHId@;^3F$cpawmUwITeAJC=}u^H_%`*$-+{63!#ox;V| zXSpiHP~f*%pYE{lu4xFPH}c-dzi{7#L7A-9+{p=QXLVK(^q073-eEddG(P>-%#Bk# zmOG8bTsrrtw2HES{g#t+2rU`V2JgpL_OBfUVVfcR@=X-e@_9JyWy0W{QL^FVm8Hps;g3 z&gl52=QhRaypJajm(jCo{J1Tzx0r?9C$jywey;{*8Rq%_`Bx}ra(mwF z*Bof1<1^e++r8(w^^FKWM&}MAIiInke4h6mLyr^b%o2C~fxbw@(R*GpZnAk?>ahQu zsL)1)A`yC9*m?WBZ~nBk*7M(mX%TF3ZJ7p0+1S?s%RPtThP$5Y*Xv9V(HQCJy1PbGoxK0qZ(iFXQNyz!>L+UacAowzf0oc`#6?mNO z`Br^f+5C7phB0E=0CD*2MXLWkkJA%?=zlYz4<;g{uZT&Bz3?w{=ZyVMt0Q~Lj8TB_3W^72!~881n>r;Org0%5oP;VIjTi->~v)S1Tvfbq)0NMa1h_?M4MYHt%nr-gUjJ!&i~sJ0kQ$fY4e{7aTU%S> zwq@Y)N6G`T2-r3BPfX;>{G+0TPn)`Dbr!%bf8Mb2jfU)-n|*E{3p9xBIQM^*c790a zu1=HH+9M)!)a&!0*wf2c(H3Jkx3XzRM}S6wr91bx1dOw;rEzd$sL2QAC-hK0Y{j; z{{7$SWhm48{#zQ0?p1~KuWn3>^w+V42B)Snhy&#o#((cZJ75e-bq*LD1`z(dDAmyt z{$KU~N?4Ib<}uE|wwS@a{h{ffq>d+5gZMwv9$fen5zjHGm)D=2NglX7L^h@p}HbPuNeQqt`uBuaAnxNPNfU4kSZox};>IBmrQ*+y)JAyGSpq z`_7q@q5{BRDlU7YJ((ZvOqMaopsnUQ@ZvN)&XCwN9_aH)sy4LSQ*rrk zO^Rt0I?!7gut%44{)M2-P>BgAa`n8BRc>(r^5&Dl@|_1t^kvhzns%U3_W38uHw-0c@i1#9S$n^?W|1XM`OnI$X=9az7a}T zY*PsV?3CiC8Zb!hd!7^d@i(#^k?{O&Ei>G_UfGkgg1J~O|If}ZHiN9-L4J5DZq?9u zXQFlGYQZ&447hOJ^$Y4O0NOcm|NZ%#vHq824Kq17!1;%sg+mpKD-1MFDt6Fw+~fV^ zIR@&GC6qu6?&%}N=g`spLCgUpYCGmDEEt)Z!QaO=E`6#@d`$*?xK{ooG0Nq0*+^W5 zV2~ud2E#TzdY-Fwm?kf?4CE*v07?&OZlt9Y9050&g&$eZ&6>Q$psv4F_fNu!eaD=6 zGVy-hxp*h+cOhju3lpdlgM-e4sXQ87<7y}!g^wi?<{5$DORn7W2cXmgd=F?#)u2P_gdHzbI5PsFq>t^7)4R_T8uVCYA@6g z-DU1u0c^31-Yss=;02NqmT_-z7VUAWQ4wNQSqYS?S*-Qc!?w7m9*o+vmQ2U zX5q+kcd_DoQi3$(Vu7Uw*Mh(prcngY0sBWtELb3cuF+Y{uM=~Y0^I-$G~vPTwB)=E zq>fDERjT0(Cn*#XNcDeqSO;6fG0^!M90pBbCiU|~4axY}iYIm;z@b&tcq(tz<3t5A zpow1T&q!&m!)Xo^YUl4UT*(qjp5V#w#LK?p?t;3_B>kF?ujTi$*!%F|uGNDr@K(Dh z*PJuAq%X26V2`m<1}mA&&rUaDH^ZL>)uBue?4HSEX6cajL% z`)#wPGha&$L&OzohO}%^XzTs@9yx(42@agPXE6sH_#=x98;ckG@HOIohWw46yQn~D z;K}}pNmtF=ugCJ_555>%CcSxXd=fIY~+`;RytQK&6t4(JI-F8rkNHZ`ZlMJ>B` zNoz-pp}--1=42$VI(4rj;U`?iP|4(4>0gK9YGI~cqHqP)e#S{#fyuwo2~8k>_YF@v zIE!El4q-s^bG@cMp$0vij=_IwBXUkf{0lIQjB@aR^4jZblVcmgsE_9)EhRN7^=@<* zMNAW_Mi>Hcpqah$(5|!@=tZKINiyD%IJ}OfrNN?+{NUA&9E1W4ki9m)y|e7=2p%=h z+F*{k+!TieRWevQ@APMc@$&lUnnX8CO0J170tlQ73wUNwE!m%$`{H=oDyQa1JZC>cq0}`*C8$!Uv;X`}>-ViCw03I` zHb?}83~RJjVW5%i#|VduWCVq&{tl5}p)gR(enwch;ExIj;FXI6&ECbM5(8j#A9_W~ z;2}^Te}kPdPv()Uk zXV;&>0qmvz=ZD~qj;Ufp*S^ym;<#8evp=8>C^dm5Sc__aJwnXyJrwAT$8k6XxD2Rh ztb1}~=SYF$g}pYoB$}`Fl5#wxEVsO!3E+Mu_$Gm;29I>yIP~;{E48v1=pSCXSi--M zB<0)xoV~T_Y8tMSizSzmhQ4Ha=+7-I+20-gjGT!Q?P5Q9>9$(h@DfdTot-Z;hY#X zCk$2#6D@>+Hyue6JOaK>qdLkX_O3$*Up`}1+SOy7SRq8m0kZO$$K%9YTY^G5`{MIwk(1HrVha43&n`RA;YCJiEi5BlEgzoow z(VaaMfc71B^7uT1Y-Ixy7E)dL%c|6sTjoGQTfE{R({K*o#DX~=!x;&YjKmB2}tBg;!XT@l`*Y`RiTl7=zv$08`26U@l!98Do){% zf!nw}I)aQM)*vw0vYi_v$vwuLp&&Y*`}=3~=-T5@YCwej^rWb=*z!eeh`HTV2QL;@*g;4bmXrk}Po(g0U>r zw5__%7`*ZfHl4+XWG9BxS#LD7`O40N%e$+cI52N9r)tw*XLzXXoj0>;NaIvCrncZX!wx2-ppPCsR&kUe0G zw;QB+%&hbl+RWP!z9_AgZ{hjRfY$p1o54CkCEN-d3~`<1cAPugSo(b#v@%u*vUAcc zM1^cSew??$9u`|eUx$IgZ+8&iHgR9{nk%rRwi#}6OeS&9dr{BTP0wvgCLA!ItjK}C zvu5VOLT`-pfkV3V_!2z)U;Po-V-Fw;-*|Q z#jGJ+M@U*vJTwhm15^8i++tUc1T7dIo7`vB&*g}-T?CR&kipSxo zqMdDl=_9%4GF9FV8?{U|HeZp27iwr_3!5P^?A*4P#hl?W(bg|`CPCId)cQ3}V+;Pj z2~|v}EWjY1b_Buc1hqVS=-;8F)3B37!4qC#Z%d|zESQ^$beB>H16J3Nb8M6;nrgs% zdpeQdaUHC8*kXBEu@N0`8@G4r<0Xx?qm7~`;p>EexDIb>AMl2zUI6MNQ3N8@uK-xZ zY#A{Fg|cICT8&J%TzDkdC;*ygu&a{Y$Ff`3J-ctjw=@8a|F<0K(Y(>t1M`2h`i+q^r^h!;}BQqFK*wt z{!WSz0>asc1>ojs704#479-+u5K#tg{8g6d4R^7@m|KQ%JfaL*J-lH^+wWY&xdZEM zJftcD_lyy>08sn|3R<2LxwMdb&DhARN?1;Zjq94+U^Zk!S2hDTn^ z7ovx;b?I3}86<&641(=JB6(q263p<}uxJkQSOvkL$ll8s*?(OjzHze!eSF8H z^zv1IYb>f6Z;*AEPVL_=v^7W(AXXfRhi!uw49}7OXF`QfJ5esH{iuYw>-@!bmbRVG zqeYO&WDhV#Xkkam@B=)QNKU#oKIk=}VhIY`%P>py24@99obb%IFrjs3kS~OHan3oP zoD4DqgiGGzH;^sP7W2JC*1mDC+j7PmZTcb{CkPon3xrvWu37WrSGl~OX$(2pohU*8 zxxKndXci7m34CJ^>GKI&xb~96_4O?%NQNEwJsvLzJ+|cl6?Qag+E`NF`$XM1>on#B zXIcxUhNKHB1&VA(=KH}VGA)pdjTgq=azmH-=0!}K@~^E02A>kBk@eUBBYtgjQCmtB z(qI&|_3Q$*`KDJr89W!eJxKfL61R_GXi@U*$9EF8boOM7G%3V53>?uugv!xyDnsTF zRuLDW2=}M9L~t4@;2=@pn$v>&ogAsZ2Y!51p&|fw zl-IpQhHrXp8LE|?iY4F5$J-G?PU+lY*_FqOkk#O^Rj4M=HQ(t>I6hJeuxTY61%~Fn z6du=1v}z-VCA$aPf$zEaD0Ix#S(!wV6A3FdS>^Xu>=tEKVc=P+!u$FwodtMssiG!_ zXy0d)4=>$n;Y6Ci659ItYG;CV7Vpu9G>gdV=pJ44QwgC87B8~;P)duL(k%R`beQm( zwh>yNjsVxf-k{b(aSz;16K=L0L_nUs9q)}iI2ZRKUNfsrpCYjr;|?|?UP(UkfJ=IS zPUmOB6U+erOH0Cz4&!Eg$)Y!&&XUtsq|?vAwV>Rpsxd}qianKHtj{@vjM#N{9O+DK z=_!V8>AN=B1HvCs{3NLv8}uj4lr}hb-oRb(;GlLJsO_THrGN-^XIv;J>k_7b!%pd_ zUlPeP_vl=wP%1C^vA8-MqTtLlK>FikgcMe6dHVzVca50T$Uy6IokZFeNy^Ppa!pH> zkxD<0<6oRigAR9o_1~#ONShPCu>1CIW5t6u@p)0b5zJCfu4zE3k2UbB#HH@5Hyc^=@4~?5*!+tF%cwMeezO z4eNY%K@wZI5Q5STQnjsbz!cQTK%2%up$~|`3;k&?yf(WvQ6*4(GQ{N^8VCJt;YonJ z5VJc4X@Y^`05m+tS%3%LZic8*Uh9;MM}PrzHT;aeqlTnDFM@o8nq#=n3i>WYL&)`B zpJY=Ehn!waz2r#xnzGqd52ytt!5Mu$mgKd7xwuCCxxd_Qu`t7}^}CB2VDlyr$oFl1 zS7WZemQ%kDn1DEO4%N1WGAk3Lh1J&_bLnMBm>V)7|G)lK@e?ApeOk~v!cs+4WfU- zOL40x)J6J0ynnonJNVaLMaQUHez-R>{JU z>OAE95d!yvC?Lig(}o19{!F4ots1T#ZPX|neq6hVyio>SwGRv~)d z^L&x&s|HgT)GN%66DM4ba;waa<{~Tj)qQ>IvtLT?MrYi3^ICvnk%YMDD;zg&%0r~C zi&`HWVx;YKkm0iuQEm_EwJvxBCXS_)a?c$AB$cu?=6fg1hj%WriYB%BxGo8*XB5ki zi8t&us*@gL6x-k|nGB{|F4k^_R53+^@ z(|jgW`1qy>CTy8&8Yl|=-QSzUT&4uKpT>!{_g1bBM%obx@*~i30{ZeUr?335lql=K ztOlj~0hQyZG$h_B{1$q@M=!9JJDSbgk4Drs;$@7yv$1Q61ti4&GYeSm&!=}NwddYW z({ih9Ctj4r#yD?%{bs2CBV#8VpIrtqw{-`!*{<$>K$)JpYD~j9Mq-J~S`^WC26A-o zb?kpbmPj9a%EkD#xc*7*#5EDJwqamavUEMnWUk(g;YHh;k4PqvUZ^LnNZbW#3(RZ# zL|s68*{rOEFsZOWm1^3JNCGQb=s;lYX$+b^GP!zzgG}tt83j%R@-`4aQyd4pGhi2g zDe$ZYlZ6fr&n6vv0^5aszj#TfI`=5h8@Aw;>{(H}XfRdPFt16no{?CyPRa6EBc0`V z6!AkTPaRa@-+K!cL_HgrE9oIo zPZY~I!3m_k7SFuo`MhZ_tUXR==+g{?ILxpjvO0}1v z6u+MF>SCE*83%R)WcB`fLR2jYUF1%(CZJ7dswJDz2aiXDKM9DITNgPfxPE z?Gjh}`R0F_a^+*Pc!ZPEoE%)!NwxT5(|%wWpP0)R=0CQLXY zFdyvOX}OyrMe9RTcDMkbF7%VWm{$ep?b@nSF(;uO>(LlH9-y1R{XrU)Q8Vxtjfa6{ zm|S~1&t@1lhTcK1@>YdB@Kex%ue1tkUD1nDA<8nc%~r^&9*ag2Er7>z$pzR$-?l-$ zsa;p`QYT5RYwl8gN&4$zp!_R5QD@p>@76s@vs`NIKzk1Vq}`psG=GZG zLX>Xde03hv7J4)kY^s7oBJ00NS)vrZH5l}xqJ8CpDX^j^Om-LJrfzYLGobp;avl(e z{5iw+%xnx3byh9bWgJ}yBMkiN8X1x+ew*svr-%2n1R`dNU0eqfhmf$Zd2(oQ{v;DO zGRH}Kx~p}(8jF8*E=|8Q*08n=4mImXHg3lNd@vKFIVM2@E@2``QuI7;LjU>54^DW} zAQLGFZxugGe-TRjMr{!3r0pMU$ckefgU10g&?PiQis1P^K zV3EfwgGNiz<5a;%qu&{XTA8;w(NNX&)wnAA^~6$%!G6Zf4BsNFXH?q@KAz|ty)?4H z|B4BDO_JLSv`&q6P2nT+9RH372u52%8%P^b-xP~ht7d*8e+Olq|1Ko! zAn5#RrC4&EX<}m}b;n|+)^EUBnJ+N|gM^}XjB{F1Zk&@+QP<@NvPsbFY_ty`8Ek|_4MxhDrLy(`1~{B;q`6=;Y;>k1jATig@1!M> zZ|Y;&vrIVw(vVSckklORleexTrPmt=^(~{ViG98*gdkFcSlRvrRVZ-zX|m`{zU>`~ z00h=1^2r2|1v;Fgu1;oG7M|BQSX6O-HL+0zzUya|wjdINRaI`(@S>k%DR|-F73wP8 z@&bZ<^P&#*Egs`ey#x^S;wK16DijJ2&6uDz#JN=c-etS;?Mwn5DvlNPb$&g=63&6H z?hmCO+~#?UyexZZD3@Q8Q!=lx01~dx!G#jM3(ux%)-u2e>dw`CW;Nm6bxZeyMJ8Mx zqVVVXRZizaafM$86d>%pK_c0BALjF4dKnqZ z)6Jp>u^hn*jw5ZV8TLO4h|%&b`LrobEW{8ln=9FW7oe06fQ&Mvd8if%28~CrF(uwP z`2eTCA!}oSA12uQxWlr47kP{F5Igcz&`4yxOZdJT!R#)Ta}6)FBw!SRRz(*ZGl_NZ^SE(V;gG6e>DCFHTfXpjdggt|9zxcuUHBcvabX^_HfRsve?JT>jB zpmN^5=@B8LU)@G3?nE2B22&I9Ly9E#U%<$_mKGAI$CzI#7tFD46LoXaIza7U;@@>M)+B9*~cUR?RdUgGzsdi9?Ow6i` z2rwfPDf|6`OI@E$DW9nQM=L$T0mbDT^w)E^8nzuXr@{~^i5KgD z-Z81<*Y%Bqb^7F=@qo({UJJimDLNfZ;~16V{+&)Z{DuT8srhM`&(4RziD^Es`v{np zvtIlo|C7=C3iq9O{TV4L#*QI|>C60gyBG2A~q*%Y3hAe+^RJ2B7Kid8-EMvlv$DhT|Ts+sO(4^k;*n^U)|<@_+3H%5^Sd^pny4 z<{z<|1&A2ISNR-XHW; zFxe5t89xzU={ig?JKXIzw#|rwn%HLq3NE8J{2g{=x*%&06~NnfTRbj zvXuQHF0SY*hAZF?qx!L_GL)`LsM&oEV;{7{rBU84{&R_w$oBy34$NR^{xIeXtrff> zF_-!zx(f%-_Q8?;asC6n3691M^L{#&*yH7%US8N?HD!7srdr>BM|lH&Jqn$|YJXgj zFum#MMWg_?IXX19MgfHTE=!Lk_!L)RYhvk;kRn3>W4;aWN7ABiY82a7iNnU4s z{8KQJT;~7()C{NE-exizoL=BJU}oVLuFPtNq+eFkFNDoTI$Mn9AppO?r{NpIv3&Zo zyU5}@Af7&ldw{m!(!AL;U&8|mExPWvyyoWS=`wz|PAxwk*2t)=&{O1kd1=ajW}hbL zzlP<0`Zip4tJRh0&zpuzNAY$llO;VQ^=<=!^2~RH@NgExS#1vwuJ&bZwEs00c1!yD zZk_YK)o3(tPEA;r{DR?Ev*xj`wD)@w*MA(n)cc{|u8!y2NrhaLH4Qeod`#@Me;|Wg zwe`|#zj6Rqo3uij5>5~3b!>7^OLh8y(yV!1o#%R^xNp!9IPSsZOww zKv!imt6=?=`b8p}o$aF_C8C3Pq9DT76#HL`#RdX*Xx>>pHmob!xbkQp{ME@tQ8QO2 zUo{e9;5KWxR9Z?zklrjDz~gG(n^?m|$7`%$iwc0sjL9owZiMJ0u|Y4ht|0ufi69>% z+{247FtVsoLO^upmLW;(_vbJg5&)kLM$(o9as`+IKGWjeIU|cZUNCz@$wKi-!uUxMTyy6W5 zy)-pAxH4v_+p1PV_|^Z6U~FHMR#a^HUuGpwsCmr6`jV>I1TXMGn;l>4<8>K}5*jlU z1?H?&rPA5?-Zl$+*cTtm%%+8t15P%+y)F^Y6MyaE+dYugI{SojbyVk z@xPtVOoBIfKkRTbZP%VPb@+KW?}Q0^+OfAG zECUWHs_4%z$`3}J=$s*B?q_Q&*QB#{&|XbYY?gd;ybR+TihA90_N-6wN%ve^QahRbfrd zj?v^YGzI^1EBQC^Q)NURo)@S>t$D+IYlZ%_%`D&f>-Or z0xqk^z=e_JMrJP`P5I>C|1p>BeE`g%QgBI7d;Pf|`FXqkowWdBdXgrd4$+&F!;?SR zUe-TQjFGaNKSXR6u+h%=gP>c9-(S0Yu?q{96_}} zli@zm%GZ5(_u#A)J08Sqx4E9%wm{QWcq*}9oqjm`TK|8p?lw-=+IZ2vvmS6qhcGPz z&sb!xUa5^@LxqZ%RD?!7J~-H9vOpYx#lwwQUS}nG&hgM(Qb8f1`sqF_77tD4{S<t_WDQ$40%$Zg${cC4J$N#}jc9WmGafWGK!j3F9f!UCrE^+N z9snxOh$>W?m7xNot1~I!Dk?x%a1nN$uniwLYZj_&OpBro`Sj@?l%v0lEf`Fkeh8F? zSofYzulrNbB6fS z)1(6pa9_vB7Y1lcp6&r6+SQBkD<#}TRS?T=$7~t%3E`P9pYiFhiIS$DTbe&HRk@?* z3boP0flR3_gFmMk)lqA?xLJl#l~$rn*p}GZi@KT^iw9-52zi}MsIsRIZvof$3kXO9 zumbZpBg7SF?XGIt+L_YHG5}-Fqlha#ujcQ7G0(53yNY8PpQRb5_sd&CK4qw&&b!Fop@i3Ay5q^xsTmpGnFQdKLFJXZ@cSpxE-ZE>&kqvHeuC29t+_*lRN@oDOgzE+$i- zBV?84Is1E#4zS`{yc8w4>pu(*NTZ?&M{^c_UYoJ`7>`x|ySJaF$bA<8SEvHeqZ476 zCHH2|$z*2F-uo^Q zIG_U9elGa!Ugz6B?z0i&sr$X{tutjf_S2tizpMeeTUr!&M&vNiLeo>7Tffo@4+}{j z#8Z-`6~)RIhB(2K*cQzkDwpnL#ZB(RF`1_!0#b@nr3*u@%0zsFP}yXHm-_^Ky)n9@ z+PZE}pBhv8qMy35_4q8-Ev}9~Gab!W1U_ap@6k0?&1sTB7LA|Mt-3B(<~8*?9_rrp z8@$+cy5ErP2H9;mU-U0ti`*K>aHXKdxou!9`0jGysqSIJY)a+4^;^0mcHDls=>nL%xk-!-I~5h(4M9kjo)p3saLME;A?(^YHT>0$_cHzffG@DMu5^d3}CHs5AS^ zm|s2CA@uz?>18w&&~sTT*pIF2&fSaIH>%KR{4K)qIqqM% zpyy9YVviW&|Guu5rA{yp4$`vw8woJpr3oZZ;Gfb890tLQA^>niSOk;)^k_4B{s`*# zied@WYhJ_n6!|_V6^Qn9HPAc~dU6tPXTA1t$K^T6>wF6*ql?c~X!}iPoHWjwL{CuE zI3(mjJ75?dAnJNOCBaj(w3dbP;GKmb;qP6gzk4mxFPv*GW@b8mycj?l=meW%Si@bQ z^8xc&GHAZB2ZBxP(=wOa%KoeBlg|n=jbY|Eed_!$nXi80DA5#HR%qJccBspKx*(;Oy@R7~v?>SfURRf#uVr4GNdO(wNFFH#?y8leO3TCv zR3VXHNWyn@CR5&3-q=@@Q>7~wgwha5N6;KUeqh6d$PohO@9+gQSKeB-QPXglkls_;D|1kJABK!{VvBK!Kf{8?Be9g+Wx59@s-crYV!$`}CV0L-WlV4F0 zEdsE#uCft*kgsuiu*m{gd>sGs_H7n7o&T+Udk91y=r4;37%`A@a4MIljVJv2uQdAd z30@G^IVl+J*Qh4dFCzSq#7+3Ja?1f_R*MfGKSm~NKjH%D6Q$?#nu;uHG$KNL?^d*+ zIs_B64s0AJRYV}!;*viZEDK^O;XaF;S0fv*5l)N&K#(@|@!3J8IC1&<781&FqsM;{ zIeWxpB$o+7Zu_#tE%$yEYBPkSfqtyuvAgY8jq{pT0vUM%4}?G(((`?hA5%> z7hB12(Z)DD`^K4tWy?gacCf-?r`~7POc~d^2&tOb+Fnlxtq_jLMBUcSuN76*Xj*+KtAiXz(pT1Q4M)ALAw2GFYiKAUqF5XSnvJ}YoqIEufbG0G2=f1 zvSExd#h*_(0U?bVUlN`>Yh$1iXTR%=D>F!js-9kTSy|%vBGhn%+0)aLvHf7W?fmho zN{81(sN)#dX_vu2Z~uph8_(44sO}oGRJeu09Zm7<(G$tGcqxU2ux;^foAs@;EW2EY zLl#@gXD<`~U?5r}{?yyK%ITT9!hA$ACu^{ec_+KiwmC08hhQAIga65Ym|I0|@# zu*#;fnfZwsL};pUIml;RY4cA4>sqwUB+OVdw?u4?i<(XUYT_3*;su&b~`z>qLtNo=LOSfu{^K7_t-rRK=EXy#o}fjwm%_i$k$=$ zKq1>>k-MX=Iw)rpP4*uvJ8u<}sj6RXZ~ol4fBRyyFRjUDpvcmv#K*=>jx=kY{Ud;z zs^cpwIGfucqm4m*HYF4Zcbn2?@t3aBT*_xNl*&5psUYO-SQrP!PPF|Dq!?15?P@a` zAoaTlS)DjzHV0r%Lr&U(!J(r7+hvAqeOQAYExO#lzYb0;kU2>j#8BRU_c5_HxE)!V zEH*S(lO7clOkJ~Fzs0L`q0{e&lTxU>-oiw}EEY-CfCHNRQbnng*U=`5df-rCGxuhJ+pmcDl!oDvEEqBFnfT%DE~zF)$fK`I@DLWTX+Ec zlUz2Zqo`A9K^H=GHMKM!QMP~`q;{7M5wX_JNX#Bm-F{gvLFDq4Co}5We8RLY{4?Y8 zh=MP$Q5kgmeG@Z$j*HX#DXqNAt4~e~x-Q}B^IsdFB-)YwxnZ&7;Y2*BgGA0i6-=&2 zzvN)T=ts#&3r&568(MO#4zX>)j1Yfr4H`3Zn~Xg9IcaF)Ffft#=#T zW>KBv-Y@z{$BbzFu2!qbRu%Je^iZrO{RYq2!ND(xGWZU%odM|n9m$~8`0b2fbteFn zm)OJ3K(w%xw)i`iMw-58VEY?i)qqi%q1OA7& zEGY^M2RXPlH!?Jqes{jAy6M|fUe;9EBFaoj5D*uQ3G+QvwQ222LL_6(^05b%)*=%t ze8{Dv-e%>t73vj4NfFI@!kQXhOb60j<=!aKc;4Iy*bIr%ZDOY9xKo3^BrUCU zq+)iv+Smmm2MJ{{mYrg+q|8M)JDbvDsG|8MwXmYaD{K30vXohDYSx)Ha6D>wZ@GD0 zvn-7X^XaKB!-*_v7{{ortYDhP*0GN{BQv~qQ(E%h?Q3_=c%QQD1Q*ly<%=OgrxO;5 z1!LAma%v@dB4RGvRftX-FAwjrr4^UmQW>o@88mWXLuXoCHH$yQLnF|cu%wvKxi-EV z9>Lk-h?@*riVVu7WiHsQM)pT|` z*)egKkjfJnk8EX!0MKwn%+p|#s**gA~X?4O%FwOc80~77XlodP>OUCaFwL>){syk2F?E4YA zaea4J9)>L`bkrqlm;cT1}UPlwM~{(MiRPug~t&rbZ_5q!txGU zv!sgY>;Um0zeaGz_8emw47Q6VO25`N7B!Z)dCd-^iSi|n6)TTGI!<%*lj;b`G15p3 zxdF?H(|c(96gqwq37y7c|A|}9g<^JlP9_}H_%)*Vyh%hy)DL!6)sSHP;inSNQee~s zLFl1eq3J(bewNYt>pQ9_am=tYD4TpSE$ft0b&k}OU-J+7I^Km<0h#5Hqf3c>!M zY;2TI=P`M#FP-OdX=r%RUS}ScMH7GPh1L!J5kImYf;;m9UboAe#mxeaw zy!UKSmgD)t%&e%Qf`LnCq^}RPH0rQ09q_`9U>Tc5Y6_CVpgLP_;7y;1BCIAoMITn(Hy)>F-e!fuXYOeu>OC{cc3Au@)c0h0`5V!)>mbhY>1o4vZ7G4hFK}qV zTD_N8bd7c!V8i~9!ZF5T(w9gU)`Vy@!RCV*WBK0gh$JB7eawa0#9K&7*?YkApPvG$Ty|B3$5`RP zUuj!BjvIw**d1p>*4o^XY52j#TgF-T{l65^xi`!(1FgDs40$2*` z+N^d$(B7UG$k%k%B#1ior|KrVa9R1IDrD~YVx9p*uFf5-NFhkvX#4%4l#?kht5-~; zwEyRlre%L`@a~B}Sf4@t+vFP2Mhb-$3wxL?u2E>u3O%YHJXj0Yc;bT)u6&dogmAsGD&$ zbyL%F?%dp#%wK9_bF+O|W82S)$3Xmu=WhQ7lg1u6=Hx2O68QK=kP{Cn2Z>)-#>zs`8+HY39c#gN5*kj|h>bo>(}+?&4iKrIUcK$<~Kyu2{3 zmLJsMixE%udrv{pDR$nD8#xFN#1zY({25vI8|{?Oy`o5aPW-l0UUR*K9Qm_!gRw|L zzPUKCfKI_I^-~~sm?aQi2qF}cW9V8W8CJ2CkSj#ou>6}&kZFwld|-UE##hetw!@Vu zV~H20?zrhy_tEq$>XmMlO_G)w#N+auPw~0p>{lO}6elg1>}I>Z5iIy+YAxi`Fq?o)CqD=Q z;T@2}T6|-xN>Y%SZ}5~q>-F0#Scpt%Q(F#}%FS8?jq$ZKcn`kTR%*OJ(BOrvQT#7* zaF_7zxKEi$**etzloZ4os*O96Nr2lM38cjdbs)0alyNWM1ALu+)mfJn+V5r9Lcsoc zJ{jcL;w$$vc<^3^)eLZ-fE%WFM;Q#*?B3dBaUY5j`Hn%X9Hi*tDZu+ei*c$P()y0V z?ra~BFb3U$x26?Qes%W@>}b^a+lpK7ewdmTXrS?uO_ew%r=0F8V}jmADWi}4yon(H z61isyFBFFB;jL11iMikUx~1N>&m^N+i&nsP;rYd3pkjX1Qm!HyI|w+qlOT+|HXhmU zY?=q!aJALci+-$5yAfaB$U{~zsN-M5d!j#Ecx>)$NU_lx?@t{v1pp;Zhsd;%p}&bu zJWLYR>&LIR!I*uO-g%e2yO@9iHGkbC-=-g9~6feP~vRvh_A`S(`DfAq;SQcJ1o zJ@m%8MhtzvaL$CT#ClpJ7$qf7c~aNEGSD+t{~F{^aik~p160HEeYTALc4>;d8)oJXKanBCr` zt^fo~5rc%{4}B}YXkqrSm!1s|AjTb0{shZIG`F90(0v3NR>lud8rf(<;IC!miIc$4 znlx&=6qu3$gyFX#igEq#nAL`jXKa!%ktqmD+>Pw;Lcpe>19xpM`}0#fvJf>1aRd=B zr)fly2X=1)NfzNbN8CQW|MCS(?B=+wjY__@7pEGxazzLL0BeHd@^7;Vy~ltRoI7Ib6Q@%S}N4C@5}`#J!R_vZRp4H|D^X(g7p zt~d0RkQ)c0)v$Ng8eZTQz3>miQhY`dr#*Q(Ey|r@eXfrk9#08%ckLORmkIl3?&PM# z3&fMmmrb6lPBnWv^b>!R*v_$IlAk^k%xMX|%iPH@^Idyf3m_zn4}AOpPlDp_7*ngF zrK>j*gds2Rk`U_aSNiBq0(^e1o1CxGjdTFVRM44%)ey-E$?>GWF1uVX~E+@;}xw%aIp#!YlWpg4ma|MkH=z728j$3#~8DL=&2$rG)#z1ah3x!4CW9c0BL z@pvspjcG%o2um#<4seroU6mC`%smH^)nFFdmxdD-wd_m1+>kwq(A?DL{Z6Shih5*O#e-(-C&|W8&mQ-4BAh>dU)rL zrmuhP`ju^<+;C8nPX#WXA(ue%xj8R!*N~<#s9C_)YqV7r=2s{maZGf5%JI zN>P^29@HV?_RuW5v61sh=216ktqAj#A{Q_?iASQe&+gYclJ-qOLgW$x`9}jK&8yM*vY$Rei1*1tW&{z&y&# z@`*76x*ZuvIl@a9hiYD~GEQw8c8s=^G-)0>^4l`1xeT*sW22(kk&V(cyW>6zTlx`5 zJ>+e-O`)(Jr;!N7(Pg+(=f7{4vx*6@uM^MwpwB_Q2@RbjVF735l&4S52uOvAzTCg= znI`Q}Lkd-2Box{X!m^`JAG&=$G;Zq(fxZl!Si_9ryc(#C>tB*&)Pt}xRp6v&t zVay{I%O_RJugI#cqzv4cxvaZ*cC}av1lOJ@ob%Lyl65M$ng&<4@SAw8k7(&q#2IDc zL}}zW)QU^!CMF|lterzOB1G7CFmQ~Z!&>#1`_;Z{-3D+3Y$;QM^1nPzeArZMg+j(?d<8NDz0aD-r_ZdSIFv8 zJmO8&U%;>Y6#>n5N}k*6_`y#VK(e2l^u7k`c{VnoiDJ9`biN+FHA7#gBxksYJ8!qj z^@zgyU3_o)Q`PlYYz+Mb>WMY()}PW_Z75dr|I!Armo$x<+iM~2G3-6kJXZ+CQE$W-(k5%p8c+s8^j0z^KY-S7{puy z{y*^p0c#Qbuh&295B*vC$}F+@YuweFy$>S5*g9XH{}fk4-i|x%=XNwV(r^04Iv;lH zR2*!{&O?Wh9fyZqRpKqHQejKhXH#vgRG4hjVpIZd)%_OS z!n}8hKQ9WxYkQiX2x5CJ0r$;U7qJu(nw}Ik2cot(WFoF^^8^W#32-VvGQqMh0LO1O z$#zVuC;-v-u-z2cdbd|?UGxlg_qVh2SbFT8?fp(~KFNci_2RG-#?w74kKc|ar0y?s zFXBGOqci2?8|}4RAYz-1mz(5cR|!BP;TgxnnTo;*llAhg$;T%MO@6tWw_{KuN(Q_C zsh5XB%pI)%-`Kz>OZ+WFk*UAiSs&W~fbUODwu`mAu)OJ>ne3b21GD{_Jyno$#PR$_ z8S=&NfQ`g4AHc1qQ|ZJfQGkk-uXF@XlfnDZJ~!zcHoMzQS@TzadN-b}4g+wyi)?N> zjm|su=bHzjD{V|BtaLpO%Qtl2@>_NTLOZzluiV&~+~N#fjd7Tdy^c_mHuWuQoTxuh zVKIA(nRMJN?%jqE%m->tf0meBsJug#KPFw-4`e+1Nyodx^Y&P|VneyIj52l@^i<0z z`u2++`mf`gHh8mAn$6xhp!AuR_jHNOBmTva)%(nX*sW$=J9U8v;f0eFmhaG4<%~rV zi>vQ(g|`-^_3e_%5fc!kfe(lvWP9w{c-g$+`n-Di=cu$;uJx@`W-to3!*wW^)jZOp zEk3EEC^+K2XZtPWje5?H<86}3XKI{-jM}ySjQYOq31PnWurCvO#Qbu8t)+FhXW(Q{ zp_UfTb#15Y?si&V<;T-Ml@-@OB!`%9pBno9zRw2a_$V-O*ep%CUM!M+;j`-P5Jv%i zo42(M-YV%lRGw;yn5%d6*UJ%_gXV{s`=Gd@fK!%aN2 zwGV6$7Uq7JL_oVLpFq}UBZ%r{x{oRhsh*jdXLtLQrfT=Y!erXL(;_3gP-b+;yD3d{ zG+w1F4CNSn<(j^EBk0;L$0}vEU6S28XRC9fcN^X8l_@U}H*&M6w6avy)Z0)*w^Tc! zd}<0U9O&-Qt@87*HNJKR)EJ3J6FPAj$=;$P2Nj~hl?lKGNbybk&ekU^2S@X@(I~PS z&&#PtTP(L16-{(AkAWze_%)6Pv9t<_Ggxvx{1js9C;82Q))>-1U|= z9q`>oxzq^>Pv%KbUH(SFl*v>pVI)fk(XuX;Y8257dDZkl*0y+}j0-PbK6GeCsq66~ zRr!I~-CXE2hHgJ=?%g(65lfZq|G5BeQHHLZH6uN{?(^&IB$COP;X1AtQqKB)g1}_` zw3N|M`9Wl*K?2jfbiz=_Q>~&3=bfN+q#v+7iU7beTo2SzC|;fqS7WC;R=;79QS7^G zS>a1MSJpRA^S=}e69;A{?0Y?gG6G<}zr9vWCkyqCsv;-=!^o98nYGDg`@w`7jHqnS z$6t=>XLa>&2cbq1k(thqiCi1rws&wc8>`cU0ZlgEMq)-?;dU8M0`WspnT}^JToXq# zf7g5EYzRX9#<3QNHad4U!mV!Uy&fl8NwFutrN-e`r6ig4F+fgGQaHaOcHO{`0CcXKE$1}?M0O6)GE7r=`)n; zxm&D8Gqs%zB9jm__yaY*YGO9%5@mL%TGjI1Kr0&&$E;E_6u= zXRvf4z-jQIyJud@Q0WQ{?xK{2VCNU1_n*J9K2O&XqIrz#dw+68CV9`FEjH40m6T{c zI4NPmhXQ}U7|f3nRLWfKL$ zkB~mba6iJ#G~VzKf~1ypRJJ=wmjrQdz+PvW`**t&yaLbrM&~C{fBGZ{XiX`Sqch+o z&w$@aN)+r1vQZ}4RTe#1WieMN8NwgwL!KO$i8A(r#`~o$_+CvsN8hYzY$${*V@<#gZ&(Na}cM+B%05ALmp7n4@|q znKUo?6;n(oCqWS$M6xX#2c5XagI0@D$OkIWFtm4vD(G!&T&a8}=`mg6e)9Hdw?ygp&kr}7g+G$C7d}NTbEOyLu?s)XG4t!y8+k*8QdC34Y*zN=L zNEsL;L2Q74GRGJ+m#t&=^;ymFy(7KtnpBexd1}ZNqq6cA9n%%u-`iV#YE09lMEr26 zniNcwAJn82h5UXNp?)hv4w1kMrQ|$DCADUlZqj607pl7ry)vI4%}i1h-$= zS<{$Y{&qC1G=B5s^39iMUEOm;Ev%x06zO{kDfH4YFt8}@WNe(9-Rz@aiwZW@FtiC- z1^S7P3HA`&WDo=as9}g?0{@$;55a-*eIk}g#09y8*7W-L5cm_k@TPr1UVBArSzck` z7fLCOrc~;^2-a(J))X9C^gca?0x8|n&*=-PtnkqT)lo+bk+iiczFDiuGlTlK>Jmdr z6y!+C2VtpH>^9yj#QQ&3Vl#JfNi(i6_nBegLm;l9ftKR#qBVKF#G!-BE3m2Q!4#n! zP4!8M4|H^6-?ZSSG*GO#yy%mD{KZ+dj3l_j^!zHX^Xg`5A+@N|@Bg0vOnsG{pngxj zS@H*sFE3>de=~z^5Z9TjAM6qKC z*L~?4V>zcT)U6&mXF~p&A58RD9-dr4Nk(t3U?!NJN#znZg6Ag`3fmzN!j$svBL)CM zk9VV(osJTamcIIiwb|spcUHobmXU#R*V1C1KV1J1*ALzEINM7xW2^y!CGD$vx@UG4 z$RUQV3|SdYQd@ETc~dxmki3JtC-PYP&E@n`aU}eqA?8|MM+n&ddh|a)P@8tIU$i3X zQ#n|oPDm_Ti)>_Ing_RfK}Buv)6c%j%F|4yWLzWuYzbih$%u&u%?uHehiLTVzCndb zllh=wjUWk0dd3G`mh?j-7c+9g{^%S-DB>;@fu=SulG|&c6@z{9y9{FIk@5##AGQ$8^7FTN|m1Fu858tW9~+h2DM$V~7{$J8xAQNr}*S0Dk#IbwQ8vh3Y> zg7;}|9vv`-R8>`dZ{~i?H*$tQN70y+50QIz7_#H#C%9ft3>wCf_*1_}*v}$Oo0vIT z`bNwu8wC}Ln&*vy(Q!>J_}G*fG_fX{J$k3EShZ*lqpj4;T?_0z`$R__*%z{1&jgR3 zPGEy475m?`2xTRmXAeJip94YBti{ z9ZVVRd9XoJJdZ}1)T+N-sanZcdAbmVigxJQaTA<6_n-hsq~fT<5SpkGTj!L*v7QV> zrgcG>nl8J2eU4T)!CvFg)JHOKQU(S!@R0-|#njJI2~rmZo0#0X^F<7}6&IGE10dTD zGwdoNy{fPY0wvJ}F=H9wWEAifHdT%m@c!?G&(}Llb==n{1YUb)XXG!EA9}no(r{~1 zM3F+sIGz#)PVHCb3_W02YJLk#gv=kytl`_}NHo+e)c#h%NMFL6xf1`?66IEQOAl#+ z3Ib=8V0sGCzLR(qr<@{uk&KYR^dGx5&8mOWOEipt12mwXqC~(^07!}lk~Soq0H_(i zznAhzhmyqDb2pr`z5}4XvAt9{naaZXXRkXb@Hy%U<0}bAWwUBx1}ExO#8D~TpoL2X zurWUJ)ubdS+m0#(p-WSf1`#=pcUx7po?Mp}GwNMT{fNXD$^DgomsgVl3Nz#;z=DU< zbUdP=63?&UuaPI68-$y_5<5a@JI=D@q?!Y$yIvAsL6q3BBAM53~ z(a4N})HWt|a8U1uX15T`g6FX7?!kEbt-{}|krG%;sVw2m2>dZfYE2THD4A$?o``sM z7G=!z*zEjxuxBD~%~1m;0_ApkXwo#~Y9FuL##?}A(1eID)Eko8!J zZHfMSNnnm^jV9Q%@znZjju@@Agd=!AjM&;3TLK8rlI)Y$^H-9ag-9L;pl~}f`#Ns( zNjPYxnb=8qMrlb{hnr1<-jr<^Z$ASL3w~-QS3lsZy1Jgn!wK^C5UoPKmdl)~x-P51 zb8|nJV$8&E@pW3hn%)`{%U#D`r$O3DDrm~I@10jurt-xjpkk2yX_IrC)q2g_k;W`J z9HTrzWSB@*?lURj5)w~DLf(Vh72EY+4l`p{dT=4c4~Lapc8kpqyXv|EjysZhbN%IF zu7)nG;aJK>Xbft3u6da&ZrQ@gzqLm+)7P{sp6amu?HQbTgw`+h|aGQ3fWWhr@~2Du5qC*9#% zU9~Y2X=><$Ejl!)oh+AO|L{b56TN1m^-AOEe5>ZGvNRG%xT54OZuT7ZatzY80FhugvbGr?%%Pf-D9=dMBJ=!YnkE(I+JI zVqb}6L-{&`&{1W7A#R=YBM(<7#+rI>ryn^~Nb0pmzZ)ue5Tzs7wu_$_q*`i~#UX{~ zrU1UBVkhYRnEB|GfDWF!fQENp+sWh2_l2Dbg_!^AQ!J(vwc=KPaCT3 z+_mAf6-pIBEO1s{Jq>_IpWBE1v80YR zyAA$2LOIjg#NYRs-)6e}-n3pfA}1yo3|Hy;EQsK!bCwhmHn0Hzvmo@m3f8$i+*w}O zc}Ny#UOZnS8JZS7JPbc`A#YO=0=5Z7-C(6m3@-f-4ux2E zsq*c<@~>s>l!QjyF4nSgg((5N<<&vlNve*$6n(!{^INr=K0tW=7GrXmCbPS2Up(_Q zP>b~BGFC0_h}L*-Xm+GoR$6ew!CHi~gR&n}XgI_7Vgzazw?YW35u2h`ER4r`FkUfC>zT)^pbcy&+A_240@f@e%C>_s4HgbpWTZ<8#80c* z&RaI$3E=R#$7DupEU@@v44^o8u`1ywx@1i-Ffb5%|J}@^kU9E91lp@vN8ga4KbuWk z^SXV0A%08aHh7*>*O|h?boxQ4+CNo>G2dzK3GEn#rc**DZZB41$I^(6;|qe#lg5hc znJMWZ0W9*Rfyhb9v_F+r+C9c5&zha)#jtf<<`O9=C`OTa-OdP@_5B}18q^UpPwt6)|cAch)FLdEKKvjM2vk09Pkpi`?Q z)j1Xg2c?3qQY9M&$3`P)c`5|=tR4(;YcdNJYuZO!1a!S#A`8zuy(Z=s##_yctYuyu z=Z?6}Thx6-e>JjD36CvhTp*K5G2$jrJAw(2p;V2zUapO0&ANCNsn0VPi>pa6dnj+T zptA&)#=jFydXJMb*gEc;B}(~UC3LOrJ}64V2fP=4HZE=rc0yESW>p?n%+6Iu{I`-j zz`jroVjaeZjb>Hutb&P;+{s9!e6c*Of+UW@{B33`0m?92ZB#-)a}r<%m)$y)d1Tvp zSj1u^u0jhBdwR9^Vl)tj#r4*eE*?!JSF-DgD7fxt!AnU~vg5ewpcy8mXQy%Z*xS_k zU|QY}f+bOeiF0B}8!@Tlrn=0uU8>y;rOIBeG2+b?l_YpLj#oHctqD{njo$?`>sW*Y4dD0=hI}T%k9!@E$aZv! z5`ZS0maawQdml~5uh9d>h8NO|8KF6!E_pWpdg=aNN+gWY^{WpxkBU+6pI^}@ z^<2l2qWkGYQG4Y4W852tA9;5CZx8$W3Yz&7;CVkQX&Z%}a)2 zSiRg~kmhwCHfjGhTHhv8A%D1}Jt)_!0kzE`v#gR05J#(3nWMoD#>lZvAl)kH#k*$l`HGbTq1Q82&Loihd)|^;;(DhNpPc=}Ul)9IP z69V#Lut?!}6HXY8zEbodP1NGC;gil)O$YPKB7gsL5z?dIT+pA6!Anw@z(P5Y5?q=N z6sGZhT#{Zg(zf~P`t7a8JVHwQ8fo@5OCmW$+-TFjfB))pUIz#Hfc1>}$hcPD@ADQU zwQ^J8p;)({V&JHP#KR99KX=nJvwtjv(cS1@bVQ9VBY+jrcn(MFz61#!w}1jha+`@zy$z}Dhm4dFEEX1KTUZ?HQ7sUb@xc=O1xZ0Tjp5B zo$8PV*B|#kpd$$ZL6I!|Tfr#fbDp$Q&kE3|Ok;}~+camVlsY+>nwM`z1EXe9 zhh6sOBk`oBXnq>NB^0V{ZWo=3Adz(afqiNoKMmCP#xqbXkQT&5P{IUwXTHtFfe>Ix zA(|GYR)c@^nK8qq5Kb|b7Q=8OR^Qk%_H?}4PV?-i^r9O&v|rSZQ?Q`yBmeQ>u%Y%G&@&G5d#Lb`UmV%3pAE!$vUJQnM1#3)-?Bqy>_^2fNtN9lU{!l$%uECh~ z&p2NKHr($x08&^JSuJUY#T2I~5+q+g3lgBYmDSyo3|oYG56%bp?-_v`dbARn;;ZW8 z$ZizxFuX?tU9}M|<;XPp1?{hCnnoQw)97}K9rPa5vNgY))xX-@e)zXb7qP}c1KfS8 zAV*7}NMc2@`G^v0Gg%RM+tOgIh6yJ?z$=C6Mr-+l`R`Oz0^&y4~oEG!J}+-20mWUJZK z%uRxXG9Omi4E%>QgD|%0IH!o%uhr!rZWlvY;_^=E%iTMOz!V15n5gjIiK2&&vBZ?v zoh6b$G`8DS3(liV-4kWc`W?`D=v}M=jQRKg;b^!~q@}I*skfFtxBxi8lA!oaB z%ec3z@1>Vs zk=ioO_~9cxj@yQ)F30b-c$vTMV{Jw>u)vDrj!x?5==u?*`Ds*-ft3KW&!q$jdLIJ| zkDm-|Cl()F8kcVNHwUoET09*#54;Emww;;?>Y`IzzVsD^$YBbFDbivHoB!lB84|kc ztR8<#Gx#k(JJpXYMFs=dqBQ+FE~!Lo>LZBK5TLmhoS@G9ITu1m^Qn(*0@Ib>oclPX z?Ga#b(5&1aAFma>n5{LQ7Qg8bo@l9i!vrsMIbpqN4y;3J3MLsSO}Vkrq40|bagYWn zhYT^C!>Uvc%27m4!DC5L(M=g<4xWse{DaGWuf|GZ(!tX>CoFpBEjiQKp)&s}L;pj7 zwAtOWC#!Q1@7Wk!P5$mvhrNOYbRZISBU1m?;cELnq8))=d$)VFtvhPD5i)0Xf7&wL zaMs9m{I?t$RGN>tKStCrMigfy;s!s1S@GscmG3_YLDBwOU1@7r#r;dt)fSnq)Gg;R z;`-mo+wvVM89ZyP%JO*o#n<1ah`D5pZAyk2X6s&#S?C$w7`Sd<;wIK33oangbKXiZ zBrv&Z@Et2tA5UY0W-oq(g5MV*WCzQS6cGX;u zVbj2lH|E-cbSnvMIn314zA3kcTuc(g^3h*?(0~H%`6vQziX-=rx7dP)@ z<1#&wj*aa;sy9}XRta3Jo;^buiDRW^0Fr((_F6eOHOqL-RSY=peZj3)B6B>EFg2F+ zjtMT6=^z{{lcyz)(_wxWH-_P<(e;6J1aJFtqFSA7#!fE#S>NgKYqS08+w3$gTv=9+ zC|g1aV8L&b+LJF8iz!&qCL%xC(HCY`JqK)^!kH2DgY5sn`#V5b4vCeo!!$Qo-19Jm z`J!Z3nP}H%tb^`YS5D1XRQD(8ZJP%cAIWr9<;{xuXI>?#^olh*a7_)HR8YC^9^~_n zv`fR-i30Mk)_9=v?OZEU`7Oo)M|FjkVXm)zke^6ZOGn8z_;p>ht48+eU9`P0-zV0K z)^-C-i8%oUY?yVXSJ|6kY%NR|{qo-`1pgI(BP>D6!u};@yQoHNT$r^-8n`(hEKDjr zrGDb#14wG7qFHh@RS^plHyt)?55BZ#4r`Kf+y9B+B3M>xH1WvR|8QgqN)OXqgP}Jp zq0rsP5f{HixMqZ+*CUc<$HHT!?=%p&5lIsyS|7FI^P-%9yx>zv2 zNL@7_wC8>&{Q@Eg+B2oWF|0wlbI&c4E-A?q>{cJ8oT$=ySdEcP=W0_Gl=1w8zeM%R z$4G}ccEx@edf{g0%cQQ=m*++xfaZ(qqg$8PCNkV8a=q`(U0e2JE0&AO6t(G7dWrGA z$>3JiI1W9uvV&$0pPlG*$%(=_nA7}cm67c0_xB9 zKc;Im9oY(R6deT^{}G%lVd)GI2{JGvSggI*=on_e_nc2umlbf(a3dD1_jsG1+;HD( z_$V>+0sj=&`2ysgss8_500u79Z_ej?tvMiKgWRIPcc=uYtN_6I?RK^vZxF0&ew zvxnAR7Tlj@9nFhWRcxDI!Y+QQcU+v5KP^j{bmaz0S^XZMr>lXJA$og3e=y5kDVb7b zD1WVSc9DZmH-V<>r##%Pp2-$XH&`zVFNWLwy>vfIqQ|i!!rsZfcWlw`PU@$dBb({| zo%azkkJTW(&UeAIn6*#?K3)+wk2g*dZA0(-khta6;l|7!<(hLH0s3Qp`y-n>8zRCe$WwsJ`NZ4g3VwQ_XzB}7huwN10I-tP`qFc= z#`ti$R$T3Mu>ZD3NNO1Rj&|~~t1A-$pxE{I6Vs>G8^35x-^uFF^H2o=0r%5F7uaeA z&W*2$5W%H~##EDf>`AS$dHK?{&*#Kokbrb)?*IP%$*#uw0Lm)_S2@+g_1lQma zAh;7GxVr>*_aMRDHMkSpeQ;-RclTjnCwcdGzVDpfv-~+Thkl;!>gs!|ZdEgUu&l!? zyhQP>4Z#rq#99;%IE_}Oi-88|;f{`bJ?|x2zD2-#e)hRPSrx&(_WZQ|tT2;PlXZzL z{i!q<-^j38En*;go~t{2TOoVWYGPRwGtoZx#b_Y=BjDbsWJ5pZIdt zy>Kt)mw?iFbEjq2g@?QcF(V~nzm22!acDiizn@rtpCq(g|NNUq&^7`FdP5#ei$zDx z>8KfrjyB%gFs{#VgC}v+F@)bBYBrRx-&vhxV-_oK!TF2E$mo`OIXM9$(@EngZ{9&+ zT4)k-G0fo{HM+oukE^k8jAtLVuKXqp#SEc@L(lh|p!nO}(W!vTMMja&`5pKU4^P`( z3FLF3J{fqc6du^;C$;Pu`$6ylbX`VK{JfDXao&7-WG8o`k|#*{$9J>^HGQG#U&wAg@7mcSXF5C*+#c@C$$)N{yt66N?Uv@ev=a6er|ql{!9pm z?az{im8g!5j*X3uw-vOEGnvzR11h4U1tk2F(19%#FpB%Wr~*x6Kj;*s%*G9%l@bWv z1j>wTo9=2Pqkq-qkRSc*sUbf)@#!m_v$8rJG*Dw{BAL>AbN&k{>XoLGVLm9?*^n$h z;tm+=xwZm$a~wdwpj;6 z$67Q9_-kMDi_cqj&k*#qL4rHaTE6#_I&~6B$AKH-cU6$0RX%$+1x)^bin2y)+lc;; z7rAol_2ICg_cz?hV8(R8mEq6~u9k>^rt=5#tiZCyMi%Rw;F1&=KUQ2{`d*EsrwYwa z<2`e{BP<2zTw4c!plO7`#2<3(T}upu1Y$D71`EszzhkcgBG_J4L zz+6btz^fs)$R@k}HU`1xHa!@%cf9lN*&lu@nf=Vijeox7N57iIA^~=3dpk|!Z|et3 z(fA<7db0O*EheKnWiw*zmc+&L0Tf*)r&;~de;v4#0*6%*I{Q?{owhZ1L93uaY>L@y z$Vf?FG)bbMX9WZ}BM<9$cjuznbaMusuCT_pg|vaN>7S&4(`Z0ZXTnC3kh8#2@zd-e z5~Gl083b2OadHoH&rDSw580RpDZ_~$`6Go;?UxdO1X(<{V8u*}rRM*TiwV5j>!G4k z3qhSbt~LMKB~X!PRkP)b^TsXbY!*Sa^&XRJGa zY`y;Fu#|*kon6n{dGFzJ8zu+R6=xJ=;;OR`BHSK}`m&t{^Hg?&n&rOA@nb(}^_CG+ zdCeEvkEF;UyTMb8$-SDVyzhOK^;|y}CIH)(-SA5HZlo)bYdi=2pRS zIOlgV@Mxy`<`639BAfr?-RX2kfHz+9s84#R=Ipdu?5R99{!c>YbsoHMhn=>jJBQI* zq?r?;+0L}H($l~0ov4y?G!L1p^Q5R9m50aIf|g$~L3Ia&A8~{UPZFmK)JGzu)yBrr zsI8E-9Fnvd!*?v642MKN6LEWv2&2`6>b(r_43olil}-r0Tu(p(n${z{yTMr{7*y^4 zsmkU^s+rC&t`onn0Kz3d_S4i~Zk7>3sg_KK40o}a+g<{su`HJF*4prJtx+8S8@a^D zFE<(oN26wI7j?>rL>iqJm&t3DNREL2t1?Vk*XRGIAFCyK>)8FtRNRK!!Ej*Zqj=8j zyy_b}3~QWNTnyC)?1OSf~uyN=R|xLo}1tY8Vs2+lrJ#G}F$(A$M+6yi5X@ z6faMZt&4!Ij+w`<)!mzAW}2w7S|*`F%pRXz?iL@Q+h6S&2Rzmn#K|B9Hgagn)gsh| zn#_LOpXGE&3IL#;BOKpJ8JnFlF+P{8B|}7S=fCjZ|4xHq87(%1*Kn@%MY8(*RbcU{ z`}68(*(b5sz4gG$JEE0Nl;_@}iPR4omtAT3_#gAD-`AxJaLtME)sSDyK~i)SsPC<4 zq7{}m*>!#^fOlK33gtnt(#BAlQlM00gieYdinS$d&QHjdD~1+d`1xzfsU{n!JNwyv zBgpvtqmyPT>Bw^{=n2rp+P;m@X&ZDz;!a=tlp%0Geht3b%Oridu5ooS%Ie%w13pL- z{{w5^nV{aOhh4NVY1@AffS(*@tsvi4NGZ%?Ms=3k4ZtVALdTKSrPQJg>H(D+Z;5PD zPkAHzIB&nP&gm~snd9-P{#(|EQBdu~4p8!9RlPGEfrf+0b0fn|_p{|;7i(#cGgB@x z$;44Ot+tks%l|>Hu=MXdOa}f#LXJ#Y$=Ho%yXvBIW#b?k-yqnWUd8|_a>MukkeL#a zxhH`xOdOZHRihP69vk*T=LeB@m=WKgDwY7Uw^0nv*lWXF9P~6@;U~eeh%`Yo&slH9 z@y(~rjXto!y|42Wn5W$hU#h|lSFVi{__)efE2vD{8#;3gJo0w#iD#VOj}zK;{eqg_ z5HGtNlUabmv_?IGTrfi>b+n|`OBtlNOdg$B^5MuP@CxZjsE~pEGzf$kxBrJoXIx1i zwa!VzJ#kdd8;1L}c~QQamR{vlC4zI2qe{4a?Ilm<~+WyLKbQL9e(2=i7gbx*I)(2ghEe~IbU;K6*m*IqQ z)VAZzf_&lB>V7L5bE)--bNNJOz2k^Q=sPF39{eOLOu;za4miR!tn{BWw>fh)IUUf5 z+_v;=ujdqUC7ihGG6@H;YN}%i_v&X9fON+rvJL5pc0U?$gO4W^`1bWa>}#zjI|K7G z`0>(rkIzSW`QtSqpk|U@yP7}Db?FT%NG?xq)Vm-evy)k^5XkDDvCp|Q zAJ7#G-wsEQ)N0FHmIc8AE~rRN3U_7V3X(ddx9izs%vXy3QflIVlo}8gg4TER65kxf zvx}md;}}SZozRe1s|RaJlb%@BNV?cY*Lyv4k@UlOr`G2`L5oK-DBYzN;iM@xc~)R>RZp=$p~}oFVq>(I;b`i6$BHxdcJ`8p$E-L* zZicCC1ODQf4k24+1YD~!aX7r2a8yTDN()*D{{lB`o)P&v`o4~JKjX*O-yT)lE@0-y5zIaSA6mW#c@BC zjjMu6;+ur*ou$lxkyPDr%oJ{n`;nZ4kt{-uImv}@z>v*fCcW&?yn1j!6AzNX7@|EO zT9ZT0gn%2pDRje;F2@pBz*h5u*>_r~)n>)2pMg2fldBr%_f9pAdz1LFX4h>v(h=|# zX*YaWXS=27`zpan(|o6?l3)tLZyb!jw6^gh_S~LrPQp+nu#_i?nm4e`H)+I5(Hn2X zo{1p`bvycOp#sc?oT9JOAqe>mDfVX?bVA z>4j09jxF7JPdQQXY2S(4hg~!9W6N|5MqjvbG|gFju{Bx{#wQ7tJvcuMrq4WI+~SbR zDaCWn`Z1;KJVp0`osmBVc0ax`i)0@Vk4<`DNg<9C@6SlkXFgPyZ^kZeO(47w!GmS< zZOI;^mwMz{H+0R)m&{v z5ZC$7GK<{Eq@`%&c**0+{Po((pM}q<;D+grY{PzPz=^~SAV`W;OaJ*^DuFVP!d5YA zseu%qXs}F%I+AUwBDuS}gGzRx@#sx^adB~I*K$7N z@$=WKAy_SJ`e`M zEupg{_iK`7gD>t^2ZubS^X&=~;JN1ZU=%8^lEV#u*w8?Hq}(!E)50d&QN`N{=rIG$ z6xSKg4|>W)>*cN%ev*niWTM6fGK`o;5`Nx;UPG^@sd@NxN+PfH&Q3Yf*p!|IvTa^(DOSuNo&|N4ENlONit|`9uH0 z&?4mL63Vadh+(w;j7|PJ3i7qk?%gm#(D7Yuh!MO~UpOD%uCc?%9q~vKE%;qAQ7;n> z-ng7&Bm#8t?PMSi_MyG)FFh-KBa8!ueOcVsNAyKmUtL|{{M^7<4ojTLS%P`361*+0 z3gz14x=Os4|Mz;mmCF?uoaJEHhMCE8Y4^(e-2$-zj~~qlo(1Y`;ex zny_gnCk0eWp?Pcl5V#~<@nGjS;*~UTf3{Y4uQ$QJfW)4&yNmQ4YJEz@?Fn!_w{mG;{(HwKMkKIqg_G^4PRM} zB%I2$u4cD=AFtQ(%A@7mwxYAgdFRXGRd=^LuV+ZJbRc6rzR;`0h45_Od2HdmUxOCU z%{s1>SiS=Y(}zSKVuBicd~pa(Md(b6nKjGR^EOzL;@3D;&P@`*oF0NY3gk;%4(fJC zc7tZn@c)}(=6J@nPgXv2&l_QK>5b2uu@YL}zw?T)YL%ka<~lBJG!j=9q#Bv- zE-k)V=IiZoc!WyW`EqgEfBb#7v--|PZ@${UII=PipvY!F4QM`k) z@(sG^t3r$$w=7kRfF^0^JXXu^_2CKkwAdS#f5>kIF{@YjoKz`hQ-Yy~%htHQ(RJ>N?Yw&%C#EO(dNXxF53R-G32Xhdu5Jh)-S`;&qNyXiI(H#IvNqebYhSLDS}m3bSdd_H0O8>JPDw8ig` zF&QX+VSlq_97o#4L4-w%k35@uzy-KWo7#n+`QGY-BiulhWu zF2b{Q>eWSUrgcJlIOi=x`%fHe(9as>3z9d!czr?Km#%O5lU0=PrIs&rkFv`j`wbCp z$K!aM$tYB9@bkV0KW2bRnosxpfFT2xi8uI;j=754?IP!p#zM*jN$ic%c%T+uh54WJ zwlXHGY_bX;r#Z}U^`=lMDRHD9DPzIxXthf^5LPrIKjr-4;09amnr~0tQ1ZPnD~qqP zbg>&k9XX|~ubBQ6qKF0%)_B(IA-dJ5gR2WZwy@lE-uN$EFt!%^Kf!`q(a)g#%tuIo z`&qs08aZ^=;J`fno?~!s6Fj%G6wo&^_ugy9Tg_Y{*!I`2_q^Bb4UI9ki30NmQPvpr zhUoY_zTX~9XK!z2@2KfCl)711tlet{=0x?;K3WUy2-N}}BF{W}SXXd?ISw|KL& zv(Q%8qOnt}EzV^X6*g8@$f3QpNGp=8p3j0|A$ddqjqByNc3vFq*0&M}9|0Q-IHvXG zD>CGhjh9jHqg-{owpR|0vuEQ(RG3reiVY5D(wDJDIz2`n@kpKxC9n^DN6LSKIec$= z_8ND@VeD2PmajtebnQpSoHnl*qm*8&J4(%G`TrOl(uAJ3Uo884nE{o3FkF0zkY#QC zV}{%`R;c}FUyA2%oHaJLiPdo&31$GlaMt*#`gnB#i3?L|tKIXyvZ4YN?oF(u817(z zukARrPJ#)$q!q%o@5^)NA5A!z%O+Q^u9(@`fx4DU_=1&sm4X|;1_j0 z!RwIh#4j6NPr)&rH!gA-&5pSA*>0P+c&c%dGmZCq?bZ5aw)-ULr`i{T65p9SPeAIB zb9V~i@(P3fqZZ+!~26EA86 z`WEh-)W^M_js;lbLT$5zW}3(*Gx=N_o0^&$8cx^RLHq9kIrU=J*8H=RlfsA(UU{@B ziz$J}q|KXO;{$rrUwuF;Xa+vUt35Oej2wauCXi?FYpWKaFpnA(j@x=wi265K>poYl zGjL5oP7#j2_fDCV5l(%~r+r^%YVg4oi&JtSJO}VM73{z!Rl?8{gkiYLNoS`KZv#wX z?>O`_mSF!Q6Ru!xUb5clD^xhUO->R+>zkZ$!MsksmK6w2_V-=9;ZcF5C(&@k$v4I+ zHVoNWRV}UT@xwQ!8CC=xx{mJt$xPRd(OO8=eN1craW^Uj1%9In+dM4}o$??;-nZn4 z(}}8eE^Qx!hO$uZOzB5>?3sSZC$ypFjEWR7fAUn}Dy**$GQBb6PgX9QSaLk)?_{=H zd)yQv^)&mu_egKpZ%vl7oyqR3&I-*!O;ufurC7QMq^?vwsY~o?J#)4@uraSEdN^MC zobrEAg(3$h9+A_o4!?%P$bs7)y8lg^%LkZBQdl2Z{^AOpvTAvazLH+De#B``9QKaJ zuPt31lT*tzU!e13|4R&GFoMVVXf%4Jnv&ciu2%`41^_40k^qoz#2EFw|Gk7#IR(gY z{1TL4G0tJckhb~@+KNC3Aqw+qlBet{7{4wo`{QMQg3FHCVB9ehYrwlS5D z8yDUx{UDs5iW)y|#kK#b^QbkO+A?J`jhkjC*Ghev{Mp)v<@#dc4E~pVK1c67G(Mo* zXTL#362)V?#8$^jIDQ^dyLbp+%wQAtPJ#kj>Bo?Q(Cr4vE#g-O*4Hm|WleW4_r}&| zqunNaOA<@hqSRT?C|!G$;G7O}tTeW0BE6dL5N73{ku}&4=jxZG2xk4hY4io70QueC z9|$+580jB2PWs=~4R(>Jjh_gBCwFF!OU^Socjr~ibc`^5l2qR@6nZElu1xOF*NYuH zVi8G3^5Ue%;v=?woc3>+&ma&7?(Jy3%;v{U1iX1(*_l&9pH;j4knBJE>wK-Y92TuS zud3&^l3wgEPsV@eQ?lb%wF^vf(3h39i~ z+xBr*F9velWDf&1JGzcTy3N{?jlid==^~E-R}TW=)@%oezyf-OJGj&LL0zW{IbrrUGH{? zud`}&X6_91h_2!Vc1%5aWaxJ8aid*A_d%|C$RE^H$4Y|DSyomCQ@dNlVSj^@pC?d! zw?$7wkcdwQF&kqyXRnP>Fz;-2A zUi)%X=rrrOomAW7%Q_a~nfAk=gdoBNTE|N)O(nwO>rwkX)6a~XZN7c^N?bCHR)=ov z3E%k~uYBJTW|FMtyAE(N@hRVFOElKY#hnlDMdZ29Ye0NVMI^B{c)4wV9YnY#JR0H+ z2CsK+Jalv@s(6=Yl#BFR8??_LmEVZRrG|-QlmfpXVDCZ~+}N>$B{O(zz<5mWTPNGk zU%;i5&+kxhX)fcaB5GA{a!up;saW>#V}M;(!Hdmt6H$0jZ(IF@-&vNuqv-19 zgBm{_?$59JSlfV_q!vh2#i@j)T1O|c$1VLr@a{H4Gu-(GyTXNS#_RaNQ$E5x)AyDt za8T^GS-yk%?V*KN@9VPV0nt`2>qWeSqnK~9OEB6x54c_uBLhcE6>pHeHDg$%pdOSh zJ=u^lg#r8tRSDR)1vdw`8HNFKQ`*B%L?8RJogo_ zfCJsd1WpN`{)}O6u^fLDw13I{RwZ=5zFRSj-UE6C@~pQX1R1ru7t)4>Hhtjl>!Nl> zV)oTtssIX|6vqf9K6v(qD0izUog&%Vg>MGd7N;n+;?yV)TK;9@ya}m<`E(uB^$PU{ zy>~;0X7XpZmtfSmLSx-dr`~Vt=`PZbk}ZeL?U(@b)fTrkf%E$euS3SqFAqJLF(e7X z7l=OwQ;x9rwb?z+<2#>bo+vx$njm+|@oe8d0$1)09X*dnI-j?iZ8oD-yi7tVcOf1R zk~S#Ww0%ZkA zl3w=nuEMUZ{XV7a*kAFkK@Xn}!&aFVWfU{Tu|X7kS^Vi=N01y$yxxOBpyWZUQ4_|M$Ji5^RHn-ZE7_I|xKXiOa zE$EkzupRJ+-3RPp&H&Lqz(-fVNRmW~^}YN^zqd;9999xIcumvgNliY#kvWA%JF}%9 zaVFWK$RvM6u#7_&MZP_;z$ik2H#mUWBg9=RsUSMMm(K~UjepX%DNt3zw16j1pFrAfebnp-(dJPokHARMmfhM^nAUKE7VsW zFXmzeWL|DIw$#^Bpj7~?z9D#i!D;i}n48_FGC~$FQ_kv9kk#a?__>7YYxC`_L0qr> zW%~0hB@uGf`|kMr&ObWu=Gw0I_k+%Ly!Oxc5xW9V({=5Rr~NaAe+ec^J1&T2Pa&c~ z8Bmv-m(C~FaUTAcopq*m2ed|ye*zbzs*dNamE#TtB*ia?Fw%&HK?5{r#^Kgj0&2wW zDusdDQ}qdfku_*Kwhsd_7#kU|heA#o>w*HS(o=X*1K&Z*It(lzXbtR(qKdeC>R^1c zf!al)M~UwNFuhV>uRZjo4XZ9@=fpq4^jKVSJT2CZG0Tg344~lZS@Ubb*%Qe9t_7y4rZXy8rU@c4SwZ;z&L9Nj3GT{HE6StDW-Sho-}sW#=Og zVl~YSB0uv#G!9knVN8QXP^K7ZC*<0d^OM}J?};Yuf+##+?Cns58Btg~loEP@C4 z1&%OFe|;@dt3Ec5iY_o*kOIa?cE*v#80H@~!wvbOnJp`wx{fOF`6jhEa)(XLrKqR8 zpwzS3&*kY^4VWgFzxJ*RUuCwgzgC&KXv}Aq_hxq|kZEF?lbdn=jF9!(!_k<;zU+lD zu9nwQ+;*PtRLT+xT#X3teQ z((fA66jVAb$5f~Pt<_Ph?sM4z)+3;f0ZK@G6`A-%4?H)k&NEj(KW>bh`CQ@2)+XOW zwUvL$F||Iu(qP5J`LE{N_c?hOX(l;o)Qs?{pV3F9qKLBxkm>UchqWgPaiX~A#=>P9 ziP6dzJei=cKGp~abLn)aNf8jKGZ_Zw8bZT!epOHLV#(zF3e{5{bpB4e2(E0PsI~N8mWT%Y5xyf&0->tGB)FI7i!Q*D6UyEtnV~^tVCWq1dd}_ zKehJxzNeZWNo*sUzxs@gK&=sG^n@xT1c_x?E@7>ffA^juI-L0Nc?rSCW#2CAsdNz7 zyiaoc77A)gv$eU*kFQ-{*X>?z$8zX>xQ&EEfBHEcY~$f_Wj{l%5$>}b9(TPL_`&jW z6?a2fEc2Fo5lcW3&82rahxiG~g=WhW`i3Rq5zc25;pCHF4o$UwY+Xa~+pxsj%;%)V zAu5KFSJ<5JS0X}0)jh0o2(Y+NvG*mUHx%7k<8rt=Vl!|g%=IFb#_jXCubB~K3f2mb zadpAFXXw+MQQT3o(7o*qsA1?oXWI-tpD_<(b2G_y)K8{`Ali;mOIM%X7g0oi zr>0l~gt|J73DURWe>&oZ#W=R17{1RiGKWk82>7y-F^79eFQu(x)z|pwRS}KgN6yA; zClBocH#NU^H=hWFMvkD!c6#%enaXEUjmNV!sReB=kI~N0P3#cNa-eK(A5rhAriMxI zWUr$9`^Fx5%1U;@A8?09q6mOoDJ^2eg3iZodz|U>>t4wFOi|+r8og`m%2em)jjV5T z0#E1!8cZdL+H(o#z_XgGrR04f$UrWgBNK_*r-SH?y5S_lka`FDf*QI>StfJkk{xZo zBORTw${AqxV#T&grHYD0bPt=xY$vqR&hCmGS=8XVi0wCmXbf^MhD`L)`pMJS)d+6| zspyoo5*kz+DzZcTH5`V@z>;g-0`;(Ctv-x4G>_*eFIDwz{~+#cblcS#0?G4C;Opo6 za-Xbdp{I~6-^)pxs*ZL(2F^;a%k~2Tbp@eYim_PsCq#{(1kBPBXeYVWxA9tSyga`u zZ%6Ii!>G)#x1Pg0$69Yjcr;@A*LAKAh5W4D4Bw#a5-=J2ZjDKcB3Mp0+r!!k%*CU z&~jz}-+MvyA*sMQ)E_GzC>-_lS}2K#%D{+Jj2`DKDUaqzyTaDC#{ir@^;Wmc#SLhY zc@Ft^Y;PX&9G*f|`Rbgk$yJCY&X{DfB5;007&|M~aU^wf;yFQ~&Pm?=l0f?hO1 z3NP=n0vP=~fd7L)q&^~5aok-Q-}LAEg7P>X1Yj(*GYgbWQzuGEgrV?M%Ev{j9qW(t z2uDQCdXPtx>8P_F^99z*rjaU0z)j4Rl zU0SPFLDaqrubZo-X$=xLd^^6gsPih8<85cs{{gY!$E(9zF@2 zNn)0U`YHwAiPi8BMUHL`4n;VfL-{!()lrTFUq0+)P!@mtNO;#Wzcx2~X<5;xjp$4} zIBkD<%21>V$$v%sQ}IlKhna?tL(R0km5Cv~*K)P@_YzBWwSvPablhhA>68sl0|X$$ zn@S=H3@|FT-*Ea1BH;8OCWG2a57$oT3Q3jpYj63O)imZou}{LNOBX z^JV35_r0rSy2AF;n8N}A^^T(xfBRK8tzxYB0foeZkrL9Aof2vN!@FMQu{MKZC;M7Q zNF`CzACjLL24@kY91ekI76hi3+6)f7ILCl6oXT4f=1jW6H-#Lr&M;1b_yiGqLH5ND zh}LApPY=PtV>G_6&)x0C+CJ{_bVBIlw?v@vM&ok47^Z834NP`A% z`mu5C=1MM4?!#eu_&coDm&o1PA~2zcGC@|Pu?qVFv+vpJL6cqcaSQozman7MFG3=b zD@E48v)ibbM7hDilDhW=;%H9+2l4U^u`1*y)VS|gN(;5;Tm*%7`B6A^gky%^s~>vB zk6Iw=r8{UYVdSymra<-E#iwekTZrUchp%SmW839?WxVSR^K0L3$L$dPg}Yc3*pRl` zOYET+6Ns)*f-|13;~ zt1lrku-QMueP-4Tnghj>i*@(e#gk>Sk0Dp?>bSQv8o<)4Z&Ha>U{-aVr!?h|dN$p_ z)kja=3)P2*&?=9vpV{Dz-UR3nS%zq@iLUBG!P#Ne#2fPBws+eog5a2b;>TNm!Ud(_(6xrb!Hy%r2v6Lzzd%0Q%@wcG2UT z_EuF;)An{FXj{>@sz{LDv1~6ETZmLAn%du@Lh`A3u}}@t4 zYv3BU@LHA#0xT^8{HLRsS6u=}E4J17q1%&OHYP|jM5|`0zWT{y4?iPSjq}P&jyUvp z>vx{J)06!bSi$wBROUy!vs_U8uA}xoNbl9c4dU{Gv^?Hv^i%EZH`MRw)FMo=QUN4A zU%sVT7BT|>=~ua%rn_i)DNO__N;u+5I3}1>Y>_Gb;fNHiLjA8{)rY00NiiAyHL*Yn z6hA;Y%@-c__m1tZZ6hYoAB!*Hsh+6@!2`qQTX}%pERSObq9@g(QjcYw3xi@*Z%R=8 z6WPD9_Oc)tq2+2XOnQ_l{T;*w4-*3iQy7DexBHRVeM^uu zmg+oyox|RzJ?VCr#kT^ike zN==Od*Xm*2&dpxbcauiR3J@c?itQa({*;!;_rYe^zJ_g-TbVHlWU zlI?8&jNn`Zjw9fGujxmN5LToIGo)B}0o+MOgfGJSUtrD@Q-q(<-vv6+Ms^`iV#Gpk zGEd3Q+=aPs-}m9C0XsD%Vm0pT6MPS06&uezc<7!IUw86q^jqE&-dGY=QFEn5fMCxTcQTsO9i?QUcTV&YTtm66lj?o zj>&|`0v!yd>|wfEkASLig7awj|B&y)y^AW#Ant)$yeRIF@=>f3H;t zFOOVZg<}`rO@(V{YR;EkqNoCOnaqUV8}x*{hLKTN?PmX5r(DAS~x3fQ#GfAt%e&B<6kH@9eQB&Jfb@3&*W<(BE~5s6`Cm zdFtSba?hvLph_lJFHMj>qD?c!amP2xD!gvYJJISt5rS7@fZke=%fmx~1o%k?8;h3n z&%jv+9ypvyzPsR2nKujkyJPA3PH2!yJ&d%FWOpHaXp@x#e!P40q zs=8MgjHcT=p^AgU5JtpQ!Vn7r+*0QLonhpk!=_9ERxJ?caPw%K>!E^uYqBMLSo27C z1Ri_+-JwlUfWy)3^3xN%df|fcv>f&7@-iB`)>zG8F`S->hZ;IdTW(Fh1kO;KRHkMgI z|Bl&*nQ%3Abo8`9+-~;JfdlDBWoYb#`j^#B(j!v)DO>M z`Bi^k!87`hghKI28COgn0|o{Y@MHZMLYT6I_w0^(mYy4wItB~N*L2HrR@9W^DxA?U zR+Ar^x|^h>0Mr4}P?VJ&6B4FN#1IJ{KTKiM$ji0J54ybY$XYy^3nHK%^!; zXBfYMpBcG;Ey~mUYa6(FJba|4hYj^$uFpP>z(8vaOoP3+i>S1eSrA;7(p8B~a&q!= zBRNIhj4^jRXR4Fd>ea~fz`)+`xm)5^YB<|Y@7s%9Sczzo<&{Otq_owfaf%)WAb+YX z*XL{J2(T(^U{gfdgxtrjm8B`)jDMag#)J(K5aAbA%_6dI2c20niQd( zwG0hgG62deD@l1A4u)byY2?crZR$YSewFc++Z7c^fO;FxgUQ^nu_y?kUC~7y&hMQI zxDsBqzur_lY1E>Wl_D+SO-UoowH;NSB3uj(z`&{t0%G-acR#$?6eT1iq*2J?g*c?9 zs;ecX6;*sqF0?tc-OmCwCbSOg`OfQ77ppBKFBc{z7M4spHl@l^mz9-K@3iUBsQR#t zm>)kQ*1#CQ5=aX2Lthtqq{ZoT=}o%F7}MUIy;WbaQ)d3Md7PdysR$sWKq3oJBpN^i zWt@sOx3$&QIvvd(>52=B?`(9#LhbJ(IX#7I^Ju2tW!>x5!4xHp!rIHz=jR1NyEdM- zS3P&GxIL?5LTLTk;z}FjhIDUlPYuL1Ix3T%t_41$AfT#c2^h0eShlwO-)@&0k=<7! zIiUOY9CDzeY3JvSr)>o?H8HVpcJ{8?VPj`zX8tKDC6Puj8lUadU9&y->tONHNWo(Z z+sngwcPY;L?XTV4JC0Cja#m`=xedfa-kO(EM@sp1Nbc+3V@6z4bv4rQXs_I(lH)A{ z9`cz`gJ0O_=;-k=;p&?Edv+Ot6U&fiCRf2wSx(|(11^MdE|zKf)R0QbxD zxP(xtO?u2LLx@WqQL5^BS$2I~$W}ufQb!&FK{7sBHzS0I16;)9*yrTPX zHt4f3Kfg1R{YXyF5F_*#~+G_#1^L1Fkt7rS=dFx)=r#~^w;p89|`XSxF54SSUo42g= zLIs0AJXhm0f5G2R?g7fp08R+&n&-U+QkG_)k;o|(g<|@u;c>fkzexrF;J%~%!)u-> zys3m`wBh1)=lVUs-{k{j%BGBLZ_assbb6yBTzj0wr~l|QVcRxlt9_CagDbK4ZWm1r zaGTdOYu|7ey15ATZpGVl@eU-5RZyhE`>MO&d8OBWGCkqDIkD!$>ZfycEp%TiBlOxY1ip4%a~Nej5+(A|nT9i$=tglD)w1RaF$j`!S z=a$bKAO67Rd)6ufi)T(sljTkj{%XVuZ`^KjV3e>37$` z&x?%0A~INwIh=Eyjq>5?YfS!GX9uPJA_rd_AGlKLt~`nwYV=wFx1m7N=U!%?HFs#K z?@a)B{k7v#tCam6CK*xsXlos-DYFI-7SOgSJ%{Za+R9NPh6K6dP22k46py+F>;+ z&jROhzVo)^*Ap4L)>AR7IyTeReKMY!jz1ARSR;X1SIr1M(%)Np|Km9pcw={-%f2?D zOrr*O;sqYw4(yS~=Y-UbXUAuz*OH!TJSn_#tb~EFH8Xi#sIX=pF7tBR3=$r9nG=E) zW(HL_bxR9tGL;)UKc}YqyU6p=HKh+nX5E{vEBx7~C0%TV9AgNDM7s-B5lPRNTI+YEuh)9MkE41; zMXbnX0&l@3-besNSjZTj=~+v3JpGVY<)ob6b6o82-G6853AxhF;<>H4LIkRO+P>$l84&opb`4)llJ?B`GgahbFhH)0#cefdyi z4b$tC(FwKpj(h!4AYTfPbFkn=T=v7T!b@*)h>Q=^V|gVbrVE$4Th6!LrNaA8Sct<} z{JPcqpn|<8L2>-4sp=Ea-$QQ~jVUevZlFkpi|Pg6!dbth&LCyv#QDwHs3dz@h?kL` z9I6uRya_>Ch?@Gs+D*}C@0~aq-8*)n>tCV#DJ7aZ0=GqWi%lckS3}<_Uq1*-d&sY|MiVy<2$QtRGYrUz> zwR?bPQWk*Ju0zv{cCN@bXY8ziE=}an9;WiEZSRugaC^r(_HY!(wuO+en&z#mQN71f zb?^1J>#k6c74luAV=IE*=OwY~sRNmb+v%bD2h;Cymz;7j(p!NRmi8@L~2t*hpN_q8h1`fhnQH8)M|K`Q&^ ztJ66=JG(R^MCC7fdpiZxU;X8s_wh|!!IU8d76t`{2qvyykwwo6AYLb}`CMEP$F_@REO;nyHS+doq@w0gb})LXOSEV%91I7=&bMh|G^p`9E_#uK;;VcqT| zehg03x|k2I&$pT9pASQM}>^cCJ% zvg}C`K%j&Xti%gOBT;*~Ri-P&iA0>?Sy?^w=CZW`v0Ot*)y(L?bB=|UWjg%S&hID8 zeCWQk`0xeKnM0<;f)7Hzli5v;}!ti(kb&dI4fMzaa0q z#rg^hU5t$4%?r2S_Bj)9j-2-cQ+ft0Ql6F)Lzo;!4>DW=4L1?u0LO1Pr<~rPj-V{^kXJ{ z&tk!wLh73MpDok25ac3D$5CL1%ra3bzL__IdE?3;2KVDq{Qih`#~Qa7{SHrVhJmrc z%8D?N_WVqml|pPsUOLxSXoq5TZb2KhOE<;fcIjl9S_}KEZ-t$EOroWMQ&UUJ4&-7v zefMU1Paa8)TH;P~ZsgzIA^X1cy_Fp6gJ%=%{IMNn=;{c@uolNd$_*KA2$2ssUHDcQ z3ywb2(6`&WYkC?BPzj|e+|_OM^-fQ&eqklC(3YF={juNl#K;UCj6P!Z-I|G}m^fNZ zH=<^)C4Hc%ZD|X4DYkx_8kmYfySKL^TPns>t|-Etk<-$rJr*0GHaw%5 zX4teMn8R|sx)R&}U_#s|^(+u533t#tuHs=VR>c|>T~AGj$E_U{N1PS}bLLjMi`hKwLIwvW=5%+A5c;*J9C zJ(eNDAhnYRYWVfFLeFFF%wG}3UjeD3|d(Dyf=!D=MSBPcp5v2K1vBR&M0})MsXQ9ed4eG zi>9j%i?Vy#OM`&4bS)uBhje!e(%mc}oze|UcXtX>QmZsb!_v8=bP7m^z{iWfZ~xur zdam7b&diy)?|WttvCF9e$sKkMzp3D;PzwuO<>o)WxZFPK)3qksS$qm=8Y)isbFq0K z6%KXu?|5=P%bT?rU158~I<8TG7$V34H8ZmeKaY_sSN*~g{G3EH{r4H6v7!Oi*VQ%G z)xiOt6Z+*7I?w2$44MIA@ZTZ0KtWn~lLzbQ|+Zl0q7lvR6V?j-oRp>=6vi znYBtBAk!%pnAS@8Cuv zf$c0?%#0k;r6$JYiW_Oqb%>r@%X`Dca=e(2S)^reml1SN53yh8C_J3#W1QWhn&2w< z{Wv^849z~f{SC?tg_WSc0}d9M&NJ3!`RUVtf1stMyA7GAdd{QN^o=V|q_s_ju=mBF z$Zy>Gi70-#DaW6*pzEQ1Z1ybOs7ksCm9T%2s|j*#diA(wRjMhG4G4sB_au%R(VyeS zbr?aFM{fIR4Oe=6(BWI-3<)_DYB*pxANJ?5%qALHDvlIs=Z{NrZaJ+> z(Y?)ulnN>O^AeQl0uT-KEK8iHOQMZH+iJ{0HY#@a3PDHu*mX8w-Dyq_^3Az|k6Y8# z88~%tKAZH;X^E$gAMU#U+f;nZ$=-&*44vX3IUX!1F51R0u_*^IUrOAbbXZ=(}>Q-g0(M}StVP0 z6{_}x4LL~M9Af{m9U)uwHx=o_!2q$IzqJ1ak{{oEWTOEDRxa@l5f@%nhduYkyYtSE zy%-Vo7MU{Re!`n`Q!|SxN6&d4MsfKb_B&uB-kBJez?*(?C*nV&$?i zw&K*>*JC>Z>1suzA>1RdEJs8~t}$zG+Z4ZeqCT=^VYG(CpN+Im^rKlB|1izB=?I^i zyRK|^;7h*cM2>Lm%=nMB^2ydUR#!*nky~~)%s>DW5V;+7aUgh5YF%kY(dxVcZ~kh$ zO$Mp)zYRU&2L>xIcM2#ejQLwCe!xVbZ>TefQ`#%~Qh%bB!i9X83Wq&|fvB#8^Cxz& zC!P%Ws-cYUWzB-iK~nHt{jI&oYb%qnW{!A*XFk3Q<>Gf>mv0a`9j0Mo37p@Gr@z2Y*KQ`t&@z|83jMWWCm*b~_8v97NAyv!ZO{Xv*! zn7BM+v$K@1Glt}rwtep(AcKLKOQb6mkLQ=;WtrAn#Oklr7vv%{H7EKO3@)tOI7Pav zcnM9A#1xA7HTy4#PCbCVUtgtgW$^Vwb?&!rL^EN#j{jL`RK02xzrq(G`%GLCbFDGo zKIUWdR35mg{~dMq%-6n48bg~M8w0k;(y;3&*jolX3$pa*?0^Iqe4d~A&NKgAg!ZLh}<_!!DH{moDQ z=FcD`e%?&yzc=V2VxuLvjBI{=(6}#CtiMhu-aZZ*9cOO;)H#)Ro~z`ZrIp6Kwi^*}{^Y+BFLD}a)Egb}aUnh-J3k44Ldg!qJysskqe; z$QL$Q=jtOuKCOIZQp*!^x0?=?&)w(we+{n%eUMBJA)h+`MtVM6N3*r368i9_jRjnd z7<)gW7(MiTnroTDHR`<0o;SR%en0o=voQmR*dK&lEQ%8d&}m3}p<-~5xB%1=%cm++ zkG{HnWd8fv^U-n;NM|F&(B42|fbv1T*7vce;}YxSuE*1N6Q82>-tQ##tx9MAa_UNk z!FA!S_&ay)?~Vufl`rzZ``ElwB`dTkJsz+@GbV`mrRM&~xcxz8 z?fP~}0HEXbOQrqFIQJ2Y7X{VILc4YvMtDQk3gbaDf6jb}v| ztoZb}kKp)_jAiJd0C2i>i1#~=&OiTwlG8r3;ypY6R+0N}eFTl@Dn8?B;<=a=Zmm0F?WhT7Ztr<2rdHxHY& zz&O9>Y1L0&D*hMkalq5XQSE@sfXDTd&XcUGRl>H@fxO!cA9Cu2w%h#2Bhk)vM~8!p zo^O>=Fn<85r1m)!1&x>p8|NE>#{SF28C{eKhl2)}C^qQtIeqH0TyBI2uy9TyTFwO} z_frEUX4pODhtJ%GIuuQJhN`vKFxD==4G8~bHGYD|W_Qz{c0J^u59j;5`ukwqwy)9& zw|3P|DEvfFYj8Un@N`vck3TPsa`GqPKG%KJ$Nz+_v!l-CxH#`m&Te~K>s|Vq@BZR? z#KYkD)0X+X=wqDdP4D~U2FjZsR{4}nJ7*4Lz7y#FkCyT6w+N26D*4UZgFX4TG;F?o z(KSz{ya5mdz>ixRN8g^cRUtUo51Y|8-yd5armyYYZ~Y0|4>VQuACG*V_6&`MgFL;U z@qhQsDL-p14XYd96`lOJZSQ>2&%b{|A$q-s`BsDVq{R$kWpK1h7tlu|YZ%NeFZpt& z5bD&3+EtGwfA)xFFsl6=6h=q{MjGsU$AW=KP2bSZ3R>5TpT_l%Z_g+nBj<&7veW&K z93Ss!C_69i+~K-tuzarY#&36810Jv*&sxyB2s>OJhTHwOQSvS)usR;O-c~XhwC*V8 z-~Ua`KJ5$W*dL3(NqvSuy%h61*9*r#A5DH*{nNtxw2evrd@{EDD6S_^VwYo!v3%w> z;{$HvAA1Bvys`nl5ApEn$7u?mcJkqQ`AKU90^n0vpwqbrSzqXHOWty&15>wC}0m5UGIvnk|nVc_ndY!Wc zY^-2?hF%*ub%%*wla6=%MR5H5(Art=m5#Dp%>O5oZB>YaEqnPUETH2-H@@wOUr`;u z!Rm0X(lJcZiqbZ1tofw*^M<-{b7{6Y?^yHGO7_l;qW?XX(Nw30%iI}{EbYQ~l+^~| z+vahjPJZ9+{!~i;CvA$`Hsc%5>)U(9$Aj9Zon&Ld3XUB^E$BKTK6NZd1e%gmFLZ)% z-8SQef4U-iY9}wD(O_-hdA?KoH{j%9 zctG^8sQ<6j&bz9oho34>OP-q7F~nIOS^1ovb_4K;fvCGMOvKQp@wQu0W8dwxfZ6`) zw@70vb8z>5#bt1-8y0rFwYR@c{!WRCT=?W)U3_`iVl%#(xqgV4Z`(aQ{5s-+i7T4x z_KwekSytxN@yZP&V^ns@Kb$MQ;CT?{+Rg(OPEwz+b=P+WoIL|Bu0Lxx(?9z$y4aQ0 z`N-Xa>itJcjUV6S_&w_Wo{KeZFB81_6|oE}<7wO#x)7Ib`NZ04gC3~uXcX2kCc4}8 zLwK=ezjEGVpyOeEmvy|3l-JO?o$N2rRrgiSo_k8C)a%PodcMuJexaYRT5Gwsz z%Naf2V*yHDYT{Gd1!1}%C|Jff}R z%l!v)Z9dA`-Z-(e07?|x9CM(V(eR`R!HHvMkC}F^!eN(@_UGnBm8JTKZW;^Kwf|Mr zHVQDqi_f_(|6=F_@qGxHw;%qUA23hH`*7A^e3w-1>3?J^^yI_lJ=e_pcRRb&sTH8) zaC7m+!FOlc_`+Pp__mcvrQ2{^R8KS^IluFUhWD?r=;>MdWUJ^|X*T5b%83Jt%t?Qw z;@ZRa?;QvC0asQx?N8_Vj*EKyyKTkuk1GQm9=0yi+O@w~KmX;*cj=Su4DuB4KeT!} z{xpM?bq>lYLEuh~tni;gr%PT3UB_|%Jz=cw z#p+KOZqM4KhXcyalikOW%BMZWPXlN1DmIO-B7MsN{)oNwdN=p)`^4#JXNVeJqUa3~N{w#U6#axqVs@!x^@+Ri<%@tX>5^3d5sk?Z3%zoq+{1#Cdz zb2Z9ItfR=^V#0@C@uFpLds>bi2Z~Kn#=4z<+5C6i-{xO0zimH#`s|I)-uw_gBI-W+ zHD)n(mhEx>(EqykoVDdwA6sWapo-zc(D#4?b7R52w<4Ew--R!};+H1QN-`QArDB$-WAK{%$74OMEZwdsxW<@(5 zI{i++vSgQ17~zEt3?EU}z7@TDe5A3!cSXU)tQ=Q4$Y_Up-s_*mTZ>LzjLpHJ~mKOG;kzZ=w56gzwC*e)G9wl(Lf zTjRksfGl6~xL|4kMxbfAHsUh`7fE9LBOVoKM+4H5XaG~a{t7TP$g1~h&l*1tH)*~1 z6kVrY!CY(G#;5EUF!%pu>N{7snl9!+52U9@{~*X$hMpB7x=vX*M0Gi^J@X)+oS@lK7|=p z?;PF>e*h;Sp~=gtzFcVpGy#nDJ+Jbcn#W!PIkPgIOel6W-D%MzYGcYTBkLu1PkXJ_ z&a%ckq*2Ujtypuvfx-7Tr`CUB7CWV0UJ%EgqeRpBWBMq1!PbN1U3w~4wiYTACA)dDY2((0}k}Nel@)Y-QADII8qHySu+Yy~@fPnL7EYZ#BAR*}32m zZyxthEn+SzU^F=Jc3*>)$%>m6NDc;D6pxq^*n_;k=3~CZze1DDKzd@D8`oDhRIq@{ zFN~8g_b6+g|Ab$lLHFiHZJ~NB_QRDoFAr;A$5hb~zcN-p7(yaSra)i+YH;KAEuroy z$l0)rz%qY|#HX*La?QzwxbHEJ;JAL3eC$fHgrl01Y3B{&ouC_us1NZ4e0@^rcCb6 z;a_JB*Kqq-Q1eTdNq=|iWx~U6g#WDNg1v$=nU@iG!itgQ*M3e_R&j4P&FFKGaZIBj zbd$3b@eiJUuCRgL|Mlm*XXT>$Ba(qb;gE9l(eAs+A*m09$o z)7{EpLpU~E0awU-ve>E3#PflQlPDL4cX)EVHxoa<>8mU(S{<;C1vLG)Qc?AS;EM{J zk5InRdho`mNO0Qkb6(}zkdLEfG`zSR=z)W(aWe#5@L6#`jh42hlsua8rol3Y%9ARt z=pZY3H4=ii@~-->j`+xOJ8y$SoUspY1nq=Fv?hQp-s7q&t#D#e*xWopU{n**sf=9S z?Dvca&?Dit`-kT*K74osByKhR?hgl$Ff)}(c?}0Q$nd$#|D@^%MHS{`TM{CGdKkXn zehZtu zFvyNW#(jxPdNE#c82(*|PfB;Waw^3FXT9F-lCp+|jN;DggaGW4G2t#)89wsFb)w=D zM=4K(QRM;*anyuV8aBwp!#U1G%*bCSP0k?(S>!uv;Vm0=B{>mnzN49z=2?tH3#|C7 z=-A!WnC1^gB{BIQKMv)IBHHDMxK1$D86teQ&%F<;VM`^kgk!0Zj-tX148$&qln#a# zWZ+CLb6mTFwF09u5=t>7>UwNK{6?L&>1`=SSpog?(lVwuCIeLZ#Eg*($sz+wQSNwE zm;?>{rxL6Dh(kF3`Ws^qa$>mk?rOr$l=Uc;jl~@9#D1 zwXyD4^vd9{qjnTGpX2GE0!rNCuZd!xTRC){sjq?!AQC#uADSzH*~^p|f{l+h`BON? z!xB`0@;jXuKZFOjeRK%!#` z_`q)j{8Us_BJ_F_O*Tg=?8|VX%-tgg@9b?o|7;;;@QZe1zXB_p@0nNdw095s7mrAE z|5k5=t7d3;2NMN*7U$#RB&Pod zbHXX{LA1?&(lO>E6ZRl5_mQJ__hrSz)$0~XDiz3bk-)KRSrSZKukR)T6TPy(S#q=YUA|zA-n9#vF_S?QbkwT%#RmoSW0^z z&+k4Z`TY4-DT^sUOb#ASa;9w>Uy!Ax@iccP$ZD?%r6dpT15aPK-F5wAJ3uFj5Si6T z=J&JaOT!$~trJY8HWVums}doYk5LTeKC18@qxiu07A{-=NK+@nP+9jL)s&qsfHkpa z&xG`l&RB6bq-!h!m_ZY?P*9gy1eOc%AALYrN5h3z-MmI-#GXb4}>uA zkVMtr+_1UTdQRKzh`n`gqH_%q4WVaKLM+9CYp4tNx3BL zNCfk_{^F5i4o2Wb!-L0qJx?p^3KHuj6^gHup*Rxayb-I#-Wt|~JG)kylBD?i8n@N& zk*^zr_<$v4bm#?QgLy8T)kvXdZPP=E8CSS}rXLJ8l2Zu?XAO5x`RrnoHg|+LKbVnN zjVkEokC48qwU63K^cKJbm%W?&O);}ez5iLpJ25k|d}ejIE!P(qroOe_9!7N))s8;g zNc}2gc#dMI)QG;H74xpBpfYVf%)+CoTW5%-aO3LTzuANT(x%c|TV zDxBdzO4w*PXpo3)0yT{9j!AVZsQK$ek~i4oRqpX!%yxL3vU!>@VOwmw z?HmKRO`2m0yLM1MCa+w7dxIZ_RX<-BwbN(Ac9%jUSsx&X#mwZkRa}vk_ZHflR$ScP zo;X2C15N^;NHz~v@=12Bt;Wnf%+yJsiLOKz>)h=W0%8oxRHOO!S%`QwedyL4>Io~E zp)Xe(Nt>fc?r&3kwdpx0f<5tK&fonmiR>LO(j1t0k+$vVPy^ zVuEB(%s|Xdub3mxmP6DS@P~;Ri2%*WJNskzwu4TsiS@O-6R3E_#r$en{p5M-(+A5! zYuuQCR2PXN=G`n*3W2nk#Kd)QSRPA)Vpnru@h>T-%OW<2`lpR8a?%vxd@>7|87oQ!};4tMyY=`WqeM=eRnYPm`@IJ~!HCylEFxn-WW}WLU znLjCPb*L+-p=adsOMRiJI`r71R|0npI%kveh~L*3qs`S+TBi~0KPEX)aQS^T9#6%A zBwF@55skK^O5jVGW0X~c#D2BM-j4y0 zu<ANYn=1~Cp&VWc990nz|B-=n_Qb6XZpR@A!wQ?KjATZeYXz7TRH@H+61_?Tn*I*Cd`E63?1xGh zdsbn{7`Gp~VY`%4N7Y({T&Cs5Fr5up4k)DY8zS6*^Oz=FP{h#yM*d@bTlrxuL6 zE)zSvR#0PO#*6W(%~&==R9H8z`QD&eRA}Y4HfDC|EU2=Gi zx>!_gDH$8CS8ELISK}crgYcpVS_yMp$Vhz8I_B%pB<(o4advWu{W{Qtl`b2c5g{E# zBzwGMLfQnBQooHb6G?#o{~!zpD*5oL4qh>t12`So$dUKm{)*|3=hO5Zi+XMvW=g-@?GiSwz^`FIYi^uNO1FfRh2WEX##9d!$WtM3b}09Wf#xkXz@(xxIj+B=^dB|B2n1oGd}hlFLS5<6KkWA{h7^S zi5$7&kPq9e(!pXgd3!h-Yr8aF-C$Ff0UlFLQ=V)pEKphzRan7PRGca#vh~9-n?le| z5ED=#^fG6jGTCEW=MjSk$ZhoOEev=gb;DwB5C+!$7b(*vSWW(>t^k%67>WSj@W$z@ zl&lvwGNx&8U&Be9j3JAdh6HjsTQ}}Y%Gkn>dzGFK7P96A7c9n?bn3;nDz;RMQ#9mT zXMyVCVQst*D_S0!Wv#M$6zT7g z;ZA}RK0`6pOJ$Wcrl7xOzBnF7MzO;a2+J?iBm2zWqb`YY2A;EfXy*Ndax0zHEJ?Kb zpxzdWy`ill`a8s{K|Bv%%-R}rp;_F!0&}@%-;tgN>PbPSlnCXJPA~` zp{APAUIGcDiy*9(_9qb2rTOO3w8oto5S^}cUdQ(uEvDcl_r=4hZX6t-+bvE;S$E0Y zBy2^l&!BMovn9mbk42$H)DX9w*>{eVr~wtX8(h@>ye0e6@7)(0$g6W4TAU_zD?!-1 z!x-=t+A>6jAB54x`(F4v<{Incb4#@Q4pP^>_{K-J*gyxXw>`cg!3<=ffWzZAHmW?B zFz;MER_-V#w|Zt=9CTSdSQRXzTfFzqTr=ijsRx)+vqb3(OwW9a8-W?hpDD#p>xH_DOh=a>jUvG9O7tc>QT*A zfh`P-Fw~SS@OVYJwYuCv`Nq;83p}Eq4&+OT&kfFnYQuY;(X}Ff;s{K~gamS#?;yx+ zEseK`m1`Cg*BdN!E-rxPkw^g+?Na@qLA!aDIo_vX5xU}uIgXgobnosGyzocH7UoP$d>W=JFLQkI~d z%dt6>y~f!ygC;R63emofD|VtGaz1t0ka>BIft(GIW9M7^9di+++@t{#vi&|>J2>C> zwSXFAGLvGpkO;aEL+my0OuO)pSjC#~8 z!8!?|NV1o~Y`$q(J2w?Q^@SO?&^{+-@6F1yBNT(hf2*V4z0WpCEcop~8Nu3Aow&{c z@@y^93UT1PHB~W(Uef<|HLwXW3}dv%apmxFQ`RB!MM<79BF(k=VPgo7$w6J;oCufo zKV50?xcTeOQ5w(YDvPR0sKVDS-?P3(uoL-Gf_T!>A}**k{LSDc_gd7*S;p%r@#`p^ zH-gH#=qYLa?9;jegywlILcZ8C>0uucX?Lx2?61jrB+<;FJA7ch&g$nWsG^wVZmVPD za(@g>WB3I=H5ix^YwxhLv>IFay|E(A!A7Op`66TQR`0W7=WSo~U{6kg6W;#neHB0aVt@Hn3+9N^ysi`~V>a!|od+{!Qu4Z-94!EK%YqvXFz#4Z z{emnWQ?pwaiKC(h|CFglT|y}=+uKf+(;H@!+sZ=ErkIy`SV&PSrs_)x%WPdGI}TSS z2e*{p3`Y>D+PPM$%EX#^pJ?1733k>H3> znyVkbN$G@6zN?;a?IF{~x5XsUL;1;y`8`4wXG;mCh^()b4P|2?UPp$+L!^KCwhAtr zSwyb03})88-`i9D++cP=-pPdfeYCYDKNXm&c@fI3A3l7*R7mj7t8hg55DDX*o<&_{H}ajchk6qyDTiBBnD)qUThZdJjhZ zsIKr4>`-r&Ec4SpG$i<2&bF?{H7IJk^mna~{d+s_q@MCRCr6avg$j$;NE|+y7G=5GuYRB6$K2Quk7=Br>CMec19r1cb z8Ov3XGfCjSM!bQ@wV39JlJU_%GUH@Yuab{AhM5MN8ZgY>ZE@PnoX`F*&r4wV(!9~4 z301-t1JGv2VsCf83UxGfEPgMu$LE(5c@tczeu4F@)c8T8oO!{i1Ytr129}aOuFvTr zB4(A7dX!enC#5PXhx}xi;AdLfPO(`CRcSY7McGkG({hZB790q$K3)dV&Wjs_sO#Sk79@`yl8b`hqQ4*W|!tt8K zIsI6RL%h29tXAf|l9k98Y{lnmdh97!-|mLWSYuujm%GPGxv?H8+jyAO>bs;*G`T>A z88--ZS|nsGb}4Ge<2U-ek^)-8WSCqHMa{8386`ZYhOkovy;;}K)v}&!cYM9;%T+Ux z`Y~ut+UqYKHgrQB>w`Fl5YYN;dM8z=;DyG!)KUo_a@Sj6L+$~TE zUNwVQbP|7^0~oM#_Bx(~44H`oeMo}=ek-}Dj#F5V%u4p<6etJ)$}XGB+a}){in%QD z+wJ^HPft-;31-PYBwGfg?|t~p*u{H&BUL~9(yr`iZq!eVp@XLzxp&aX)a4UX?RkF%C&RToS^&(w6G|&@z?<3iM$A4^~S- zQtby=b_V}31EvIaR1<$h00b)8`Ej+>NQTy&A7dx*v`6DwsMj4%z4QIhkWtV!5F$ zzBc|}n4|E-E19T!PMX{i_8vUbpsyz`5J}UOB|$RDI_J9$0;cKOzt9WD=Ec&E@LvWP zJE4=TP3z*})y7rtAtT4%8){Y&m&j_;{?+ELegbLZX;t!6a@(P|j^1WCbiN~5Ha$LB zA@uf0ssB8(YELn?FGrjfYMIS5zI;_nyWch2?j@e{rY4As7!U0U>e(D!RiNHy%$PS4 zXKOMo+oyz_(M`mpQYM_#3`C7*M0TNgoND>u ztBe^plM_?>G3Wafo-Bt|IWT~}2jYp8Smh-*)LdjO%4{eR=|zjs0&b1oej zWb|qycgFMANF-gw7}+A4(Oxy>}|V z=R4|r2VEGg0Ve4*V5;)m*<%4Q5PeE`_F%9ZC>ONY|ImbEgv^4!obJ=djK5O0m%F3E z{c`UByP-2P!6~Yb$dq4y7hRwYuSn3@61WaAv0S)+FcLH3&1yskxDdG1aWZYM$iUAC z=i$1H^&3PF6L2{YNeyBoc5+(0;@E79)}X_>M+M1@t5DbtI8Xr~ESAB_R0@#qW^+HA z-6~3On=fhL7=UCVoy3pJMGCZo?9+jIV4%G`&^JL;xCPBi6)4@Js3JQaINwSZdebaMGf^ZNL!v{8oGAvU8 z5O9)U&Tl&XNzLO#uqI=AMuaf5!^=f8OJNvI6Hk|^)nd~m)9}YJvz0C zEEyp3Engv$x4!plWmyw$T)h%45>)l(nCTX+V>?ZR@?yZVWAidA({C)rd5k$9wj}n| zkzK!{aZ7o@F@O>2#q>whUZYqkskwVlXtor+n;&O(53-9x1q6Q;d_%2|%HY5uxXFLG z1F9jqIEnLLiXK?2N+>ChN5T$5!U-C!yx-n~rs(Ta4_@ivC;c)TjiFQe9{!*I#^2@+ zPLLzR2U%ySg*MHEdQtVz;Bim`h(g&C6A`6Q;Yx6b7rNfdby#^WG}UO3XDs3UiPFVo z1bHKSXz`l2EOz-}gfz~0&7emr2UTAY@S-cTGJUr{tj>*nl%U=dqyjJKG*Ox=H`zLTsuW1efAYhLIeH1n2zVXwsN#c z(lNF?bImO`rFY+tI9e-`SV6Qbd zW>W2zdHJ?xe8U()?Z~t>@U>=Zq$~+#eB%H$4)I4)8Ohv2U71gy>HzzxXs2mzQ(YrS z8z*Tj9H=Ax*2{IKId6Sp6 zp!*d3^Vmd&+%@liT}R^Sap!GVqkhW4;;hA)T-nzIkUVR9GOW zPvvJoD&*2Zj8Cf*|7`>?=x%;|xhIyt8o8BPMeoY;SOR7uK`liqr3l znp`fQE6|U^H?5q`iT&Y=L7WFjDcz-;c&lE88@@cO&T3ds-pH|xZgl27^o==>))bM_ z;Kdr_e-;Sy89f5(!m=m1Z0OfKX;P#LH>ISDFfGV1F4U*K(ojm~zDqaVBcW~z4qq_^Qa42P z%yujOt@J!%!oAkzAA~5*h91Z*!ua~}12}f5p+{klOmAGZ?vRscPx$O=YjIiD)T=)3BFgx{iJ9(5M3Z{Wylb=U(P8^C z9s#1GuP3OeRHo*5Ryurq$~~HzE+7ks+eMl4Zxb+DfnS5jFPhp z1PC0I{~*0{-=6-yX1H^HW{wyEP;e+fBN{pAAWKqm#SC$Lc!6Xw<;&Sb+LUkkgu2gS7bQ+1x>7NZrfJbGwl++H?s zzmLR|?`ce^nGprXd_!(EHQv8+3SSPCLUaFR0oi{X@Z(N@R#^vyV#iqJP*SEBGw+>s zMqD{%!X)JB@8<7&OnD>})u!V$sAQ@i!@X$M!LZHjcbLh|!>8V$q-;!v4GEUQ9Fh=%3WrD`=FLpK3imfC4 zqO>4g?H_yvVD|TKjJ)qpEirn!>@5a=2^Px1bc#E^>$Yp`8~?WAq+1~)aT818RfEO4 z^(P%Z!EchnjZtSEvP>uvu9A&8m&R%x-6mx`xJgF8Q9vUK(~}z={#V*LHG&$72;gBW zHen}3t)-7qFT*@Iuq~rS8L;SXQX#nI4R_%mMuw<` zBZ`&7;n3sV%E$mL8pgGvzFjNHg@SCRdF*FxiLN@lYPh0#C)m-d%+XpV{IsD`*^akuZkL?o&{feLmY zS^}vfubkX&zk;*G&wI-ItW{H5ee+KFo(c^_6Gg}YVTy=GL0Cv5$fXs2SH@T^(qL9z zT2x*V=^5_FQ^bL0&lR{PEbuz+DNbx`;!6 zB>^WMT%v7Wvun)vbZ*5lJbF6Mi&MvLCgSyCQz_H&^2R)!BvjPYpPXjX@6fJtYj&p< zVJ8pX0U{@3>rYZr$zQ310Wm3yxV$FJsnR3>(<1=~gZD;@Cp6ic(O~A_F?g7jwCu@y zVk#g#G-esD0LDvQRFnN$9zE<9FxIqFO`Sbc-VZ!&P|cnP@os<7|pHqanWX7`5g@!ByP1# z)+ti*oX4lnu;xg=`YQd36f>CX>T0*+S^8?lr0+A z+ew=kv;tij9`$|!wvMv9O7;S&%yJ~5&Xy2qDi5d-6 zDb&QwFr3~pXH@jW2C{CwB$>WVW=y$wGzpYs?9EhanGB?L-zumpyfa&aftk*CHF*Np zt2n8~gv?3Z@Gqna--%R0WjuKjO)z^lgr}jnV7-%zbn>S;E1b+@wz6oN7Uml}=>jj| z;QwWwKnBoK#(Zzq(4ZNqsCluXggD3&ZUDJm6?L)nqf+fcy~MlUr)1R)0hq55wPvb& zWU@%JjmYk*F1?xL$S_+Q#dyNs0Z{k+T`37~)_zOc|7vtUjAs^+#=^XRsLD_8KQK zvwn%3VF!ZAl1a+D>y(UzV-nh()91^86sW=WlKD$mgYbre3-lq?wm_Q4zaw{L-bjX@ z2-Ch3gd!A96~PxSpth_%*F%xQW7Sg?be`lU8qjwmgo}48s6(t(2D8QSfFDt&o;z}e zuqz_a#}Z}2yr+o2gCscP?G*73XTl?=C8VcDd&Q`VE=_on2<3A&BO2gLb9NG;%@V^a zEzAwJd~N+sH+Dy5^^)!9?_g3kG#9=x%{E!?dxnBCaha*U^+Ou|uY`S`b<& za%oaK_y21FhNtuzq~GhnET-)e>hbTDywM5W<*!z~FW_m8sR;`)@Y(N{wn4RRxza>> zHHx!$L14GSExPQWcj8)1q2k9ihgoI&Ee^HLRdc?T|jX;=hOjkOI|9%PEIch5fGxCAfK< z{M-7#9_-h#tLg%+Mg@FI{TXU;=YF126K32o#Ah~p?zN2fpf>BziJF4zIhNhG7T2Z= zI*5RUua+iaT@Xu9;+Wg$p@8GM(4K)#olHiH)0Wt5w!7dHUcHOgXSglz=?n~eR^`%n zf(?F?Iey}wV@!#yQ#fh4`t1+N8;V(Z`s16EWLIS>Kaj2m8mA96r4!RT{R0bH%#g9) zsHcE(yzNnr zE#i(L+IdlB46~oD&iUt~+|Iea|5Kx%zEBeZEuELLt>#pfzsX1Z!7b%}$*+bPkH1!W zWXdSjJ+ScUrs=lrYU&Y-?!A_j7o|Dq`=>{KQiAJbb{w32fw_R}|1bedXA=+(F3y`b z;yBL%b8Lhpe#fO|U}AW*Acpq$y$e~pCLU}$wJF=6D(Xmu2NNP)THx;Ld)fRIUsaWc^j2n{s@E_=}6FNl=z|0pCtNJ-YLFE-5j9_mfb z&P4}qspFIWfp^|0A)BoM(OUeJo&}O^NMt_*IiUp!AImx2&)+M%in9vJ$708sI5ghZ zce?4l$yFo+@t~xq{KM?SCx`nVV=^bV9+CII)->m0aL?@xGT_7e5DcuTa>$a4}0g?6wq0T z31q8@4@p@r;p9UeN@Ki;o%z%UX!S|>>UX_n4--l8`7f!e>Dv0|w@_8(E~b-tiM z^#f6vVS*+pufxDNfW!qpOf1c_L2hG)6nnZBR}gVR$}lXf1DUCqCWObQ^kEz#^_tFP z?@f{d5CAxCTgH&KV#~r2On!ogr|pG!@d7!~#nee+y(1DiaJ_mX1Kpa`w~sQCjq=%2 zL8_)|E;seuMMB0=Y``p1ESNquOpy?dYFCYy411Gh;n_lE5eNn{8ua6|4iKR4{@3i% z^bP2kD6;Ob%d-gpz_1wp+-#smk@&M*y95U8wN<8Q2<3so{7zZj6A#FXk&3km%&6H^aO&fqUr|BU(Yc`D;Siq?0nq1FO-lJLDLnttG}AlrCs>5 z-Ree(&bx*Lb3Vs@)@DRlz0{(`!I2TW;#}&i%FPqNi{YduZgr%zoD2B!a{g)wxT3MS zxXkC_sK0euF|WE39m;^zU6Ea~>TNfv5+I!W--9t|>e-a6#Az=cwBE6?GEOoC{iv_* zX7O3dYej2h3r5Wxq>rmeg{E(WUHYDb`~Ln3|M(PRsHdmN&%`zR9%wf2uHIf}x!T@M zcD%)iW3ZK_VZuHt+wLSH~?H}#{+O`!D+oIQdPCg(p=(ERWgRSLUgUwHx^ zr)PO|5u<3;-l61m_TpduuThF?d-XMp)znPCb?A(VT~fgxYh1OEHoh5tJ}x(JXy(4A z5h_mt*Y09}IR^H8*RjIV_M-<|7@SK9NSWnP@c=<0fm~=C-h#M9i7xFlLuh=X1hlO_ z>R(=Uqiw6+-hH$uod*uK)r8PGXI8;cM^Ob=|9mFZxE}uXVZh=zCWLl9tEpfc5Ng_F zv3-k7)f24fnvY~H(3Pz+j#po@iUm|;~-;>dvGjSIA5iOG)d z(ON{MUQ%V0i3T6&?5^5*a=R=V+yhiPRu%tPDU@R+5rf789&}PZh*s;+{ zOi{fkDxYT8>@>^NXlYnrCc#`~TlnHi&6}WvgAi3Tf2(Vu`(bf^#r5AFnDl96Ph*{> ztXWK~bT-wd!kA@lQ@h8>oU>TYP*hot`@!9k*eV$k-X*4$){k^A40rU~^w9t)Mw!-Y zuLL1})Y_l>rxBR3Z2cnz8aYF%iqDnhRtVZ^A}*G`_)+AMFFsb%(N zzx%-=x_nPu`S&Yd{`yj*5D@Jq{cI1ar^RQhCn-P$q+yH=BcFts`2U!C2lmXOZD}-4 zI=0cVZQHhO+wR!z*tTu+jcwa@Ztt_d^PKw=YRxgK=3LLH8hCEQr!R}_a?Kr+5bYP! zE-776S6L-4s>N#O`Slxew$meNjkQo9&)u6X6L>aWkf4cP|8*yA8#5&in=U!=N~@Mo z{`zs{PZG&l*Y=)Rmsw=9)fr8UJdB; zAzoK;tg0ro5xNeVHE!WnQjU#dHwVj!GGF;1Gt0$3U`_BEH_eO~Vot)M)hU4&JxatR zIF}pk3bC{crzn}72mEN*C{jqjJN{1}9l2PWA`7&b!N=U$_hoDkXiUXmv}txmNYHxb zV`;&km|}r{q+f!Nt6C{CCoA-LKk%o$R$uKTe!~b$jK_`@0AlQ zafb9th+?w0!6&uJY-7ClMvnp5JZ2;19n;-?(-IVkoRua5WFt5Qn3Q=p$J9vQW)$Kt zXSN2cDPxFg61+l0`{g;JiJBZ%M&gNo!&hHIjCcSIv-zVlO>1ICJ?RZ`uzy)qdB#Kn z$ct65|9Ka$SwkTul>JL}3NIrhv9Tyi8{GM`V0y6_OQIRJJGM%VhE{)qjTSWPhmv0u z)uAh*ja%MIC5B#Xhdo$>ez(2;HT2Wl-8!CVvXVYzkau#mWr3v?#8wsvp=IC>Ziu}b z_Ir!C!LC)V*B;StyF8Vhnwn|AUm~dAz>N!W2l%U3)pXOj_oq6x{Ueg6=ROeTg8Ta`A4tu32@>DQBT@KbCWfh~bfm<{dH zjOSfo3iqpCabCc#aqSj=Q@?&LiL3~k9`SD{bUbb>$>PZHevzUc2!V6Oi1_7c1)}E1 zUKk(PI9=Fi3sFM$E(=;dZoaB^^INbyl|Y&FZ{I`&(}gQP4gEpry7}E&tZUqF^*2^Y zS9643NLT@mcmW7Y5aNhu=3YXrXJ_!!j?C!?HU628nLJ;B-4;mXMVfAU7oh>)pc{50 z*DLmiCfr~HTE4`dF{zuYFuMoF~6tG z+l4Rpi2SI3tPoSx2%NQ|i+uA8v}oPqhkux7&jf5y8-LRHZGZf__U{p1rOxNAVEUfs z($C4nk7ZfCPQiaHt3Y28wLW)pw}d2 zkQ_bMm{PRBHdxDEgW7lCVA~4%b0k_u=xVch&N~;nnxEv=9-H}y@1A8fz95c{LtWd0 zh-eAzp45l|!o4;?7CK@H-@a%LfoG_6#CQ@ke#}7VW2{mG-p4SW&&(=Q7PAX;p5o*7 z(e$5QobY-m1>Z)VteNF(RT!qRX&dqrw$ayX81(OGXO}D1(WBnR{&<`>Lpiy^;#2I0 z-xP9Ei}W5!G5?2s=YZe!cNWv1apkg^pZ556vJJI7q3PmrS_uShx3-xjS9A6+YhtKN z31PNRQb<(Kmkj#=NIsyn_lVwfFP+;^7qrmcW6Xnizc@y=8s10DkR>-^-W5XwP!;y? z)$>OlpYsv?M(10N+}JE4Eu)|rd_20KU2)vfiezngqYJcIFJ(c~axYv0>zN##2{V3B zTR!_vHlURz5kut1&w0ki*;n<9|FrAB zBmCF9pXz;6au%NAy@-wQDJ3QhjliTsyO@(ALzgUa@SE;0r=km%EZM?Se_8Pv>WS-} z$02Kc+2No12Q{ASg%aT_giQO{c9xr4vI2mJ6|+C63w0jzI%kd;UluoV~^;w38Lf;=(28wy2O=j7unVVtCe@9wuX<$_`T>g_>8 z8#^WX3*j69ieD@=B@mf_v6^lO4we^tKI#>h_M2;z<0!Z(-R;{TZSyE5Q- zeXRcb0A6yMk%>Ussh%hp6X(}e(l)Y$E64pqwfkzxFUNP$ za7-t&gu6A@uTOnk(o+Y#@CrFcd&J(J$(Ibfjlgpc7ye7C^v63De}@Eb$ekbjsDJ6v zc1yxK>god3`iM@^OPh+SU9F%0Q;z>DB{_qppk`(&%u4Rdch7@~`M&P)?rpyhwo~nQ z(w(I)j(7ILJ0Jz0Z*9Xkcw_CpP+oQ{^dKv6BwH6(*~1^EpFO07^2hiTJcPEt4h|74 ze!5HTM9AWy0%xlYfbc4N(4?`aqFDqgyQEsx`RQ$$q7qG0y1~pq@Z-dVSso*&~DJs)6bEr??jQBYT0SqdV6{YJ7ns> zVx$haRWZZ6d}%@$=GzYU+rLKei2#;zf=W}?gi;s$9)@Jm*I@FR%_gGtT$l(e5(=9> z{B){ULRa2PPY~9%;*b9a8~;ahOveh60-x|c_UF)sn2*Fv{L#Z*AhPpZj~uhqH}o7_b2Set#I%oo4fzVuDd_hv-o)H8;uE z|8nO46zMndC|MC|AQxvT0pi1s%?q9oZ>Yo%8gw7PR(D7ke&| z5#*Z+g3a5AcLd_p-Q9(lvKdC5wnR;uSzq{zVR1_j^x1z1AO zBX7+W?_I2~nlefCtX&vs$+qf&61EWr>Fede#66Tk&6k>@>QG+khyE)meAl!ZW$*^H zysn2yS00Wr!M10DUK?L6s3fCSZRP)pz<&Vc zicBFQjJloTDj1NvBdECU^rL{%qWY~|;?p7Cm=tcz!0fb<)*aOh%j4IMvn+G)5X82+ z#JzW_qI$>|``?@O_;M*u*hGY87I1ZQOITsE@n<1I9*F-Hlec;6loGRpK0BD-!~>1< z|J)n+k`;Z-#035={+h%wkFj+}ai@ct;;FwimGD~q{<`7cjqvgZy!Gdk+np1f5-?fU zK8RWGZp*aEL&ETUl-B$Iq{}DnhsQ`R_6?I+YHE+c?tLZ_YBPd!l@A4JSTv}k9D56^ zvZRfIiS($5D9OYJFTxQ!!H?yP*w?a8p{e1aKilGOF_!76xbTk@(p1Bj?LpA~jhUy# z;tQjodO2NO`0>*1SQq;CerN#?2m;Z-@yYHsywx54fExXdxtM+`7J-EuSMiqRV#!1u z`DfU3!%g0mF|TvuHg}%q&}Y4bVic^lB$@MyqvTGDBCL@V_yP}8b$ZvP+ysLnVdDJ~T>p?X*6Ie^Mz9+bXCLJ* zNMPczH*Jln|9=^};|eeRVAM<*!#*{^m&5z*S3_8UkVm5o-X*=!G1&Eu_m`!hzB=Ij z&7{4~Cv-cG9f4gfY#eCIm$B4X`)Qkhko#Lau#?k@fcwCO6H(U=S}8k@*FMIvDGHy{ zQ|u5#B&_<(V(xi!c)P}BTggB;EL%cGW_48p_5Tm z&B4~l9`ufSBTKhXm@sY=8Tqi{T8RD{f`YP6!EidlE;~}}p_av!gllH%`3ul{k*l+U zcJ@cbP&EC+f6UJbMxYbR!|&wh%m4f4#_H++E-K_m512qTjI-Qp;d_2`m;*xNVHjHN z)HEx=O2KB=W_=LJL6=P`k@&Ug5>^vR`gIMPTpss7(8riW0U4dCY`Fg=%{7p$ggUQxppZWAi!l@qJ6%MQc+Yx9@s zwEXaCd^l(E^Ej#m6&r*=FvIC$r=Bk|BqTBgzX^f01z|N@$!;Z6Qnr`o(0u8^1A_iT z2_tnR##QW?MKHh_aW&XpE%9%O*u2ju7&FCT*BefdoQBA(Fv^~vi=R(^9!#+z6e9z( z*quSwn)3h-*}SofCokE|g>M;e04KRVo2{4cX;P)DzXTfd;d8E{j#?0z{QN`qBr8l8 zpM-RzOM~B&vfABAPa;CBZ}CJe%$r8qb*on68jQSSSJE>X;eOo08GJs&BV@eBx#z7-upjUM1tCDth&sWxv3sZ=g z2q+P#`lL_U6an!tDl0uTh-a~idV{jAX5}_qdM{bcZjXrI3f?4O zno`bk+pA?OIJp3QxkJkz@&~vki~Py*uB|xMl}XP4_W-FPvSytM}!PR|)T)6~tr`+7_O9SDX+>11J z?jDb!*$;O4ww?SU>LUM?P~zOp1%;af)J?aFy^xjk3_lP1jm1IwVYoLT^MPgrzdB0x z#@Mu*jK9*Xe;Ukbo)N9+3Nt@P=zl*rx|=5QS5_=Wl(+0URo{$7tsjiyVG`=TH%u+4 zjX(KfH?(x%$JA6pyTnKH0(2gH?WCUpU$;M{9D#f2kdkYVj%Xct9(Qa8_OFQTpfKY`Vp zE`Db?`sO~-bKG+pA?+*yXcuVP-C^6EReiPQxHQhwl1a8D71*1;MOzq(+Umc^*$b1E zivFc2))Y1A;nQjC_&7CFq5p`Fa1+OSEv#cgD-}^=|OZ=`eB;dD3JanBPCRnmTG{&)*GfO!YE2tusgCX6~rt@$tQtSRr#{U1d>T-ut|xqjvZC2 z7uciUYlb^SMw)O@7524V*r~p8$KEOB^7rzf#c;cS24YKh_`&L3Ubfcb)NR@!+SRWe zzLN#Ah(h2@UyB{8dO&A&T3Fq@xtg1jzwW)e&t+?@j`W9oj#7(R<%bo-N<*LR>fXEpiK|(e~DuB+sq%lNF`W43ozAJwgZ#);-Y8AFZ zTpl<%UFGZ|)sB7|yHp{=Sz%S!jkA^VC$72SBvI#u>^JC!TJklmbjFCAgSYtf85+)?9!pBnE7oJ5!a#pve{q{J)p2@;iTU2_ZeZ| z;sz))0>jcg*3jT4fkSW`HbM0tj*fMvH&El~jsqA6j41RtoJx^~s?U9OL7@82jp1p% zm9~efhOG!a|KH&`jqmi&vFI>=j=j!Xn~$renGLS-(pvfJQy}{pYg4$0iW|tU*q@}_ zP0=LKIW>6vB&Wzlmkv${ES4K+!D+Yqvbfq2g^5{~YbRfNYF>!B9;a*Jw=g6U+#IW$ zV?dm$J3>KR;p>(>yjVUze#O> zU62M3DhOO#H1}{ap~0?t+!@n14r4m_(j`Jfx>u?gfkhN67x5%UwKDz2S#zAEd^VfY zT59y#Di^Q0cUupI!$MsXf-)s7Rd8&}a<=_0Bn!sM6kipNdSl2 z{nbf}*K?^|qjK&IsCDF?U-JLoSpeT3HlnL7nC&Z6{JWRzLf_AH1c~?S`?-VyD6r5J zPoo&k+|^j-7~DUMp~S~y@6RMRp=o;^0iik0TeD16Y6!rTC7@(Zsx3sOOd&9Sa~4X` z=QYh|H8UqGl5nRd)R@0muh!qpwtg;c`M%vQgbI!Hy%i=cR4lIQ%4Z@n-`wzyj`do5 zdd3!Y5d{~PDPz1&>uU+2!z+HDn~Ji?V~cr{1g(p2{cC-$GM|LzUo0iSaPX}YzMn3| zZ%j@E*KNe5FOJ8RqPKIN2buv88;?5PvNhY~qR_ zMfUdL$Z=*3^`B5bL#zD!i&u9-Wmf2Fg@Mm8<13UvDg;@J94caar_8Q7lmIVY=Uva? zmKy3X?UhDM2ww>fd=MabHD?7C@VfD%4Z$Y>UW$aAY?`9x?F+++G5%KQ+Ftz zhSDrA72g}1d6=3=Qtko2ZJTKe-Xs?1o3Ey*og4}GwDXx2oF|<9rE3Lhd3h-fmfQ>> z-4)%?Il<{Iy7TP?9(;aXi_Vu+kekRc*3fGcF?9+*5X3woU|)~;jr4T3l5~A_HV3bb zZ%v@P-+;={PO$FFR5lUUCa)DN7U>Zc3YPLtp^&W5Bl=Fet+`bg${5PX>Ui)A?`{!z zQGQPRNivgKA?v-4mwna2#-}!fhrKYW<0d6H+9Mh7lfo^yd`6BB5}0JS{&?Xp zNk;4|qwH3(84Ou^-E^0m3^xt*lsB)WC-uKn4YG;#*;epW7&(Z1ZjcRQ_qronM?RC% zC3}06-!i+t2KTt5F0gpsvMZBHaUcg|jZJI_WHZTEdyUUc@Gb$~RdO#mBfxo4YvmZ_ zH3xKFr#Isqcsn6L3(F2wo~VbyOWB+-HmN5-U>R82SCi8BjuP)DXK)0Q2~; zVX@H_PiL3hgML_7HuD5U5L%<5nY8pFp3GWUEgdVr?YSp%r<$JcizZe?2b~4K^&2MS z9p5RN=!B&ehi(9lWS!cdeuO*|HKC}25c*X+lrS&QToB`iqmeDny zj+j%Yf>HBv#&-*0%$G{1aQS8TDGO4?Cx`=(2cJG8`>trcz%Vc_|H8!f5Pv~HSeQX zQ%=To;$k+5bb_fh>BUNNpG`i4HHi%r-AD9SqrX8EW^Ik>j{8FHCf$#V;A9}2-OG!`ml1kz$C7oG+ z1%bqX;p`G$bX2uyRop36!t`uU>ew*QMy-2F<|^jV{6Z(=-?VKq^}Tq&2S@!Z6O zf+56$NqhS?19rmwX4Q<#X4Gk2n&~TSi21rwguly7G;@N$&X(yrS-?H0Fk~UDQsB}+Ey&0#y}Yy zX!k7O1y-ABAH&Oy%>ox9dPj`+VdF-!!Fm~vOH=JAU(e}N2 zzQcU5=+~WA^r#=bympfa5yQR%f;pF2JA?-_Q!n|m-nacd0GJ~cG?+T!#b)9N$bOg@dP~Nt@d2doA_Hq=(;63b?g8 zHZd#sNsL>B?EW3!vbG%wA8S1l>!{2p#4o(a@-I59qPoT+NUDjTsS8x=V$0jZ7CY<* zr;%(f=4N|7X^H7ZB6dceQgWGGlNtcikaNtCbbM6{^J@F~wH?-_XOf;@4xRM85r$|L z<4ilGi8d|?xJ!S^FnpJ#W5qbq&slXSD~X6wR}8OF#*gGJWmoR(s%;V-W=~cP;22-% z&L?tQ;PT=koxJ>^Mpy&M74b)11s9&|#<)uy(fVd)pzi+cpS&xkFotF_VIMv1$H^wr zuY07!$<5QnY&WH)>tZSt%OVBB%aR5KIZl`Jow5l$jbFrfy?YmysRp%l@o(KEC2TzK z(>PYsC%5Em45?kz^mJLc`AWNPf>8x0A~J{XnTp}a$U3KUqE9IRhk60CiKs3i4N++9 ziKekOG>3N;zjli&_QP*Mvb||M(8KIEv#mD88 zTrw2O9Bhc+CbiY>SluW^MojR(U&p7)HkPvaXrxtk{Lqvmg0Nk~qJch|&jpziUPQbY zk2efd5TAlQo!9AK!uU=e8h`1BU}R=q{5O{+j8l=MEu_7|{;21+us9BtjwvmxNmPFS>5j1&Bz)Rcpm^?t3}67kOs{76$dv^H<%uP=3TEu)5Tql_wv~O}zC#d` z+@xeI7Z&Bp)jsZbsM2n80#p6FO0X}U<45<;jd}PR6mFbhv-tF{4-GrFK0K2ZOtDib zU!HqKxOJJAg?=a2G4c%Jo@hi99d`~M%QyLIdz6&Ad&uV|c2bdhJCDiDGn zI^&6B8U(?loS!J&I7%=|QmAjqhG1JUhsR7IW6_U-2Gc)?(Zf4`U-(xFu{`hKJXY-k1G z=s;^7V_3ZR2WFM0c@LZvF zctJNpQ&rSCz@B9kgGD}m;Wmmzaw13wUUa$4GON`r%MkeuYJRP2S2aVUSW9&w2=zy6 z$vv^+_=Dtc3B#^kyvwPa9d4u5!Sn&AZ(oL*i@;Qm106O82{u_iL%0qLy+twgoV%_~ zEwZn^I$Bv9c)ww;O0IP*XLi=1uu?|jM?hCn#v?OB7ch~%u?^l>ilTaL_sX8NVEF|5 zu;FHIpD6=W7gXWZ z({K5v@>OrbU^=UgBiTpF;&(nCTsvbK?A9-KE9j>Bts!XtZ2hm5iQ}L=-IeR*RvyHM z?jRMkd;QQWytB<4li5+`5N+;#(8g~L_|*p}f|NUV2LyHIagWEsS0Bna;b)OM1g^`I zKZLt>k2~)qT2WhRL{cMkXDh|3A08HqP13hz%borjJ&N7S;lm|_Q$z>1P2Z_6^=Q7T zxx)+2J}7ROzMW$4Z(a#q$JWM|-cftHN#L?+f8#3LB#H*lpz;3b3AjW%sp0(La?~@; zY=-Xhd4bvU0>q1N$R!wYV4zZjg0#83R&bxiCF5KmfLk| z8s{OPmSx>8bXX8C*~c)$Azn99WfXs4eeu2zF-i zFXzW{K7-{BD$CAv2mc|Cf&ez>l;o;uFhLWSml~(F(pn192M&xgfSqiy1)$n3p*X!9 zgry?!q-#+RlRuN|Nqf%O^eGf{byIsSBd%dcTKmoSiu|f2@dFD##%QHS3wxmL#Behv zsE_2tMA5PK&B?{qQ9K5-)g0;JFMHowq;D8_k|GR-M3sNfcj=n08(LT((09Vz@T>~w zvB&Zywq(UGU$wNusPo&MDYB*L-}=Yl?GnXSO7;@1)#=oUM%w%v`;(!z2n7?M*!Z=Z zVul7=JxbD?<^CAhpFJkxRKf>S8n{j;KkO|}YmVQIoqx@&C z)8)@n>?2zMYsOP)qQ18phY87wH#Qkle<)d5g-$K=N zxymu%PLZaa`kQcA?=cXaNiR=6Ykc>vV28H*e;XLSXd}K_IcFPSjOE~J(p5@=!XS?h zhm|}9e93ve@KpW2z1wh+J1GC+OI0WL46_))+7$2KsJ)8l73?eEt@-q`Dmmms2An?j z5AP^@&E&7Q)OZ5jI>U)07{fZowlX^SrgvB;T5*RhMps~#S4fcoj2aE?MAy05pl_E3Lc%fC8v=pmdT zhhL~mhKHud7-Rl7@~3I~k&1RNTt05A16T{1L1JKuBf&;gwOMC{+S2`fRIc?Fn#ZI7c-^&IWDRCn7| zRY;K_&O(Z1>839bi)F9?M1JD?lHrzb6}$~0LZ&j$6oM|d1w;bYlY*#rPES;p=^`Op&>v%C{}f!7YR{jpX+|b0{PzW0NCN#J^0TL zVK6W41@JG3&<8Cr57v77n7;*2{uAg$3vTw#&WK?xz+=^L& zR_x?MSHz%cQ95-d_xsxt=q*9s?FbQn#}0qH>=aY4YVh7w!nf+H!@y68ov?i!C|WjM zmA2;IpGn7^TEVh#CuNEQsx z6}Xgh+8Kpa^A1T(@)8?I$8~1V`#v|$ zsU?dt+9*3o&ApL6#3r}{Bp6nAoSiY>%8ZQOUCuH3_|umm_W;D8;5ZZuaS!*73*r@A zH<2neUchicL!Lr739igLn%W&*$xa8|?(7GYjo=$I0zqlc#`aqIgRT zm0g#J%1MU8P&LKZ^GBwPv~gTv$xmjwKL5tIvuxDv#gIdxKTHsxCJ3+xleBV~i%}wg z&G6wg;^U^={7f+Lfs%;gDRnD4E0B^wS%BFe9O@2;OJVYt>&jg&SJxM&CHEM`M1E26 z7(-A1BQfsjrTJ@<%zEbpL8e~}x?m5d>Rq|(wORdGfDS+56MuzO^IrUZn-OEd=mFK$ z(;wj8F$zc9NUWGV8CFo&jPhy0X^c?>KamoFk}pPemUgyZ;{5KMJ)Q2Q6WQTU?>a)# z^k?^+$SQk?{8_gk?}3ENw~r4nw=`N6pfY~;4xa18Bf+gVn>jF=NRUem zt#}z1g74QX=T&|r3xjzq@wzDbSq^XA?{avt=+YCH{J*!}3s0*#49op+*Mz5Y5rCJ? z%;F>QfxHYe+E@LHvy0iZS{{BSHl8Ivr<<7AhosV3ZU?4h1Fh*g#U;k;k&()g3)#(O{LKcWK*yt!vvN;!j=`;BL)NA8jp z+PYzvpToWsb{2bbWJf#4^b%Cn)t;9Q`TjPZ;4Ug#PVp3Ze94#l{Nc#o=gOG{FVUIX z@bBrVML9ii{##A<%jTS6HifD<4=|kpVKja6{Jds-5ZS?20TLZ}(J_Lljz7HcQIx<$ z&5~WqPh^yOMqq+8O!1^8zCk)h2QZlDOx%NAemq+K?m{cE%SxLCLzTvg`JfyS?dL{e zXf01IJXYhdghE?oTc*^|ZDvt-jq#+MCKCk^-gEN%!RBBPUtGD`uEmAL{$wX?au1et zwGuP@rM88u;3AL91?!8K2#E5Sn3dvzw4mKc>cv6xKTlEBUm89Q&JbGKl>KeYrL;Vd z_1feD!BTf@NI8M|PSVHk{9SmO@4Ir#*`6v$-X%M{`kqkhIMrXbK-ElDk+f%xpJil= z64)l(r3$IGS^{HdUiBX5&Qy|jd(i&WqU{erIO#2GtAS-}WK2FB$_QP2WTrj(5)|6P zA(Q02SDfMM?`Iu(uf$}BWEUvflp9*4Wep;36=XD!Lz2;YbMop%nZ*ESNT*xU5Yxh| z3^A}!@8;H2$bjtyRW$-jM^x(6dF3l1qh`HLzWR>obC;c6<~JmlZV%Gk9>%<uP((c<#+u(CF)M^{6a9PNfq4Nsi2uNz^g zDy$~efAMrrcD09EUm427MY2hSd+y_LGGiR&?fZu(hV&$$VHUWNaLx{j5{Ig#P#5Kwp^rBHi(z)Co%3CPor z*0R%bB`mt6O~nqDFKL$+?r{a#n7^ zn|bp-wW;UY^*G=3R76-|A2kw%~RiGI>QDd3@@^94{2_&Y)CWyCWN5h@jP#$v_Js7YN- z`zTYl__MNu6+M~guYa=q(QG}!$>jfaM|fODs7hBTnGvN)N1g%jX2wZNKp%#q#u&P6 zXRX|3r(+!??A1di&H#kb$lPkIe14LKO7swR&eZFhp?_rf;U7)mM)qL`PXd(im8lvW zZ~hebX`_4607lB8!yXHj>og2QZ^ccfIY+|v+JkL5VqK6pPlBg34kBF1Mlk~@ z-pHwBNOyfR5mAZ2sF*iO<^mZ#MLRW10E5*$HzvnPm=|qaKULS=P)O69`CSgIxK*P( z<9Hieo~(f2=%6Bw5us~%ed|a%apkG0VsS>Bb>SI6o0H)y@LygzcvYNr3+QyH9TS3^ zaaROon^R00`|I0E<*IQTv=r%(vkCIx@!UYf)WtGXxC;6t|B@*r=k-mZkd@OdTIha( zHqEfUcu2ss*Xuv)MuWjN!2P&X zGjbxQ@xx&{rdPh07|y>e>nxZ6*$V&%Y{^Y3}2;SJ^v>vyYI#O4VaJ}GOQOVq~@hP;l!uEG%=YiXvG z@x_{FS$?|fLreu29o~vO3+Cu7;|Tvs^93)1vOsACHj0^T0X;^#Wrg&kf*9R`p%;;nB~lV{La5Il6;Po%O#iz-@fo=oex&8X|?`#%9Au9KGOC_)MnDIYZN&j!on=m zNRD`_1I(937#nT^01?o9H>PabiO_%>g~@Ld&-pyM)$dY>oK(Ca#>7G@nQ!Xeq7h~u z+AwU2kf83iDuh9rHUHIqlV|?cC!qtF9k!!Y8(5s<--_-*c-pDG^bLnj0jWS}RQ5f; zL!xsza`6#gk>!~8+p+hsPuNP&@4l=r6z>*mh<)|6XcbYys){Mw1q%ET-U|4dJcryde?E0_6mCyV zo0Db}-@E9YnxqquCW2}{(7#IGG>k)PJaK>KaVz}Bvzlo%v;oxrR`$4UM7Gp{YmiJS zHVTN7JUS@?$$?qZZ)DkkG=T($qx*1T+W?rC+oL$R18!NTxvQd- z>iYZnxv1yq_xLO5p`T?yl-)oO!VTqlCrR0Nf?{5ihjWJvhJOrDfU9oWGFg*itHze6 zDN-6$r1Ed8uXHzKh)X;+Nn(gez}6Dbg0=}e%+nt>oh1pIK}b`GDGDCb+x(JQA7Inb z`C46nqeW4TlW9Xnd^a*Qt1x1LptN)~#+7z!?pmI|!5S$aaS4UqpI_bs?aA>zM<-aD zM?&)gndd&*#8$G~x!XE&oG<7ecSH$XiuZU?(R?7qv>M3`q?|gVez8WGROD_A2XIQj z_D|Y&3Cg%lW*mjzI->^FtYovGUK=YSoqgnzFl_7tSL82vl?`BNn9@JZ2_%u=x zK&CYynr(3Z+7;i;kKCRpMdYN(1J&g^;W7HL3XW%SBNS>ngtQVE@d5f(rQd>dE+ zxlM7ZE|t7W+*!dGXKlle>*$}8Ejb7~q!x5rYQz|19ri1e$Ho##00mt7;u>D|i>8tsHUqjJ%-b6-}bK{bKIUk#nipG$ep{$Hd zDGLRkgyA$9GD3o%4bwDCps~OSa(DUl4Wh~y(sl>594B!T&-;=SdTtrxUS5&fMP;zM zBKcs}a)kV3rg5m7@D62&@R2xk+e>`xNc;C}mc?D^A=MT4zNYqW71xs;JxOnP3NBjw z-ke5EhZrR8deX2LQYSvY?5s6cG2F8)ZisIiXS1f_JH6H$7{d*?vitS7Xj^JgwfY~l z8u)K~eS$XCP`5~lbfY+j7JLjzMk{6WO9bv;FropZ+3lZLct;L4AsOHA5E2uCZZ}huRWw`r z8)alKL##MMZS#r!M?QMR*a5SB06<;FOKU2UaI4{9Kr?5io^KeZPqKRMPj#-tilWc5 zmlQVGmi+<3Ry%X0Zwye4>21()wKk;R=@b8(^Bm1`7S{vh78C_%_DSJdTe7VV`BsR0 z!BB1O1M>F|)w@zAeIG2S@mN*~mNnkr-wU5wuMR5kN@CQa!gdLT<84~owUMWy=sR5;r00=Zj zaQSf$S^u7or5D?~SOGf+f7SLQ9ab>V$`l|&_jB|4e{@!g z`H3ye#iuru;qrf}lp0KXQXaFMI*~@1&-m8owiG8DxXqaZXje9Jt}V^TWc`c;r=WuQ z&=Evty`sOUMCnyK{56p|*EGuShMOCpxODiaDxG3c5;y@e$3Am1r(%Y%Cpz;uCHeGB z-surXHl||Y+k_)w<{P|?#hm-;SVa3X=1ex}nIW90Fu$iAK&}I8YJ`BxrGAxrm;#GF z|G|l!%z_wzGpbS2Pwok=im)sN91_wDM&DBitecYiMB`4FW*9FP8xrm=P6>$6z;b<3 z-YzXKC19kO(TfAM=qdk#&lj-i*s6~&U~;?3_$blF!4tz~D!0@9a~<6h_a(s_5f=LD zzb_#+0<`BMnly9xdKu%Ik-*18WXK%ZvPFHAt;Ct_w@sAv!Ly`WbDOu`(T>EzjU?X{ z^3o>pe(O6;G&&_o-u28L^dhN*P=Z46&JecZ$DUg*GPqxS!UyM6S|F!1$U{8AbL{T8 z1iR)Z`>sXBAID?qyMG^>q4`~ESB>x$d~)NGnii>~;1<*DJT*y9xHX&L#(*o7Fy}n? zN}n532;^HB?UO7(GsH7;>o@urQ^<23h-dYKR1Lpb>_vS4`gk=(9swFociQwUOx4F( zj?e26d+2LKNt+Fv_}}hjRmGHxj_>vn{(}y`k(rscnhWz&OyRIsSRF;O_OM@P*X@NI zmuhh%+_`Alm~p}!_2%5joDEy#4=m#CD0`kh&n=9pNLJk)5wN*Bu!mr(NbqTEaTVV> z%v5H0#u|}Ie08s^7W(x93)$Vr)FyZeQ08Q-ZdR(p$63GPs{dIxMj@^+@L2OHh7{Ls z?ERY05WEDK9*CMKI-}ezJpn^VnzmA82>0)D3^kob@(W|xAA!2t`nP+Ex(m9Qg`LlT z<%s-V$e&Y|C4RK6t0X0SF-P1`fqxW2b0yiB1h!`a==E=*jSAAu9Y|sRbyWxeJ7n0|HN`5FGBN+l6eR^FB>H;8Xj%ZOz``EN8cH@CI2w$T7#OhtxsHzwzs2l9qD!hgtRt zZ(%g7ho;3@)H85xr^eF3vSz*vdB45b&3-tYci>Uxj=nRW#z}eIC>ymuY?sm^41J!G zIys!&^ze0j+A}Fv%qhFq9+;6C*6tY>d$}MSQc|667x-U3yU5+*dHK0i_|f2B_e$7V z`cpqvPq(D932)KX=zys5K=zRbM9pcl?n}!z&x~h0VC-WBtW4#Kf!cSy&V6-KYvWE8 z1DAYc4K&88hO_D>jEilPCO@8Qp&4#xkO4H$>6Jq8mzGJ_dN(AFk}KWi++9c!PjZZ% z3?BCu29d~YnH*Ll2=tTtwD245{{HQ-u2h)>#<`Bpv?4=+0*Hh+-9U!^+166+$Hc@K z!Th=E{d!=2?0C$*Fptg(Q^xu}i{6fYazF_ikTYULF=6W&wVb5rMbpN|EQza^K=YE3 ze?|WP0!kLO>EMM0jSEmy&nkNgpwdNk2y%^<4qrn6igQo1x=GhIBHQ1kNc z{{`_BjP`Jj-fEO>Ba@yQgWEjvAy(@oUpSd?i%;G=?3x-jNlWHs%p&Gb1iQ4ZB?qBq zGRR@8SRK{vVYzj}Bj{ujfXq2{46&Da4K@^P4MA#kYbU^Fv>)*c}f}NsO4bZbzP~Uw^IALH<`KDp(|4vNIGK8 zy*v;hy_K&dl|~0r5GCBstc`5jVVUQMn930E0-hcfkFEP(8}H}A*T{XDa!%FvkuY#P^+i6=oxV4+8!l~4>NHn|AcL?<{!Se> zGH_Ndn#tAD+^MdcfGXz8<`T{NKz69zU5#n9fr}7eT%s=}e6nH5Mo)$>*#YkqAtNWF z!1A0-hw_Fp+(n#nd`!ZOLi~6(CxSf!V{x)+xvWN)Xu2+DUan8r{(5nmTn&o z6ZysaLQ0G8y3iDESrb>dQdUhEv31+D{a|y8_gSC=V1#&?F@JK5e7s|D_gVjY63XLd z(Gj35m?b6K?X!AI^mUBMKFSM^`NacAmHNocuzaoFT7ebM?d{C>_SMlsy%U{1Nn5X$ z#1t|`4n4&8)Zv37x(o>#A^(Gj|2Rf1Kiowh2G8DxRe!z}Z}Y6Hc@nMPK+HLR?K^}u z_zw%uh0j8_CrR~(+2@;7@omCQsW!(OW%q|*%mebKr?%cjOMTHl^p5AJ)?WEpvX85+ zronu>E?g_6z4eF%&@rGg#u(S7$>impT{D;C7+eCfq1XLhwge8qTl?{fs*OwE+R69YZ!j~dJf1? zb;SIr2W7KmI|fkv!R_zA_e_dPjBg5>miZjXdMXH4w7281l`MJyU%S^!W#;4-xx&J&ZQY!hxsNb^IKdGhfulyX@Lw+WqpMLlu!6YAVarYdT zUQDMN{f->z(W9ZAG69CH&`~dG*<9vhC^-Ptf?upd2%aL6bEz zH-5rEdW>u6^`qD2Msm2+!7>Ii7DY)DvNB3iJD<#gh>QW%i=@n#WX%|zM~p6S=yQQN z)_LCWT@MXx>%NsBqe|YWV~~s5x`_SHp+c2{#xFA=)k2nyOm^I}sqNr4hiNt$iIwrCA+8r{b=f1*vC?Eo3TwUutW~oIe@BMV+`3XY_Sn_ zt?#*%Fji?myVvr4H4F&inrgAKs#F;+&uNZV{?Vc2 zMgGkHv669`QYZ~Oahd)1KYu)*c!o6#f^HgWW)8CvQDC46AsEtR9x{g3xev{T5o#BO z7%#JNK{IiQU(ycfsj zdsqS|pZUvqvK+4CYq;eOBFhX$fHIV3>0>n3{Qw^c&2g|kX5n|&oG%3gLm&cInq!)Q zxMWf;4Yx{F9ials8#}d+_Qn#l^O{|?oc)l#wuF$t%p#ooz?5dD)XZAIvMXwe0BdWNtT9NoOfk zGIp~viIBaeJ#b9i{Q=OISSuDb*t_U@`+BP}PAPP6kU7oW$}Of(HpN{CeZAvB^eI4% z&X3tMWzT|6 zyQ=7rVV5&hv3d9%^>*Cx=Q|#EM_{_bKtiLX)oV&yZZvIhEZIIO4(Nfy`p8||6rLp$ zTy_F8^Gz&f{Y83HAc6jN4@j6p=bGGpU-{dI*QZBu<>)i+dfzBI!A+kT?{z=LS|K5tGG&oNu%r~-@C5z!yCf`I&#FkMul8x%i7*3F~?Rjr{M&- zjarsN)4SQ{yDI3gp)JSIlL~UY1x4mX3DEL0W4$Jp4@>=9vsZa&=n$d$r1Gu-La`BZ z+I3xFmgZLnKKU`-1;T%0vBiNB(n}R9;3pTW;UMJnwcSR+l-aV!@GKN{J+wP48;p%e zkh+7b9PCtItOCv3Qou5ucyv_g9P{I-axhg}?(1D7no)L7j;(PyZYo2>oUA?8xKGk9 zT{;clo`pxwNVm<>*2%3FnDc6tpLAz9_S8wK=f4eW^HFNQpD{_&C>}Xg9f~oasy~-r z5TwBw!}o)KY&y2C*2Li@sp$}W*O2tNoP)GT^%P^(1}NJr_&k75JBjDCtGbrQ`J21@ zO_O9KmFv|LOC%~;wn*DiLSpN$bE*D!VwgpCT4e8pzp)YOn$78P*R+XJzl2&Qq?}$G zJ5xP;Nf?SqI}tZSeM#q^nY#w*-An=!&A#jQHyPVw~Fu?#eK=AO@T zFD(7~fXWEEniwymT!4k|DPO%svqtIjn(opjkNpwhE9V_kSwN=}yfz!7ydCFloG5NB z;e8_KUdl#q;2u_G<+f(cQR{RBuM}cwsaT!e@crlAz}92G@tAMOQ8ECqi7{mQuo)~Z z&j(c9frm<&mR3fQ006+0{PQQZwls*nX75Ej8cG}>Y;l2PxyTtnA-KddJ`5t=->q{} zHm5k@ES1aXQxIbe2M)WrtS-W?nMNZdNh%E>9H~z(xerTi-RV{dQ;36Cy2pz=<|LGR zqpC6xXSjBrAiJHtooH-|{JdT!#SQbsDmPa64w2u*8OpP9R_!Ju)>t{c-m^9l0gu6w z$XLj2{UCa471|*_?g_O=$M2ZK>DawI!kNwM2Y9e+e7lGumwF5Fr}UAeSj{REhr&l~ z{qa`)Zom7)-vTeY^z`<~Z)%zI>vzqK;|6FU0MDfUrK+6McW{zJ$INiVtS!|2M5Br2 zL1ozxl&<5h*5xiz!L3}|?sz3~0jV(N6c^GH^->ZOA2&K03&WpAfSlf3y^n({KAw&-ov zw1t#Ylwa8b>n7s6uT*0%N$7R2+j1u}uP$C9D;yPdMJj1|Z9)Q@GEL0r6DMB38bfd& z;f^@w?h*7vYkj^eH}7JeHos*(OxqwUIb7}}Jq2`aDL@sfaHtHm>(ZYgUL6gOZ`6SP zm_nb0y1aOR8D5@(PSY{GSmtlj0!>07$bpwgXLO}<=7+Zip0}1{cyXyx*^~gv7O;0M zo8Eu`BqjyLX{qqW6Q-8eUn&!?~P9U9GvAtCjpt8N-$ue*BD~#tzk`z zq74y-EY3{FfSCf*r_fY9tj>pm)x_@sHJB%K)DuCEa~4-GBOFf+7Aut{r9|QM0KDG9 z7BNC#LLXdSmd}08hFJHrH-hf_oP@uu>=?M5uqFE^QkKS9{hG%9YC~7wrZzeBtWc3l zN0)SWL)ixs3o*C19AO)Y8zl{)1_7#Q7cpFjQ}OcfZ7(Nm#t^Qnf}APlVFwjXifa$ z_1U##eQfk8{F$B6+kSv$k-32NwEH+Udg^#idMvHrow2g!z3AfRgE`X83eX)VbZrNy zjAtv>42x7Qmc4f#+M1RN;+#`%P<4~Ih-Eanr2<|lN0=z9XhG9uy_?2naA$JNG~Zi% z)^^+}E~t}Yk<~;*rZ4i{Gp!}D{3~^pVe&-%?imSvufJJJ zsb_I;8~%T)AlGGRSX@Qyja?O-4_g@)51*`R9Kd#AMh(R6+J^(M7^uzC-INVjUX_$+hI ztUR>5;!Bx&CwM&3vBH=5bXKJ%#;t?TY$TOH%Oc`GvWW>0&j4 z4D)>jI`L<^CzvNz=2V4mHl9HaVQ?^rq!&SJwrZ$*W0hv@vf*x^gv&wTf)vlk20RR> z2q22QpQi6h!Ii0|QVuz}hOijra?U=)W|TEOBYBwyITw>;Lx;dy4PxeP2P+Tq97<$1 z)uYdokAQVI6~{@%^Lb{t&r2q-=C2OT{rzu5GBrEAmYp`X&P+J6OJzhiWpGanjNhay<5whcrlUK9B}k%6n|4oI z0x0gp|09(vx8(NiCcLF?^;OFz7fR9RoSi=KS`j3V%i}S1(-E_o9WDj;=H5NJ{1GJ| zTVeOjI9}6dJ!)7y(bA`+1(MQoc|I$o@B2dey6!)d4PmeBLoFj33e|@f&(^ozZG0ac zosTh+yWV<(+Ido2c_LBR|DB)PopL@9^ZiAxgHZ8nHe5aAO&Bg1p*QuUZH4=$;a(dp zpQp$2v34TL$`>{l$2c<@Y6{<&!-uHT+V=$4JYaXjSw&dT>;)MPCh{qoPFTg_ur)H@Amz(lCx1jr0`p*53d_54-#zS6Nhypfal1Hf{{OK^R>$-;8 zSWmurfQFkq%(*fz`gBDR8|F;X+-12N9n>+QA zn~D{0q@o?MRi=saouT20A-vJ}Q8dh3GX|SoV^0n%degN}3A3V=Ws7w5e`o?svIZ&J zG$Mo+{y(lN0XQa7(#>ckgt|#JY=x@lH_!{(S zg&NNe)#g=yLkJJ0{@cJF&;%-#?Xy$GbDz4q7r<}E^lkx}K`|@jHZ^z`QIuq>=D6-M zp*!XAXv~~>CERHpSt~%h%GOLl_{1N*BT(v$b)(Ym;vA7=u7P#QX=1k4XkBgrDM!5W zBgFqgA84E2^1e590jIyKN56|rb1w)T!Ag)=y|T}6`JJiD{X5r#rO2vLIy1W5h>iki zjA2{LK#$#eB!ST9NS5HeywKq*rUxMH`*&#dW?TDk7^hc-+1Z^stym)&cEF~xbns;J zZfg5$arVfe_8Hp>bhyn*XVR@1Ag`B*f@8>hg?i9cayO|>L~4)i!}c@31F1gq*S%3a zOkj-zsyxmwR~vK_U3H*oMbZJKnSMHNts=Y$A~O|&ACz!DIb_0u)S8gpdkZxR*-S+u z)gsRoMb1phd)#Ap3+apC(lwO%r9C*w&EsQJvAWy6f=_{?3>G7iTE)8 zATt?Z;6b6g7K$_#bu5Xw-h;=`)Hqkvaq4Wk;u*mu5#G4!;ns?FxDKCjaX=1-cGMEH zs_AU#dS?|**QCqRcjgKo%g8Yb)+SyC?G=i(J5odl&{Zs(tIIP!MYOKm8dYUv<5zFi z3R!1*Hy; z|NAgc*DB<_%5n(l?|HJ9h9>{=IG+0A;H$19%o{=_;Ev|u>ImhVmx*ZapwFYjWo+oA yRcI#+Cy0Mnll+Rx-u=MXBL?p{@NrJDAO9bYU{(;Sof)$L0000`_ is short for Media Player Remote Interfacing +Specification. It's a spec that describes a standard D-Bus interface for making +media players available to other applications on the same system. + +Mopidy's :ref:`MPRIS frontend ` currently implements all +required parts of the MPRIS spec, but not the optional playlist interface. For +tracking the development of the playlist interface, see :issue:`229`. .. _ubuntu-sound-menu: @@ -12,4 +18,49 @@ TODO Ubuntu Sound Menu ================= -TODO +The `Ubuntu Sound Menu `_ is the default +sound menu in Ubuntu since 10.10 or 11.04. By default, it only includes the +Rhytmbox music player, but many other players can integrate with the sound +menu, including the official Spotify player and Mopidy. + +.. image:: /_static/ubuntu-sound-menu.png + :height: 480 + :width: 955 + +If you install Mopidy from apt.mopidy.com, the sound menu should work out of +the box. If you install Mopidy in any other way, you need to make sure that the +file located at ``data/mopidy.desktop`` in the Mopidy git repo is installed as +``/usr/share/applications/mopidy.desktop``, and that the properties ``TryExec`` +and ``Exec`` in the file points to an existing executable file, preferably your +Mopidy executable. If this isn't in place, the sound menu will not detect that +Mopidy is running. + +Next, Mopidy's MPRIS frontend must be running for the sound menu to be able to +control Mopidy. The frontend is activated by default, so unless you've changed +the :attr:`mopidy.settings.FRONTENDS` setting, you should be good to go. Keep +an eye out for warnings or errors from the MPRIS frontend when you start +Mopidy, since it may fail because of missing dependencies or because Mopidy is +started outside of X; the frontend won't work if ``$DISPLAY`` isn't set when +Mopidy is started. + +Under normal use, if Mopidy isn't running and you open the menu and click on +"Mopidy Music Server", a terminal window will open and automatically start +Mopidy. If Mopidy is already running, you'll see that Mopidy is marked with an +arrow to the left of its name, like in the screen shot above, and the player +controls will be visible. Mopidy doesn't support the MPRIS spec's optional +playlist interface yet, so you'll not be able to select what track to play from +the sound menu. If you use an MPD client to queue a playlist, you can use the +sound menu to check what you're currently playing, pause, resume, and skip to +the next and previous track. + +In summary, Mopidy's sound menu integration is currently not a full featured +client, but it's a convenient addition to an MPD client since it's always +easily available on Unity's menu bar. + + +Rygel +===== + +Rygel is an application that will translate between Mopidy's MPRIS interface +and UPnP, and thus make Mopidy controllable from devices compatible with UPnP +and/or DLNA. To read more about this, see :ref:`upnp-clients`. From 6b85392f0086610e1d7b12201fe9913ad162f377 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 12:47:04 +0100 Subject: [PATCH 217/233] docs: Simplify install docs --- docs/development.rst | 44 +++++++ docs/installation/index.rst | 235 +++++++++++++----------------------- 2 files changed, 131 insertions(+), 148 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index 1fd419d0..74436223 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -37,6 +37,50 @@ implemented, and you may add new wishlist issues if your ideas are not already represented. +.. _run-from-git: + +Run Mopidy from Git repo +======================== + +If you want to contribute to the development of Mopidy, you should run Mopidy +directly from the Git repo. + +#. First of all, install Mopidy in the recommended way for your OS and/or + distribution, like described at :ref:`installation`. You can have a + system-wide installation of the last Mopidy release in addition to the Git + repo which you run from when you code on Mopidy. + +#. Then install Git, if haven't already. For Ubuntu/Debian:: + + sudo apt-get install git-core + + On OS X using Homebrew:: + + sudo brew install git + +#. Clone the official Mopidy repository:: + + git clone git://github.com/mopidy/mopidy.git + + or your own fork of it:: + + git clone git@github.com:mygithubuser/mopidy.git + +#. You can then run Mopidy directly from the Git repository:: + + cd mopidy/ # Move into the Git repo dir + python mopidy # Run python on the mopidy source code dir + +How you update your clone depends on whether you cloned the official Mopidy +repository or your own fork, whether you have made any changes to the clone +or not, and whether you are currently working on a feature branch or not. In +other words, you'll need to learn Git. + +For an introduction to Git, please visit `git-scm.com `_. +Also, please read the rest of our developer documentation before you start +contributing. + + Code style ========== diff --git a/docs/installation/index.rst b/docs/installation/index.rst index c84dcf01..d134ae40 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -1,57 +1,21 @@ .. _installation: -************ -Installation -************ +******************* +Mopidy installation +******************* -There are several ways to install Mopidy. What way is best depends upon your -setup and whether you want to use stable releases or less stable development -versions. +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 this page, then have a look at :ref:`run-from-git`. -Requirements -============ - -If you install Mopidy from the APT archive, as described below, APT will take -care of all the dependencies for you. Otherwise, make sure you got the required -dependencies installed. - -- Hard dependencies: - - - Python >= 2.6, < 3 - - - Pykka >= 1.0:: - - sudo pip install -U pykka - - - GStreamer 0.10.x, with Python bindings. See :doc:`gstreamer`. - -- Optional dependencies: - - - For Spotify support, you need libspotify and pyspotify. See - :doc:`libspotify`. - - - To scrobble your played tracks to Last.fm, you need pylast:: - - sudo pip install -U pylast - - - To use MPRIS, e.g. for controlling Mopidy from the Ubuntu Sound Menu, you - need some additional requirements:: - - sudo apt-get install python-dbus python-indicate - - -Install latest stable release -============================= - - -From APT archive ----------------- +Debian/Ubuntu: Install from apt.mopidy.com +========================================== If you run a Debian based Linux distribution, like Ubuntu, the easiest way to -install Mopidy is from the Mopidy APT archive. When installing from the APT -archive, you will automatically get updates to Mopidy in the same way as you -get updates to the rest of your distribution. +install Mopidy is from the `Mopidy APT archive `_. When +installing from the APT archive, you will automatically get updates to Mopidy +in the same way as you get updates to the rest of your distribution. #. Add the archive's GPG key:: @@ -65,119 +29,32 @@ get updates to the rest of your distribution. deb http://apt.mopidy.com/ stable main contrib non-free deb-src http://apt.mopidy.com/ stable main contrib non-free + For the lazy, you can simply run the following command to create + ``/etc/apt/sources.list.d/mopidy.list``:: + + sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list + #. Install Mopidy and all dependencies:: sudo apt-get update sudo apt-get install mopidy -#. Next, you need to set a couple of :doc:`settings `, and then +#. Finally, you need to set a couple of :doc:`settings `, and then you're ready to :doc:`run Mopidy `. -When a new release is out, and you can't wait for you system to figure it out -for itself, run the following to force an upgrade:: +When a new release of Mopidy is out, and you can't wait for you system to +figure it out for itself, run the following to upgrade right away:: sudo apt-get update sudo apt-get dist-upgrade -From PyPI using Pip -------------------- +Arch Linux: Install from AUR +============================ -If you are on OS X or on Linux, but can't install from the APT archive, you can -install Mopidy from PyPI using Pip. - -#. When you install using Pip, you first need to ensure that all of Mopidy's - dependencies have been installed. See the section on dependencies above. - -#. Then, you need to install Pip:: - - sudo apt-get install python-setuptools python-pip # On Ubuntu/Debian - sudo easy_install pip # On OS X - -#. To install the currently latest stable release of Mopidy:: - - sudo pip install -U Mopidy - - To upgrade Mopidy to future releases, just rerun this command. - -#. Next, you need to set a couple of :doc:`settings `, and then - you're ready to :doc:`run Mopidy `. - - -Install development version -=========================== - -If you want to follow the development of Mopidy closer, you may install a -development version of Mopidy. These are not as stable as the releases, but -you'll get access to new features earlier and may help us by reporting issues. - - -From snapshot using Pip ------------------------ - -If you want to follow Mopidy development closer, you may install a snapshot of -Mopidy's ``develop`` branch. - -#. When you install using Pip, you first need to ensure that all of Mopidy's - dependencies have been installed. See the section on dependencies above. - -#. Then, you need to install Pip:: - - sudo apt-get install python-setuptools python-pip # On Ubuntu/Debian - sudo easy_install pip # On OS X - -#. To install the latest snapshot of Mopidy, run:: - - sudo pip install mopidy==dev - - To upgrade Mopidy to future releases, just rerun this command. - -#. Next, you need to set a couple of :doc:`settings `, and then - you're ready to :doc:`run Mopidy `. - - -From Git --------- - -If you want to contribute to Mopidy, you should install Mopidy using Git. - -#. When you install from Git, you first need to ensure that all of Mopidy's - dependencies have been installed. See the section on dependencies above. - -#. Then install Git, if haven't already:: - - sudo apt-get install git-core # On Ubuntu/Debian - sudo brew install git # On OS X using Homebrew - -#. Clone the official Mopidy repository, or your own fork of it:: - - git clone git://github.com/mopidy/mopidy.git - -#. Next, you need to set a couple of :doc:`settings `. - -#. You can then run Mopidy directly from the Git repository:: - - cd mopidy/ # Move into the Git repo dir - python mopidy # Run python on the mopidy source code dir - -#. Later, to get the latest changes to Mopidy:: - - cd mopidy/ - git pull - -For an introduction to ``git``, please visit `git-scm.com -`_. Also, please read our :doc:`developer documentation -`. - - -From AUR on ArchLinux ---------------------- - -If you are running ArchLinux, you can install a development snapshot of Mopidy -using the package found at http://aur.archlinux.org/packages.php?ID=44026. - -#. First, you should consider installing any optional dependencies not included - by the AUR package, like required for e.g. Last.fm scrobbling. +If you are running Arch Linux, you can install a development snapshot of Mopidy +using the `mopidy-git `_ +package found in AUR. #. To install Mopidy with GStreamer, libspotify and pyspotify, you can use ``packer``, ``yaourt``, or do it by hand like this:: @@ -189,5 +66,67 @@ using the package found at http://aur.archlinux.org/packages.php?ID=44026. To upgrade Mopidy to future releases, just rerun ``makepkg``. -#. Next, you need to set a couple of :doc:`settings `, and then +#. Optional: If you want to scrobble your played tracks to Last.fm, you need to + install `python2-pylast + `_ from AUR. + +#. Finally, you need to set a couple of :doc:`settings `, and then + you're ready to :doc:`run Mopidy `. + + +Otherwise: Install from source using Pip +======================================== + +If you are on OS X or on Linux, but can't install from the APT archive or from +AUR, you can install Mopidy from PyPI using Pip. + +#. First of all, you need Python >= 2.6, < 3. Check if you have Python and what + version by running:: + + python --version + +#. When you install using Pip, you need to make sure you have Pip. If you + don't, this is how you install it on Debian/Ubuntu:: + + sudo apt-get install python-setuptools python-pip + + Or on OS X:: + + sudo easy_install pip + +#. Then you'll need to install all of Mopidy's hard dependencies: + + - Pykka >= 1.0:: + + sudo pip install -U pykka + + - GStreamer 0.10.x, with Python bindings. See :doc:`gstreamer` for detailed + instructions. + +#. Optional: If you want Spotify support in Mopidy, you'll need to install + libspotify and the Python bindings, pyspotify. See :doc:`libspotify` for + detailed instructions. + +#. Optional: If you want to scrobble your played tracks to Last.fm, you need + pylast:: + + sudo pip install -U pylast + +#. Optional: To use MPRIS, e.g. for controlling Mopidy from the Ubuntu Sound + Menu, you need some additional requirements. On Debian/Ubuntu:: + + sudo apt-get install python-dbus python-indicate + +#. Then, to install the latest release of Mopidy:: + + sudo pip install -U mopidy + + To upgrade Mopidy to future releases, just rerun this command. + + Alternatively, if you want to follow Mopidy development closer, you may + install a snapshot of Mopidy's ``develop`` Git branch using Pip:: + + sudo pip install mopidy==dev + +#. Finally, you need to set a couple of :doc:`settings `, and then you're ready to :doc:`run Mopidy `. From 636dc6152d2044dd917e7699f62523fb24d8cba1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 16:11:45 +0100 Subject: [PATCH 218/233] docs: Better installation docs The GStreamer and libspotify/pyspotify docs have been merged into the main installation document. Everything related to OS X have been grouped in one section. The rest have been merged into the "from source" section. --- docs/index.rst | 2 - docs/installation/gstreamer.rst | 98 ----------------------- docs/installation/index.rst | 128 +++++++++++++++++++++++++++---- docs/installation/libspotify.rst | 112 --------------------------- docs/settings.rst | 39 +++++++++- 5 files changed, 148 insertions(+), 231 deletions(-) delete mode 100644 docs/installation/gstreamer.rst delete mode 100644 docs/installation/libspotify.rst diff --git a/docs/index.rst b/docs/index.rst index 0f5ed164..bce84b5a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -39,8 +39,6 @@ User documentation :maxdepth: 3 installation/index - installation/gstreamer - installation/libspotify installation/raspberrypi settings running diff --git a/docs/installation/gstreamer.rst b/docs/installation/gstreamer.rst deleted file mode 100644 index 38dbb86c..00000000 --- a/docs/installation/gstreamer.rst +++ /dev/null @@ -1,98 +0,0 @@ -********************** -GStreamer installation -********************** - -To use Mopidy, you first need to install GStreamer and the GStreamer Python -bindings. - - -Installing GStreamer on Linux -============================= - -GStreamer is packaged for most popular Linux distributions. Search for -GStreamer in your package manager, and make sure to install the Python -bindings, and the "good" and "ugly" plugin sets. - - -Debian/Ubuntu -------------- - -If you use Debian/Ubuntu you can install GStreamer like this:: - - sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \ - gstreamer0.10-plugins-ugly - -If you install Mopidy from our APT archive, you don't need to install GStreamer -yourself. The Mopidy Debian package will handle it for you. - - -Arch Linux ----------- - -If you use Arch Linux, install the following packages from the official -repository:: - - sudo pacman -S gstreamer0.10-python gstreamer0.10-good-plugins \ - gstreamer0.10-ugly-plugins - - -Installing GStreamer on OS X -============================ - -We have been working with `Homebrew `_ for a -to make all the GStreamer packages easily installable on OS X. - -#. Install `Homebrew `_. - -#. Install the required packages:: - - brew install gst-python gst-plugins-good gst-plugins-ugly - -#. Make sure to include Homebrew's Python ``site-packages`` directory in your - ``PYTHONPATH``. If you don't include this, Mopidy will not find GStreamer - and crash. - - You can either amend your ``PYTHONPATH`` permanently, by adding the - following statement to your shell's init file, e.g. ``~/.bashrc``:: - - export PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages:$PYTHONPATH - - Or, you can prefix the Mopidy command every time you run it:: - - PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages mopidy - - Note that you need to replace ``python2.7`` with ``python2.6`` if that's - the Python version you are using. To find your Python version, run:: - - python --version - - -Testing the installation -======================== - -If you now run the ``gst-inspect-0.10`` command (the version number may vary), -you should see a long listing of installed plugins, ending in a summary line:: - - $ gst-inspect-0.10 - ... long list of installed plugins ... - Total count: 218 plugins (1 blacklist entry not shown), 1031 features - -You should be able to produce a audible tone by running:: - - gst-launch-0.10 audiotestsrc ! autoaudiosink - -If you cannot hear any sound when running this command, you won't hear any -sound from Mopidy either, as Mopidy uses GStreamer's ``autoaudiosink`` to play -audio. Thus, make this work before you continue installing Mopidy. - - -Using a custom audio sink -========================= - -If you for some reason want to use some other GStreamer audio sink than -``autoaudiosink``, you can set :attr:`mopidy.settings.OUTPUT` to a partial -GStreamer pipeline description describing the GStreamer sink you want to use. - -Example of ``settings.py`` for OSS4:: - - OUTPUT = u'oss4sink' diff --git a/docs/installation/index.rst b/docs/installation/index.rst index d134ae40..d77db58d 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -1,8 +1,8 @@ .. _installation: -******************* -Mopidy installation -******************* +************ +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, @@ -74,25 +74,79 @@ package found in AUR. you're ready to :doc:`run Mopidy `. +OS X: Install from Homebrew and Pip +=================================== + +If you are running OS X, you can install everything needed with Homebrew and +Pip. + +#. Install `Homebrew `_. + + If you are already using Homebrew, make sure your installation is up to + date before you continue:: + + brew update + brew upgrade + +#. Install the required packages from Homebrew:: + + brew install gst-python gst-plugins-good gst-plugins-ugly libspotify + +#. Make sure to include Homebrew's Python ``site-packages`` directory in your + ``PYTHONPATH``. If you don't include this, Mopidy will not find GStreamer + and crash. + + You can either amend your ``PYTHONPATH`` permanently, by adding the + following statement to your shell's init file, e.g. ``~/.bashrc``:: + + export PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages:$PYTHONPATH + + Or, you can prefix the Mopidy command every time you run it:: + + PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages mopidy + + Note that you need to replace ``python2.7`` with ``python2.6`` in the above + ``PYTHONPATH`` examples if you are using Python 2.6. To find your Python + version, run:: + + python --version + +#. Next up, you need to install some Python packages. To do so, we use Pip. If + you don't have the ``pip`` command, you can install it now:: + + sudo easy_install pip + +#. Then get, build, and install the latest releast of pyspotify, pylast, pykka, + and Mopidy using Pip:: + + sudo pip install -U pyspotify pylast pykka mopidy + +#. Finally, you need to set a couple of :doc:`settings `, and then + you're ready to :doc:`run Mopidy `. + + Otherwise: Install from source using Pip ======================================== -If you are on OS X or on Linux, but can't install from the APT archive or from -AUR, you can install Mopidy from PyPI using Pip. +If you are on on Linux, but can't install from the APT archive or from AUR, you +can install Mopidy from PyPI using Pip. #. First of all, you need Python >= 2.6, < 3. Check if you have Python and what version by running:: python --version -#. When you install using Pip, you need to make sure you have Pip. If you - don't, this is how you install it on Debian/Ubuntu:: +#. When you install using Pip, you need to make sure you have Pip. You'll also + need a C compiler and the Python development headers to build pyspotify + later. - sudo apt-get install python-setuptools python-pip + This is how you install it on Debian/Ubuntu:: - Or on OS X:: + sudo apt-get install build-essential python-dev python-pip - sudo easy_install pip + And on Arch Linux from the official repository:: + + sudo pacman -S base-devel python2-pip #. Then you'll need to install all of Mopidy's hard dependencies: @@ -100,12 +154,48 @@ AUR, you can install Mopidy from PyPI using Pip. sudo pip install -U pykka - - GStreamer 0.10.x, with Python bindings. See :doc:`gstreamer` for detailed - instructions. + - GStreamer 0.10.x, with Python bindings. GStreamer is packaged for most + popular Linux distributions. Search for GStreamer in your package manager, + and make sure to install the Python bindings, and the "good" and "ugly" + plugin sets. + + If you use Debian/Ubuntu you can install GStreamer like this:: + + sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \ + gstreamer0.10-plugins-ugly gstreamer0.10-tools + + If you use Arch Linux, install the following packages from the official + repository:: + + sudo pacman -S gstreamer0.10-python gstreamer0.10-good-plugins \ + gstreamer0.10-ugly-plugins #. Optional: If you want Spotify support in Mopidy, you'll need to install - libspotify and the Python bindings, pyspotify. See :doc:`libspotify` for - detailed instructions. + libspotify and the Python bindings, pyspotify. + + #. First, check `pyspotify's changelog `_ to + see what's the latest version of libspotify which it supports. The + versions of libspotify and pyspotify are tightly coupled, so you'll need + to get this right. + + #. Download and install the appropriate 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 + sudo ldconfig + + Remember to adjust the above example for the latest libspotify version + supported by pyspotify, your OS, and your CPU architecture. + + #. Then get, build, and install the latest release of pyspotify using Pip:: + + sudo pip install -U pyspotify #. Optional: If you want to scrobble your played tracks to Last.fm, you need pylast:: @@ -113,9 +203,13 @@ AUR, you can install Mopidy from PyPI using Pip. sudo pip install -U pylast #. Optional: To use MPRIS, e.g. for controlling Mopidy from the Ubuntu Sound - Menu, you need some additional requirements. On Debian/Ubuntu:: + Menu or from an UPnP client via Rygel, you need some additional + dependencies: the Python bindings for libindicate, and the Python bindings + for libdbus, the reference D-Bus library. - sudo apt-get install python-dbus python-indicate + On Debian/Ubuntu:: + + sudo apt-get install python-dbus python-indicate #. Then, to install the latest release of Mopidy:: @@ -123,7 +217,7 @@ AUR, you can install Mopidy from PyPI using Pip. To upgrade Mopidy to future releases, just rerun this command. - Alternatively, if you want to follow Mopidy development closer, you may + Alternatively, if you want to track Mopidy development closer, you may install a snapshot of Mopidy's ``develop`` Git branch using Pip:: sudo pip install mopidy==dev diff --git a/docs/installation/libspotify.rst b/docs/installation/libspotify.rst deleted file mode 100644 index 042034e7..00000000 --- a/docs/installation/libspotify.rst +++ /dev/null @@ -1,112 +0,0 @@ -*********************** -libspotify installation -*********************** - -Mopidy uses `libspotify -`_ for playing music -from the Spotify music service. To use :mod:`mopidy.backends.spotify` you must -install libspotify and `pyspotify `_. - -.. note:: - - This backend requires a paid `Spotify premium account - `_. - - -Installing libspotify -===================== - - -On Linux from APT archive -------------------------- - -If you install from APT, jump directly to :ref:`pyspotify_installation` below. - - -On Linux from source --------------------- - -First, check pyspotify's changelog to see what's the latest version of -libspotify which is supported. The versions of libspotify and pyspotify are -tightly coupled. - -Download and install the appropriate version of libspotify for your OS and CPU -architecture from https://developer.spotify.com/en/libspotify/. - -For libspotify 0.0.8 for 64-bit Linux the process is as follows:: - - wget http://developer.spotify.com/download/libspotify/libspotify-0.0.8-linux6-x86_64.tar.gz - tar zxfv libspotify-0.0.8-linux6-x86_64.tar.gz - cd libspotify-0.0.8-linux6-x86_64/ - sudo make install prefix=/usr/local - sudo ldconfig - -Remember to adjust for the latest libspotify version supported by pyspotify, -your OS and your CPU architecture. - -When libspotify has been installed, continue with -:ref:`pyspotify_installation`. - - -On OS X from Homebrew ---------------------- - -In OS X you need to have `XCode `_ and -`Homebrew `_ installed. Then, to install -libspotify:: - - brew install libspotify - -To update your existing libspotify installation using Homebrew:: - - brew update - brew upgrade - -When libspotify has been installed, continue with -:ref:`pyspotify_installation`. - - -.. _pyspotify_installation: - -Installing pyspotify -==================== - -When you've installed libspotify, it's time for making it available from Python -by installing pyspotify. - - -On Linux from APT archive -------------------------- - -If you run a Debian based Linux distribution, like Ubuntu, see -http://apt.mopidy.com/ for how to use the Mopidy APT archive as a software -source on your system. Then, simply run:: - - sudo apt-get install python-spotify - -This command will install both libspotify and pyspotify for you. - - -On Linux from source -------------------------- - -If you have have already installed libspotify, you can continue with installing -the libspotify Python bindings, called pyspotify. - -On Linux, you need to get the Python development files installed. On -Debian/Ubuntu systems run:: - - sudo apt-get install python-dev - -Then get, build, and install the latest releast of pyspotify using ``pip``:: - - sudo pip install -U pyspotify - - -On OS X from source -------------------- - -If you have already installed libspotify, you can get, build, and install the -latest releast of pyspotify using ``pip``:: - - sudo pip install -U pyspotify diff --git a/docs/settings.rst b/docs/settings.rst index 99064b60..5bc63d7f 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -19,8 +19,8 @@ You can either create the settings file yourself, or run the ``mopidy`` command, and it will create an empty settings file for you. When you have created the settings file, open it in a text editor, and add -settings you want to change. If you want to keep the default value for setting, -you should *not* redefine it in your own settings file. +settings you want to change. If you want to keep the default value for a +setting, you should *not* redefine it in your own settings file. A complete ``~/.config/mopidy/settings.py`` may look as simple as this:: @@ -140,6 +140,41 @@ requirements of the `MPRIS specification `_. The ``TrackList`` and the ``Playlists`` interfaces of the spec are not supported. +Using a custom audio sink +========================= + +If you have successfully installed GStreamer, and then run the ``gst-inspect`` +or ``gst-inspect-0.10`` command, you should see a long listing of installed +plugins, ending in a summary line:: + + $ gst-inspect-0.10 + ... long list of installed plugins ... + Total count: 254 plugins (1 blacklist entry not shown), 1156 features + +Next, you should be able to produce a audible tone by running:: + + gst-launch-0.10 audiotestsrc ! sudioresample ! autoaudiosink + +If you cannot hear any sound when running this command, you won't hear any +sound from Mopidy either, as Mopidy by default uses GStreamer's +``autoaudiosink`` to play audio. Thus, make this work before you file a bug +against Mopidy. + +If you for some reason want to use some other GStreamer audio sink than +``autoaudiosink``, you can set the setting :attr:`mopidy.settings.OUTPUT` to a +partial GStreamer pipeline description describing the GStreamer sink you want +to use. + +Example of ``settings.py`` for using OSS4:: + + OUTPUT = u'oss4sink' + +Again, this is the equivalent of the following ``gst-inspect`` command, so make +this work first:: + + gst-launch-0.10 audiotestsrc ! audioresample ! oss4sink + + Streaming audio through a SHOUTcast/Icecast server ================================================== From 5c7e18e95016108747c8371b8e4eabff817138e0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 16:17:03 +0100 Subject: [PATCH 219/233] docs: Update with realistic test count. Reorder sections --- docs/development.rst | 86 ++++++++++++++++++++++++-------------------- 1 file changed, 47 insertions(+), 39 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index 74436223..6cab7bf1 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -164,18 +164,26 @@ Then, to run all tests, go to the project directory and run:: For example:: $ nosetests - ...................................................................... - ...................................................................... - ...................................................................... - ....... - ---------------------------------------------------------------------- - Ran 217 tests in 0.267s + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................. + ----------------------------------------------------------------------------- + 1062 tests run in 7.4 seconds (1062 tests passed) - OK +To run tests with test coverage statistics, remember to specify the tests dir:: -To run tests with test coverage statistics:: - - nosetests --with-coverage + nosetests --with-coverage tests/ For more documentation on testing, check out the `nose documentation `_. @@ -247,6 +255,35 @@ both to use ``tests/data/advanced_tag_cache`` for their tag cache and playlists. +Setting profiles during development +=================================== + +While developing Mopidy switching settings back and forth can become an all too +frequent occurrence. As a quick hack to get around this you can structure your +settings file in the following way:: + + import os + profile = os.environ.get('PROFILE', '').split(',') + + if 'spotify' in profile: + BACKENDS = (u'mopidy.backends.spotify.SpotifyBackend',) + elif 'local' in profile: + BACKENDS = (u'mopidy.backends.local.LocalBackend',) + LOCAL_MUSIC_PATH = u'~/music' + + if 'shoutcast' in profile: + OUTPUT = u'lame ! shout2send mount="/stream"' + elif 'silent' in profile: + OUTPUT = u'fakesink' + MIXER = None + + SPOTIFY_USERNAME = u'xxxxx' + SPOTIFY_PASSWORD = u'xxxxx' + +Using this setup you can now run Mopidy with ``PROFILE=silent,spotify mopidy`` +if you for instance want to test Spotify without any actual audio output. + + Writing documentation ===================== @@ -293,32 +330,3 @@ Creating releases python setup.py sdist upload #. Spread the word. - - -Setting profiles during development -=================================== - -While developing Mopidy switching settings back and forth can become an all too -frequent occurrence. As a quick hack to get around this you can structure your -settings file in the following way:: - - import os - profile = os.environ.get('PROFILE', '').split(',') - - if 'spotify' in profile: - BACKENDS = (u'mopidy.backends.spotify.SpotifyBackend',) - elif 'local' in profile: - BACKENDS = (u'mopidy.backends.local.LocalBackend',) - LOCAL_MUSIC_PATH = u'~/music' - - if 'shoutcast' in profile: - OUTPUT = u'lame ! shout2send mount="/stream"' - elif 'silent' in profile: - OUTPUT = u'fakesink' - MIXER = None - - SPOTIFY_USERNAME = u'xxxxx' - SPOTIFY_PASSWORD = u'xxxxx' - -Using this setup you can now run Mopidy with ``PROFILE=silent,spotify mopidy`` -if you for instance want to test Spotify without any actual audio output. From 78bb341282792e5c335e23d6fd02d608fac9147c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 16:27:31 +0100 Subject: [PATCH 220/233] docs: Fix references to old installation docs --- docs/changes.rst | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 473c7e37..a01eb1c7 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -406,7 +406,7 @@ Please note that 0.5.0 requires some updated dependencies, as listed under - If you use the Spotify backend, you *must* upgrade to libspotify 0.0.8 and pyspotify 1.3. If you install from APT, libspotify and pyspotify will automatically be upgraded. If you are not installing from APT, follow the - instructions at :doc:`/installation/libspotify/`. + instructions at :ref:`installation`. - If you have explicitly set the :attr:`mopidy.settings.SPOTIFY_HIGH_BITRATE` setting, you must update your settings file. The new setting is named @@ -547,8 +547,7 @@ loading from Mopidy 0.3.0 is still present. - If you use the Spotify backend, you *should* upgrade to libspotify 0.0.7 and the latest pyspotify from the Mopidy developers. If you install from APT, libspotify and pyspotify will automatically be upgraded. If you are not - installing from APT, follow the instructions at - :doc:`/installation/libspotify/`. + installing from APT, follow the instructions at :ref:`installation`. **Changes** @@ -660,7 +659,7 @@ to this problem. - If you use the Spotify backend, you need to upgrade to libspotify 0.0.6 and the latest pyspotify from the Mopidy developers. Follow the instructions at - :doc:`/installation/libspotify/`. + :ref:`installation`. - If you use the Last.fm frontend, you need to upgrade to pylast 0.5.7. Run ``sudo pip install --upgrade pylast`` or install Mopidy from APT. @@ -815,10 +814,10 @@ We've worked a bit on OS X support, but not all issues are completely solved yet. :issue:`25` is the one that is currently blocking OS X support. Any help solving it will be greatly appreciated! -Finally, please :ref:`update your pyspotify installation -` when upgrading to Mopidy 0.2.0. The latest pyspotify -got a fix for the segmentation fault that occurred when playing music and -searching at the same time, thanks to Valentin David. +Finally, please :ref:`update your pyspotify installation ` when +upgrading to Mopidy 0.2.0. The latest pyspotify got a fix for the segmentation +fault that occurred when playing music and searching at the same time, thanks +to Valentin David. **Important changes** @@ -883,12 +882,11 @@ fixing the OS X issues for a future release. You can track the progress at **Important changes** - License changed from GPLv2 to Apache License, version 2.0. -- GStreamer is now a required dependency. See our :doc:`GStreamer installation - docs `. +- GStreamer is now a required dependency. See our :ref:`GStreamer installation + docs `. - :mod:`mopidy.backends.libspotify` is now the default backend. :mod:`mopidy.backends.despotify` is no longer available. This means that you - need to install the :doc:`dependencies for libspotify - `. + need to install the :ref:`dependencies for libspotify `. - If you used :mod:`mopidy.backends.libspotify` previously, pyspotify must be updated when updating to this release, to get working seek functionality. - :attr:`mopidy.settings.SERVER_HOSTNAME` and From 7190052d2c42c706191995c011ec1fb8a170d216 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 16:55:27 +0100 Subject: [PATCH 221/233] docs: Add ToC for the installation page --- docs/installation/index.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index d77db58d..f54379ff 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -8,6 +8,9 @@ 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 this page, then have a look at :ref:`run-from-git`. +.. contents:: Installation guides + :local: + Debian/Ubuntu: Install from apt.mopidy.com ========================================== From 3471a5639b28eb5b4774a150ba0523fe37618eaa Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 16:55:56 +0100 Subject: [PATCH 222/233] docs: Add a section pointing to the Raspberry Pi page --- docs/installation/index.rst | 7 +++++++ docs/installation/raspberrypi.rst | 2 ++ 2 files changed, 9 insertions(+) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index f54379ff..587866fd 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -52,6 +52,13 @@ figure it out for itself, run the following to upgrade right away:: sudo apt-get dist-upgrade +Raspberry Pi running Debian +=========================== + +We have guides for installing a Raspberry Pi from scratch with either Debian +6.0 Squeeze or Debian 7.0 Wheezy. See :ref:`raspberrypi-installation`. + + Arch Linux: Install from AUR ============================ diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index eaec48cd..fbb07364 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -1,3 +1,5 @@ +.. _raspberrypi-installation: + **************************** Installation on Raspberry Pi **************************** From 9e622c46e27f854660777140a2e56945a8fd2336 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 19:27:20 +0100 Subject: [PATCH 223/233] docs: Add Paul Sturgess' Vagrant setup to installation page --- docs/installation/index.rst | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 587866fd..4ae04c40 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -53,10 +53,19 @@ figure it out for itself, run the following to upgrade right away:: Raspberry Pi running Debian -=========================== +--------------------------- -We have guides for installing a Raspberry Pi from scratch with either Debian -6.0 Squeeze or Debian 7.0 Wheezy. See :ref:`raspberrypi-installation`. +Fred Hatfull has created a guide for installing a Raspberry Pi from scratch +with Debian and Mopidy. See :ref:`raspberrypi-installation`. + + +Vagrant virtual machine running Ubuntu +-------------------------------------- + +Paul Sturgess has created a Vagrant and Chef setup that automatically creates +and sets up a virtual machine which runs Mopidy. Check out +https://github.com/paulsturgess/mopidy-vagrant if you're interested in trying +it out. Arch Linux: Install from AUR From 84f902081e0220a037546df0b084c0a2081171b9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 20:15:09 +0100 Subject: [PATCH 224/233] docs: Add ToC to MPD clients page --- docs/clients/mpd.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 17282d8c..10795131 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -8,6 +8,9 @@ This is a list of MPD clients we either know works well with Mopidy, or that we know won't work well. For a more exhaustive list of MPD clients, see http://mpd.wikia.com/wiki/Clients. +.. contents:: Contents + :local: + Console clients =============== From 0211a86b5c7b797e56e850dd7d6d2c4ddc2993f6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 20:56:28 +0100 Subject: [PATCH 225/233] docs: Update/reorder console and graphical clients --- docs/clients/mpd.rst | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 10795131..b7cb1bb4 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -15,11 +15,16 @@ http://mpd.wikia.com/wiki/Clients. Console clients =============== -mpc ---- +ncmpcpp +------- -A command line client. Version 0.14 had some issues with Mopidy (see -:issue:`5`), but 0.16 seems to work nicely. +A console client that works well with Mopidy, and is regularly used by Mopidy +developers. + +Search does not work in the "Match if tag contains search phrase (regexes +supported)" mode because the client tries to fetch all known metadata and do +the search on the client side. The two other search modes works nicely, so this +is not a problem. ncmpc @@ -29,18 +34,11 @@ A console client. Works with Mopidy 0.6 and upwards. Uses the ``idle`` MPD command, but in a resource inefficient way. -ncmpcpp -------- +mpc +--- -A console client that generally works well with Mopidy, and is regularly used -by Mopidy developers. - -Search only works in two of the three search modes: - -- "Match if tag contains search phrase (regexes supported)" -- Does not work. - The client tries to fetch all known metadata and do the search client side. -- "Match if tag contains searched phrase (no regexes)" -- Works. -- "Match only if both values are the same" -- Works. +A command line client. Version 0.16 and upwards seems to work nicely with +Mopidy. Graphical clients @@ -50,7 +48,7 @@ GMPC ---- `GMPC `_ is a graphical MPD client (GTK+) which works -well with Mopidy, and is regularly used by Mopidy developers. +well with Mopidy. GMPC may sometimes requests a lot of meta data of related albums, artists, etc. This takes more time with Mopidy, which needs to query Spotify for the data, @@ -69,8 +67,8 @@ When you search in Sonata, it only sends the first to letters of the search query to Mopidy, and then does the rest of the filtering itself on the client side. Since Spotify has a collection of millions of tracks and they only return the first 100 hits for any search query, searching for two-letter combinations -seldom returns any useful results. See :issue:`1` and the matching `Sonata -bug`_ for details. +seldom returns any useful results. See :issue:`1` and the closed `Sonata bug`_ +for details. .. _Sonata bug: http://developer.berlios.de/feature/?func=detailfeature&feature_id=5038&group_id=7323 From d48e1218fc45703e2ebdf3ba1f9c42eaf75cccd5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 20:56:45 +0100 Subject: [PATCH 226/233] docs: Update Android MPD clients review --- docs/clients/mpd.rst | 179 ++++++++++++++++++------------------------- 1 file changed, 75 insertions(+), 104 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index b7cb1bb4..c71997be 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -12,6 +12,36 @@ http://mpd.wikia.com/wiki/Clients. :local: +Test procedure +============== + +In some cases, we've used the following test procedure to compare the feature +completeness of clients: + +#. Connect to Mopidy +#. Search for "foo", with search type "any" if it can be selected +#. Add "The Pretender" from the search results to the current playlist +#. Start playback +#. Pause and resume playback +#. Adjust volume +#. Find a playlist and append it to the current playlist +#. Skip to next track +#. Skip to previous track +#. Select the last track from the current playlist +#. Turn on repeat mode +#. Seek to 10 seconds or so before the end of the track +#. Wait for the end of the track and confirm that playback continues at the + start of the playlist +#. Turn off repeat mode +#. Turn on random mode +#. Skip to next track and confirm that it random mode works +#. Turn off random mode +#. Stop playback +#. Check if the app got support for single mode and consume mode +#. Kill Mopidy and confirm that the app handles it without crashing + + + Console clients =============== @@ -85,63 +115,47 @@ It generally works well with Mopidy. Android clients =============== -We've tested all four MPD clients we could find for Android with Mopidy 0.7.3 on -a Samsung Galaxy Nexus with Android 4.1.1, using the following test procedure: +We've tested all five MPD clients we could find for Android with Mopidy 0.8.1 +on a Samsung Galaxy Nexus with Android 4.1.2, using our standard test +procedure. -#. Connect to Mopidy -#. Search for ``foo``, with search type "any" if it can be selected -#. Add "The Pretender" from the search results to the current playlist -#. Start playback -#. Pause and resume playback -#. Adjust volume -#. Find a playlist and append it to the current playlist -#. Skip to next track -#. Skip to previous track -#. Select the last track from the current playlist -#. Turn on repeat mode -#. Seek to 10 seconds or so before the end of the track -#. Wait for the end of the track and confirm that playback continues at the - start of the playlist -#. Turn off repeat mode -#. Turn on random mode -#. Skip to next track and confirm that it random mode works -#. Turn off random mode -#. Stop playback -#. Check if the app got support for single mode and consume mode -#. Kill Mopidy and confirm that the app handles it without crashing -We found that all four apps crashed on Android 4.1.1. +MPDroid +------- -Combining what we managed to find before the apps crashed with our experience -from an older version of this review, using Android 2.1, we can say that: +Test date: + 2012-11-06 +Tested version: + 1.03.1 (released 2012-10-16) -- PMix can be ignored, because it is unmaintained and its fork MPDroid is - better on all fronts. +You can get `MPDroid from Google Play +`_. -- Droid MPD Client was to buggy to get an impression from. Unclear if the bugs - are due to the app or that it hasn't been updated for Android 4.x. +- MPDroid started out as a fork of PMix, and is now much better. -- BitMPC is in our experience feature complete, but ugly. +- MPDroid's user interface looks nice. -- MPDroid, now that search is in place, is probably feature complete as well, - and looks nicer than BitMPC. +- Everything in the test procedure works. -In conclusion: MPD clients on Android 4.x is a sad affair. If you want to try -anyway, try BitMPC and MPDroid. +- In contrast to all other Android clients, MPDroid does support single mode or + consume mode. + +- When Mopidy is killed, MPDroid handles it gracefully and asks if you want to + try to reconnect. + +MPDroid is a good MPD client, and really the only one we can recommend. BitMPC ------ Test date: - 2012-09-12 + 2012-11-06 Tested version: 1.0.0 (released 2010-04-12) -Downloads: - 5,000+ -Rating: - 3.7 stars from about 100 ratings +You can get `BitMPC from Google Play +`_. - The user interface lacks some finishing touches. E.g. you can't enter a hostname for the server. Only IPv4 addresses are allowed. @@ -155,8 +169,8 @@ Rating: - BitMPC crashed if Mopidy was killed or crashed. - When we tried to test using Android 4.1.1, BitMPC started and connected to - Mopidy without problems, but the app crashed as soon as fire off our search, - and continued to crash on startup after that. + Mopidy without problems, but the app crashed as soon as we fired off our + search, and continued to crash on startup after that. In conclusion, BitMPC is usable if you got an older Android phone and don't care about looks. For newer Android versions, BitMPC will probably not work as @@ -167,13 +181,12 @@ Droid MPD Client ---------------- Test date: - 2012-09-12 + 2012-11-06 Tested version: 1.4.0 (released 2011-12-20) -Downloads: - 10,000+ -Rating: - 4.2 stars from 400+ ratings + +You can get `Droid MPD Client from Google Play +`_. - No intutive way to ask the app to connect to the server after adding the server hostname to the settings. @@ -190,11 +203,6 @@ Rating: - Searching for "foo" did nothing. No request was sent to the server. -- Once, I managed to get a list of stored playlists in the "Search" tab, but I - never managed to reproduce this. Opening the stored playlists doesn't work, - because Mopidy haven't implemented ``lsinfo "Playlist name"`` (see - :issue:`193`). - - Droid MPD client does not support single mode or consume mode. - Not able to complete the test procedure, due to the above problems. @@ -202,71 +210,34 @@ Rating: In conclusion, not a client we can recommend. -MPDroid -------- - -Test date: - 2012-09-12 -Tested version: - 0.7 (released 2011-06-19) -Downloads: - 10,000+ -Rating: - 4.5 stars from ~500 ratings - -- MPDroid started out as a fork of PMix. - -- First of all, MPDroid's user interface looks nice. - -- Last time we tested MPDroid (v0.6.9), we couldn't find any search - functionality. Now we found it, and it worked. - -- Last time we tested MPDroid (v0.6.9) everything in the test procedure worked - out flawlessly. - -- Like all other Android clients, MPDroid does not support single mode or - consume mode. - -- When Mopidy is killed, MPDroid handles it gracefully and asks if you want to - try to reconnect. - -- When using Android 4.1.1, MPDroid crashes here and there, e.g. when having an - empty current playlist and pressing play. - -Disregarding Android 4.x problems, MPDroid is a good MPD client. - - PMix ---- Test date: - 2012-09-12 + 2012-11-06 Tested version: 0.4.0 (released 2010-03-06) -Downloads: - 10,000+ -Rating: - 3.8 stars from >200 ratings -- Using Android 4.1.1, PMix, which haven't been updated for 2.5 years, crashes - as soon as it connects to Mopidy. +You can get `PMix from Google Play +`_. -- Last time we tested the same version of PMix using Android 2.1, we found - that: +PMix haven't been updated for 2.5 years, and has less working features than +it's fork MPDroid. Ignore PMix and use MPDroid instead. - - PMix does not support search. - - I could not find stored playlists. +MPD Remote +---------- - - Other than that, I was able to complete the test procedure. +Test date: + 2012-11-06 +Tested version: + 1.0 (released 2012-05-01) - - PMix crashed once during testing. +You can get `MPD Remote from Google Play +`_. - - PMix handled the killing of Mopidy just as nicely as MPDroid. - - - It does not support single mode or consume mode. - -All in all, PMix works but can do less than MPDroid. Use MPDroid instead. +This app looks terrible in the screen shots, got just 100+ downloads, and got a +terrible rating. I honestly didn't take the time to test it. .. _ios_mpd_clients: From f19695ccd5d90f046d043100ed2653a8ed418f65 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 21:22:55 +0100 Subject: [PATCH 227/233] docs: Updated link to Theremin --- docs/clients/mpd.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index c71997be..670c339d 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -106,8 +106,8 @@ for details. Theremin -------- -`Theremin `_ is a graphical MPD client for OS X. -It generally works well with Mopidy. +`Theremin `_ is a graphical MPD +client for OS X. It is unmaintained, but generally works well with Mopidy. .. _android_mpd_clients: From 6b8a7ab356d69d2dfc2f0ce6495804f6cc376ba9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 21:59:45 +0100 Subject: [PATCH 228/233] docs: Add MPaD review --- docs/clients/mpd.rst | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 670c339d..5d3eef49 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -303,5 +303,21 @@ we've tested a fresh install of MPoD 1.5.1 with Mopidy as of revision e7ed28d MPaD ---- -The `MPaD `_ iPad app works -with Mopidy. A complete review may appear here in the future. +Test date: + 2012-11-06 +Tested version: + 1.7.1 + +The `MPaD `_ iPad app can be +purchased from `MPaD at iTunes Store +`_ + +- The user interface looks nice, though I would like to be able to view the + current playlist in the large part of the split view. + +- All features exercised in the test procedure worked with MPaD. + +- Single mode and consume mode is support. + +- The server menu can be very slow top open, and there is no visible feedback + when waiting for the connection to a server to succeed. From 38607fc2be9c9a81d55b7eb92ccb9a44ecdbb00a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 22:26:36 +0100 Subject: [PATCH 229/233] docs: Update MPoD review --- docs/clients/mpd.rst | 59 ++++++++++++-------------------------------- 1 file changed, 16 insertions(+), 43 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 5d3eef49..6f349945 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -245,59 +245,28 @@ terrible rating. I honestly didn't take the time to test it. iOS clients =========== -MPod +MPoD ---- Test date: - 2011-01-19 + 2012-11-06 Tested version: - 1.5.1 + 1.7.1 The `MPoD `_ iPhone/iPod Touch -app can be installed from the `iTunes Store +app can be installed from `MPoD at iTunes Store `_. -Users have reported varying success in using MPoD together with Mopidy. Thus, -we've tested a fresh install of MPoD 1.5.1 with Mopidy as of revision e7ed28d -(pre-0.3) on an iPod Touch 3rd generation. The following are our findings: +- The user interface looks nice. -- **Works:** Playback control generally works, including stop, play, pause, - previous, next, repeat, random, seek, and volume control. +- All features exercised in the test procedure worked with MPaD, except seek, + which I didn't figure out to do. -- **Bug:** Search does not work, neither in the artist, album, or song - tabs. Mopidy gets no requests at all from MPoD when executing searches. Seems - like MPoD only searches in local cache, even if "Use local cache" is turned - off in MPoD's settings. Until this is fixed by the MPoD developer, MPoD will - be much less useful with Mopidy. +- Search only works in the "Browse" tab, and not under in the "Artist", + "Album", or "Song" tabs. For the tabs where search doesn't work, no queries + are sent to Mopidy when searching. -- **Bug:** When adding another playlist to the current playlist in MPoD, - the currently playing track restarts at the beginning. I do not currently - know enough about this bug, because I'm not sure if MPoD was in the "add to - active playlist" or "replace active playlist" mode when I tested it. I only - later learned what that button was for. Anyway, what I experienced was: - - #. I play a track - #. I select a new playlist - #. MPoD reconnects to Mopidy for unknown reason - #. MPoD issues MPD command ``load "a playlist name"`` - #. MPoD issues MPD command ``play "-1"`` - #. MPoD issues MPD command ``playlistinfo "-1"`` - #. I hear that the currently playing tracks restarts playback - -- **Tips:** MPoD seems to cache stored playlists, but they won't work if the - server hasn't loaded stored playlists from e.g. Spotify yet. A trick to force - refetching of playlists from Mopidy is to add a new empty playlist in MPoD. - -- **Wishlist:** Modifying the current playlists is not supported by MPoD it - seems. - -- **Wishlist:** MPoD supports playback of Last.fm radio streams through the MPD - server. Mopidy does not currently support this, but there is a wishlist bug - at :issue:`38`. - -- **Wishlist:** MPoD supports autodetection/-configuration of MPD servers - through the use of Bonjour. Mopidy does not currently support this, but there - is a wishlist bug at :issue:`39`. +- Single mode and consume mode is supported. MPaD @@ -317,7 +286,11 @@ purchased from `MPaD at iTunes Store - All features exercised in the test procedure worked with MPaD. -- Single mode and consume mode is support. +- Search only works in the "Browse" tab, and not under in the "Artist", + "Album", or "Song" tabs. For the tabs where search doesn't work, no queries + are sent to Mopidy when searching. + +- Single mode and consume mode is supported. - The server menu can be very slow top open, and there is no visible feedback when waiting for the connection to a server to succeed. From faafa076d11c0469c19ab1910481a4baa5710a9e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 22:38:41 +0100 Subject: [PATCH 230/233] docs: Add screen shots of MPD clients --- docs/_static/mpd-client-gmpc.png | Bin 0 -> 178750 bytes docs/_static/mpd-client-mpad.jpg | Bin 0 -> 61090 bytes docs/_static/mpd-client-mpdroid.jpg | Bin 0 -> 34880 bytes docs/_static/mpd-client-mpod.jpg | Bin 0 -> 35287 bytes docs/_static/mpd-client-ncmpcpp.png | Bin 0 -> 22418 bytes docs/_static/mpd-client-sonata.png | Bin 0 -> 48000 bytes docs/clients/mpd.rst | 24 ++++++++++++++++++++++++ 7 files changed, 24 insertions(+) create mode 100644 docs/_static/mpd-client-gmpc.png create mode 100644 docs/_static/mpd-client-mpad.jpg create mode 100644 docs/_static/mpd-client-mpdroid.jpg create mode 100644 docs/_static/mpd-client-mpod.jpg create mode 100644 docs/_static/mpd-client-ncmpcpp.png create mode 100644 docs/_static/mpd-client-sonata.png diff --git a/docs/_static/mpd-client-gmpc.png b/docs/_static/mpd-client-gmpc.png new file mode 100644 index 0000000000000000000000000000000000000000..aa85c273a0d9a908b7c6db766f96ffda9589f1ee GIT binary patch literal 178750 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

Z0fNZhWOqYsE{_ssK= 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 literal 0 HcmV?d00001 diff --git a/docs/_static/mpd-client-mpad.jpg b/docs/_static/mpd-client-mpad.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6a3357dc4a70a9d70df50c78475709ad3aedb57a GIT binary patch literal 61090 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 QPtJuZ{zIq){Ljq)19JeXqW}N^ literal 0 HcmV?d00001 diff --git a/docs/_static/mpd-client-ncmpcpp.png b/docs/_static/mpd-client-ncmpcpp.png new file mode 100644 index 0000000000000000000000000000000000000000..975639c65e933b3d4f624b1eb69e62999c81424e GIT binary patch literal 22418 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

IOY4zURaC{(wN|&BJ(}-nv@zM1UC^7YbV1K87 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 literal 0 HcmV?d00001 diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 6f349945..59b2e103 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -48,6 +48,10 @@ Console clients ncmpcpp ------- +.. image:: /_static/mpd-client-ncmpcpp.png + :width: 575 + :height: 426 + A console client that works well with Mopidy, and is regularly used by Mopidy developers. @@ -77,6 +81,10 @@ Graphical clients GMPC ---- +.. image:: /_static/mpd-client-gmpc.png + :width: 1000 + :height: 565 + `GMPC `_ is a graphical MPD client (GTK+) which works well with Mopidy. @@ -90,6 +98,10 @@ before it will catch up. Sonata ------ +.. image:: /_static/mpd-client-sonata.png + :width: 475 + :height: 424 + `Sonata `_ is a graphical MPD client (GTK+). It generally works well with Mopidy, except for search. @@ -123,6 +135,10 @@ procedure. MPDroid ------- +.. image:: /_static/mpd-client-mpdroid.jpg + :width: 288 + :height: 512 + Test date: 2012-11-06 Tested version: @@ -248,6 +264,10 @@ iOS clients MPoD ---- +.. image:: /_static/mpd-client-mpod.jpg + :width: 320 + :height: 480 + Test date: 2012-11-06 Tested version: @@ -272,6 +292,10 @@ app can be installed from `MPoD at iTunes Store MPaD ---- +.. image:: /_static/mpd-client-mpad.jpg + :width: 480 + :height: 360 + Test date: 2012-11-06 Tested version: From 926873b5273ff662250f99e56937e79943895e9f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 22:40:53 +0100 Subject: [PATCH 231/233] docs: Move MPD client screen shots to work better with RTD stylesheet --- docs/clients/mpd.rst | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 59b2e103..a8cae367 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -48,13 +48,13 @@ Console clients ncmpcpp ------- +A console client that works well with Mopidy, and is regularly used by Mopidy +developers. + .. image:: /_static/mpd-client-ncmpcpp.png :width: 575 :height: 426 -A console client that works well with Mopidy, and is regularly used by Mopidy -developers. - Search does not work in the "Match if tag contains search phrase (regexes supported)" mode because the client tries to fetch all known metadata and do the search on the client side. The two other search modes works nicely, so this @@ -81,13 +81,13 @@ Graphical clients GMPC ---- +`GMPC `_ is a graphical MPD client (GTK+) which works +well with Mopidy. + .. image:: /_static/mpd-client-gmpc.png :width: 1000 :height: 565 -`GMPC `_ is a graphical MPD client (GTK+) which works -well with Mopidy. - GMPC may sometimes requests a lot of meta data of related albums, artists, etc. This takes more time with Mopidy, which needs to query Spotify for the data, than with a normal MPD server, which has a local cache of meta data. Thus, GMPC @@ -98,13 +98,13 @@ before it will catch up. Sonata ------ +`Sonata `_ is a graphical MPD client (GTK+). +It generally works well with Mopidy, except for search. + .. image:: /_static/mpd-client-sonata.png :width: 475 :height: 424 -`Sonata `_ is a graphical MPD client (GTK+). -It generally works well with Mopidy, except for search. - When you search in Sonata, it only sends the first to letters of the search query to Mopidy, and then does the rest of the filtering itself on the client side. Since Spotify has a collection of millions of tracks and they only return @@ -135,15 +135,15 @@ procedure. MPDroid ------- -.. image:: /_static/mpd-client-mpdroid.jpg - :width: 288 - :height: 512 - Test date: 2012-11-06 Tested version: 1.03.1 (released 2012-10-16) +.. image:: /_static/mpd-client-mpdroid.jpg + :width: 288 + :height: 512 + You can get `MPDroid from Google Play `_. @@ -264,15 +264,15 @@ iOS clients MPoD ---- -.. image:: /_static/mpd-client-mpod.jpg - :width: 320 - :height: 480 - Test date: 2012-11-06 Tested version: 1.7.1 +.. image:: /_static/mpd-client-mpod.jpg + :width: 320 + :height: 480 + The `MPoD `_ iPhone/iPod Touch app can be installed from `MPoD at iTunes Store `_. @@ -292,15 +292,15 @@ app can be installed from `MPoD at iTunes Store MPaD ---- -.. image:: /_static/mpd-client-mpad.jpg - :width: 480 - :height: 360 - Test date: 2012-11-06 Tested version: 1.7.1 +.. image:: /_static/mpd-client-mpad.jpg + :width: 480 + :height: 360 + The `MPaD `_ iPad app can be purchased from `MPaD at iTunes Store `_ From 197447c0cb4f14025d9bcecf895253e167793e58 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 8 Nov 2012 14:42:06 +0100 Subject: [PATCH 232/233] Remove ancient despotify settings check --- mopidy/utils/settings.py | 7 ------- tests/utils/settings_test.py | 10 ---------- 2 files changed, 17 deletions(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index d6c5d644..87e5952a 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -151,13 +151,6 @@ def validate_settings(defaults, settings): errors[setting] = u'Deprecated setting. Use %s.' % ( changed[setting],) - elif setting == 'BACKENDS': - if 'mopidy.backends.despotify.DespotifyBackend' in value: - errors[setting] = ( - u'Deprecated setting value. ' - u'"mopidy.backends.despotify.DespotifyBackend" is no ' - u'longer available.') - elif setting == 'OUTPUTS': errors[setting] = ( u'Deprecated setting, please change to OUTPUT. OUTPUT expects ' diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 5ce643cb..a57ed729 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -39,16 +39,6 @@ class ValidateSettingsTest(unittest.TestCase): result['SPOTIFY_LIB_APPKEY'], u'Deprecated setting. It may be removed.') - def test_deprecated_setting_value_returns_error(self): - result = setting_utils.validate_settings( - self.defaults, - {'BACKENDS': ('mopidy.backends.despotify.DespotifyBackend',)}) - self.assertEqual( - result['BACKENDS'], - u'Deprecated setting value. ' - u'"mopidy.backends.despotify.DespotifyBackend" is no longer ' - u'available.') - def test_unavailable_bitrate_setting_returns_error(self): result = setting_utils.validate_settings( self.defaults, {'SPOTIFY_BITRATE': 50}) From 49cf1ab8aac61722e39e3a145c57b98ff3e9fd9b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 8 Nov 2012 14:43:21 +0100 Subject: [PATCH 233/233] Require at least one frontend and one backend --- mopidy/utils/settings.py | 9 +++++++++ tests/utils/settings_test.py | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 87e5952a..5760106b 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -143,6 +143,11 @@ def validate_settings(defaults, settings): 'SPOTIFY_LIB_CACHE': 'SPOTIFY_CACHE_PATH', } + list_of_one_or_more = [ + 'BACKENDS', + 'FRONTENDS', + ] + for setting, value in settings.iteritems(): if setting in changed: if changed[setting] is None: @@ -167,6 +172,10 @@ def validate_settings(defaults, settings): u'Deprecated setting, please set the value via the GStreamer ' u'bin in OUTPUT.') + elif setting in list_of_one_or_more: + if not value: + errors[setting] = u'Must contain at least one value.' + elif setting not in defaults: errors[setting] = u'Unknown setting.' suggestion = did_you_mean(setting, defaults) diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index a57ed729..c98527cd 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -9,6 +9,8 @@ from tests import unittest class ValidateSettingsTest(unittest.TestCase): def setUp(self): self.defaults = { + 'BACKENDS': ['a'], + 'FRONTENDS': ['a'], 'MPD_SERVER_HOSTNAME': '::', 'MPD_SERVER_PORT': 6600, 'SPOTIFY_BITRATE': 160, @@ -66,6 +68,18 @@ class ValidateSettingsTest(unittest.TestCase): 'SPOTIFY_USERNAME', None) self.assertEqual(None, not_secret) + def test_empty_frontends_list_returns_error(self): + result = setting_utils.validate_settings( + self.defaults, {'FRONTENDS': []}) + self.assertEqual( + result['FRONTENDS'], u'Must contain at least one value.') + + def test_empty_backends_list_returns_error(self): + result = setting_utils.validate_settings( + self.defaults, {'BACKENDS': []}) + self.assertEqual( + result['BACKENDS'], u'Must contain at least one value.') + class SettingsProxyTest(unittest.TestCase): def setUp(self):