diff --git a/.gitignore b/.gitignore index 9229541f..6ef1ff32 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +*.egg-info *.pyc *.swp .coverage diff --git a/AUTHORS b/AUTHORS index d6ede848..87925152 100644 --- a/AUTHORS +++ b/AUTHORS @@ -18,3 +18,4 @@ - herrernst - Nick Steel - Zan Dobersek +- Thomas Refis diff --git a/MANIFEST.in b/MANIFEST.in index 6a64cb9a..f98952ca 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,7 +7,7 @@ include mopidy/backends/spotify/spotify_appkey.key include pylintrc recursive-include docs * prune docs/_build -recursive-include mopidy/frontends/http/data/ +recursive-include mopidy/frontends/http/data * recursive-include requirements * recursive-include tests *.py recursive-include tests/data * diff --git a/README.rst b/README.rst index 8598b153..b572cdab 100644 --- a/README.rst +++ b/README.rst @@ -18,9 +18,9 @@ platforms, including Windows, Mac OS X, Linux, Android and iOS. To get started with Mopidy, check out `the docs `_. - `Documentation `_ -- `Source code `_ -- `Issue tracker `_ -- `CI server `_ +- `Source code `_ +- `Issue tracker `_ +- `CI server `_ - IRC: ``#mopidy`` at `irc.freenode.net `_ - Mailing list: `mopidy@googlegroups.com `_ -- `Download development snapshot `_ +- `Download development snapshot `_ diff --git a/bin/mopidy b/bin/mopidy deleted file mode 100755 index 0472518e..00000000 --- a/bin/mopidy +++ /dev/null @@ -1,5 +0,0 @@ -#! /usr/bin/env python - -if __name__ == '__main__': - from mopidy.__main__ import main - main() diff --git a/bin/mopidy-scan b/bin/mopidy-scan deleted file mode 100755 index 00f51809..00000000 --- a/bin/mopidy-scan +++ /dev/null @@ -1,5 +0,0 @@ -#! /usr/bin/env python - -if __name__ == '__main__': - from mopidy.scanner import main - main() diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index 8488b408..a6e6f500 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -20,8 +20,8 @@ The following requirements applies to any frontend implementation: - 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 frontend is enabled if the extension it is part of is enabled. See + :ref:`extensiondev` for more information. - The main actor MUST be able to start and stop the frontend when the main actor is started and stopped. diff --git a/docs/changes.rst b/docs/changes.rst index ee1ea5d7..0c14db72 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -4,6 +4,20 @@ Changes This change log is used to track all major changes to Mopidy. +v0.14.0 (UNRELEASED) +==================== + +**Dependencies** + +- setuptools or distribute is now required. We've introduced this dependency to + use setuptools' entry points functionality to find installed Mopidy + extensions. + +**Spotify backend** + +- Add support for starred playlists, both your own and those owned by other + users. (Fixes: :issue:`326`) + v0.13.0 (2013-03-31) ==================== diff --git a/docs/clients/mpris.rst b/docs/clients/mpris.rst index c782fa26..28da63ed 100644 --- a/docs/clients/mpris.rst +++ b/docs/clients/mpris.rst @@ -36,12 +36,11 @@ 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. +control Mopidy. The frontend is enabled by default, so as long as you have all +its dependencies available, 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 diff --git a/docs/development.rst b/docs/development.rst index 59d004fa..776b004d 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -281,12 +281,6 @@ 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: @@ -296,7 +290,7 @@ settings file in the following way:: SPOTIFY_USERNAME = u'xxxxx' SPOTIFY_PASSWORD = u'xxxxx' -Using this setup you can now run Mopidy with ``PROFILE=silent,spotify mopidy`` +Using this setup you can now run Mopidy with ``PROFILE=silent mopidy`` if you for instance want to test Spotify without any actual audio output. @@ -359,7 +353,6 @@ Creating releases #. Build package and upload to PyPI:: - rm MANIFEST # Will be regenerated by setup.py python setup.py sdist upload #. Update the Debian package. diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index b1928798..0505cec8 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -1,3 +1,5 @@ +.. _extensiondev: + ********************* Extension development ********************* @@ -162,14 +164,13 @@ class that will connect the rest of the dots. #py_modules=['mopidy_soundspot'], zip_safe=False, include_package_data=True, - platforms='any', install_requires=[ 'setuptools', 'Mopidy', 'pysoundspot', ], entry_points={ - 'mopidy.extension': [ + b'mopidy.extension': [ 'soundspot = mopidy_soundspot:Extension', ], }, @@ -202,7 +203,8 @@ The default configuration for the extension is defined by the ``get_default_config()`` method in the ``Extension`` class which returns a :mod:`ConfigParser` compatible config section. The config section's name should be the same as the extension's short name, as defined in the ``entry_points`` -part of ``setup.py``. All extensions should include an ``enabled`` config which +part of ``setup.py``, but prefixed with ``ext.``, for example +``ext.soundspot``. All extensions should include an ``enabled`` config which should default to ``true``. Provide good defaults for all config values so that as few users as possible will need to change them. The exception is if the config value has security implications; in that case you should default to the @@ -234,7 +236,7 @@ meaningful defaults blank, like ``username`` and ``password``. def get_default_config(self): return """ - [soundspot] + [ext.soundspot] enabled = true username = password = @@ -265,13 +267,13 @@ meaningful defaults blank, like ``username`` and ``password``. # You will typically only implement one of the next three methods # in a single extension. - def get_frontend_class(self): + def get_frontend_classes(self): from .frontend import SoundspotFrontend - return SoundspotFrontend + return [SoundspotFrontend] - def get_backend_class(self): + def get_backend_classes(self): from .backend import SoundspotBackend - return SoundspotBackend + return [SoundspotBackend] def register_gstreamer_elements(self): from .mixer import SoundspotMixer diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 2b9806fd..ab81b753 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -135,10 +135,10 @@ Pip. sudo easy_install pip -#. Then get, build, and install the latest releast of pyspotify, pylast, pykka, +#. Then get, build, and install the latest release of pyspotify, pylast, and Mopidy using Pip:: - sudo pip install -U pyspotify pylast pykka mopidy + sudo pip install -U pyspotify pylast mopidy #. Finally, you need to set a couple of :doc:`settings `, and then you're ready to :doc:`run Mopidy `. @@ -171,15 +171,7 @@ can install Mopidy from PyPI using Pip. sudo yum install -y gcc python-devel python-pip -#. Then you'll need to install all of Mopidy's hard dependencies: - - - Pykka >= 1.0:: - - sudo pip install -U pykka - - On Fedora the binary is called ``pip-python``:: - - sudo pip-python install -U pykka +#. Then you'll need to install all of Mopidy's hard non-Python dependencies: - GStreamer 0.10.x, with Python bindings. GStreamer is packaged for most popular Linux distributions. Search for GStreamer in your package manager, @@ -235,7 +227,8 @@ can install Mopidy from PyPI using Pip. sudo pip install -U pyspotify - # Fedora: + On Fedora the binary is called ``pip-python``:: + sudo pip-python install -U pyspotify #. Optional: If you want to scrobble your played tracks to Last.fm, you need @@ -243,7 +236,8 @@ can install Mopidy from PyPI using Pip. sudo pip install -U pylast - # Fedora: + On Fedora the binary is called ``pip-python``:: + sudo pip-python install -U pylast #. Optional: To use MPRIS, e.g. for controlling Mopidy from the Ubuntu Sound @@ -259,7 +253,8 @@ can install Mopidy from PyPI using Pip. sudo pip install -U mopidy - # Fedora: + On Fedora the binary is called ``pip-python``:: + sudo pip-python install -U mopidy To upgrade Mopidy to future releases, just rerun this command. diff --git a/mopidy/__init__.py b/mopidy/__init__.py index b091ef25..416f4fbf 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -15,9 +15,9 @@ if not (2, 6) <= sys.version_info < (3,): '.'.join(map(str, sys.version_info[:3]))) if (isinstance(pykka.__version__, basestring) - and not SV('1.0') <= SV(pykka.__version__) < SV('2.0')): + and not SV('1.1') <= SV(pykka.__version__) < SV('2.0')): sys.exit( - 'Mopidy requires Pykka >= 1.0, < 2, but found %s' % pykka.__version__) + 'Mopidy requires Pykka >= 1.1, < 2, but found %s' % pykka.__version__) warnings.filterwarnings('ignore', 'could not open display') diff --git a/mopidy/__main__.py b/mopidy/__main__.py index a7e914a9..3831bd7e 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -9,6 +9,7 @@ import sys import gobject gobject.threads_init() +import pkg_resources import pykka.debug @@ -36,8 +37,7 @@ 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) + deps, log, path, process, settings as settings_utils, versioning) logger = logging.getLogger('mopidy.main') @@ -54,10 +54,11 @@ def main(): log.setup_logging(options.verbosity_level, options.save_debug_log) check_old_folders() setup_settings(options.interactive) + extensions = load_extensions() audio = setup_audio() - backends = setup_backends(audio) + backends = setup_backends(extensions, audio) core = setup_core(audio, backends) - setup_frontends(core) + setup_frontends(extensions, core) loop.run() except exceptions.SettingsError as ex: logger.error(ex.message) @@ -67,9 +68,9 @@ def main(): logger.exception(ex) finally: loop.quit() - stop_frontends() + stop_frontends(extensions) stop_core() - stop_backends() + stop_backends(extensions) stop_audio() process.stop_remaining_actors() @@ -138,51 +139,88 @@ def setup_settings(interactive): sys.exit(1) +def load_extensions(): + extensions = [] + for entry_point in pkg_resources.iter_entry_points('mopidy.extension'): + logger.debug('Loading extension %s', entry_point.name) + + # TODO Filter out disabled extensions + + try: + extension_class = entry_point.load() + except pkg_resources.DistributionNotFound as ex: + logger.info( + 'Disabled extension %s: Dependency %s not found', + entry_point.name, ex) + continue + + extension = extension_class() + + # TODO Validate configuration + + try: + extension.validate_environment() + except exceptions.ExtensionError as ex: + logger.info( + 'Disabled extension: %s (%s)', extension.name, ex.message) + continue + + logger.info( + 'Loaded extension %s: %s %s', + entry_point.name, extension.name, extension.version) + extensions.append(extension) + return extensions + + def setup_audio(): + logger.info('Starting Mopidy audio') return Audio.start().proxy() def stop_audio(): + logger.info('Stopping Mopidy audio') process.stop_actors_by_class(Audio) -def setup_backends(audio): +def setup_backends(extensions, audio): + logger.info('Starting Mopidy backends') 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) + for extension in extensions: + for backend_class in extension.get_backend_classes(): + backend = backend_class.start(audio=audio).proxy() + backends.append(backend) return backends -def stop_backends(): - for backend_class_name in settings.BACKENDS: - process.stop_actors_by_class(importing.get_class(backend_class_name)) +def stop_backends(extensions): + logger.info('Stopping Mopidy backends') + for extension in extensions: + for backend_class in extension.get_backend_classes(): + process.stop_actors_by_class(backend_class) def setup_core(audio, backends): + logger.info('Starting Mopidy core') return Core.start(audio=audio, backends=backends).proxy() def stop_core(): + logger.info('Stopping Mopidy core') process.stop_actors_by_class(Core) -def setup_frontends(core): - for frontend_class_name in settings.FRONTENDS: - try: - importing.get_class(frontend_class_name).start(core=core) - except exceptions.OptionalDependencyError as ex: - logger.info('Disabled: %s (%s)', frontend_class_name, ex) +def setup_frontends(extensions, core): + logger.info('Starting Mopidy frontends') + for extension in extensions: + for frontend_class in extension.get_frontend_classes(): + frontend_class.start(core=core) -def stop_frontends(): - for frontend_class_name in settings.FRONTENDS: - try: - frontend_class = importing.get_class(frontend_class_name) +def stop_frontends(extensions): + logger.info('Stopping Mopidy frontends') + for extension in extensions: + for frontend_class in extension.get_frontend_classes(): process.stop_actors_by_class(frontend_class) - except exceptions.OptionalDependencyError: - pass if __name__ == '__main__': diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 8ee58d3b..c2001da5 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -1,4 +1,10 @@ -"""A backend for playing music from a local music archive. +from __future__ import unicode_literals + +import mopidy +from mopidy import ext + + +__doc__ = """A backend for playing music from a local music archive. This backend handles URIs starting with ``file:``. @@ -20,7 +26,21 @@ https://github.com/mopidy/mopidy/issues?labels=Local+backend - :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` """ -from __future__ import unicode_literals -# flake8: noqa -from .actor import LocalBackend +class Extension(ext.Extension): + + name = 'Mopidy-Local' + version = mopidy.__version__ + + def get_default_config(self): + return '[ext.local]' + + def validate_config(self, config): + pass + + def validate_environment(self): + pass + + def get_backend_classes(self): + from .actor import LocalBackend + return [LocalBackend] diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 507511f4..503d9eb6 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -1,4 +1,39 @@ -"""A backend for playing music from Spotify +from __future__ import unicode_literals + +import mopidy +from mopidy import ext +from mopidy.exceptions import ExtensionError +from mopidy.utils.formatting import indent + + +config = """ +[ext.spotify] + +# If the Spotify extension should be enabled or not +enabled = true + +# Your Spotify Premium username +username = + +# Your Spotify Premium password +password = + +# The preferred audio bitrate. Valid values are 96, 160, 320 +bitrate = 160 + +# Max number of seconds to wait for Spotify operations to complete +timeout = 10 + +# Path to the Spotify data cache. Cannot be shared with other Spotify apps +cache_path = $XDG_CACHE_DIR/mopidy/spotify + +# Connect to Spotify through a proxy +proxy_host = +proxy_username = +proxy_password = +""" + +__doc__ = """A backend for playing music from Spotify `Spotify `_ is a music streaming service. The backend uses the official `libspotify @@ -22,14 +57,36 @@ https://github.com/mopidy/mopidy/issues?labels=Spotify+backend .. literalinclude:: ../../../requirements/spotify.txt -**Settings:** +**Default config:** -- :attr:`mopidy.settings.SPOTIFY_CACHE_PATH` -- :attr:`mopidy.settings.SPOTIFY_USERNAME` -- :attr:`mopidy.settings.SPOTIFY_PASSWORD` -""" +.. code-block:: ini -from __future__ import unicode_literals +%(config)s +""" % {'config': indent(config)} -# flake8: noqa -from .actor import SpotifyBackend + +class Extension(ext.Extension): + + name = 'Mopidy-Spotify' + version = mopidy.__version__ + + def get_default_config(self): + return config + + def validate_config(self, config): + if not config.getboolean('spotify', 'enabled'): + return + if not config.get('spotify', 'username'): + raise ExtensionError('Config spotify.username not set') + if not config.get('spotify', 'password'): + raise ExtensionError('Config spotify.password not set') + + def validate_environment(self): + try: + import spotify # noqa + except ImportError as e: + raise ExtensionError('pyspotify library not found', e) + + def get_backend_classes(self): + from .actor import SpotifyBackend + return [SpotifyBackend] diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 6f386aae..cedff8d1 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -169,6 +169,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): return playlists = map( translator.to_mopidy_playlist, self.session.playlist_container()) + playlists.append(translator.to_mopidy_playlist(self.session.starred())) playlists = filter(None, playlists) self.backend.playlists.playlists = playlists logger.info('Loaded %d Spotify playlist(s)', len(playlists)) diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index ba5f85da..dfd9d99a 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -71,16 +71,19 @@ def to_mopidy_playlist(spotify_playlist): if not spotify_playlist.is_loaded(): return Playlist(uri=uri, name='[loading...]') name = spotify_playlist.name() + tracks = [ + to_mopidy_track(spotify_track) + for spotify_track in spotify_playlist + if not spotify_track.is_local() + ] if not name: - # Other user's "starred" playlists isn't handled properly by pyspotify - # See https://github.com/mopidy/pyspotify/issues/81 - return + name = 'Starred' + # Tracks in the Starred playlist are in reverse order from the official + # client. + tracks.reverse() if spotify_playlist.owner().canonical_name() != settings.SPOTIFY_USERNAME: name += ' by ' + spotify_playlist.owner().canonical_name() return Playlist( uri=uri, name=name, - tracks=[ - to_mopidy_track(spotify_track) - for spotify_track in spotify_playlist - if not spotify_track.is_local()]) + tracks=tracks) diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py index 82755540..4096476e 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/backends/stream/__init__.py @@ -1,4 +1,10 @@ -"""A backend for playing music for streaming music. +from __future__ import unicode_literals + +import mopidy +from mopidy import ext + + +__doc__ = """A backend for playing music for streaming music. This backend will handle streaming of URIs in :attr:`mopidy.settings.STREAM_PROTOCOLS` assuming the right plugins are @@ -17,7 +23,21 @@ https://github.com/mopidy/mopidy/issues?labels=Stream+backend - :attr:`mopidy.settings.STREAM_PROTOCOLS` """ -from __future__ import unicode_literals -# flake8: noqa -from .actor import StreamBackend +class Extension(ext.Extension): + + name = 'Mopidy-Stream' + version = mopidy.__version__ + + def get_default_config(self): + return '[ext.stream]' + + def validate_config(self, config): + pass + + def validate_environment(self): + pass + + def get_backend_classes(self): + from .actor import StreamBackend + return [StreamBackend] diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index 14d374a0..0c370c8a 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -37,3 +37,7 @@ class ConfigError(MopidyException): class OptionalDependencyError(MopidyException): pass + + +class ExtensionError(MopidyException): + pass diff --git a/mopidy/ext.py b/mopidy/ext.py new file mode 100644 index 00000000..6cc35139 --- /dev/null +++ b/mopidy/ext.py @@ -0,0 +1,27 @@ +from __future__ import unicode_literals + + +class Extension(object): + + name = None + version = None + + def get_default_config(self): + raise NotImplementedError( + 'Add at least a config section with "enabled = true"') + + def validate_config(self, config): + raise NotImplementedError( + 'You must explicitly pass config validation if not needed') + + def validate_environment(self): + pass + + def get_frontend_classes(self): + return [] + + def get_backend_classes(self): + return [] + + def register_gstreamer_elements(self): + pass diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index e81ddf3f..7b99efd0 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -1,4 +1,11 @@ -""" +from __future__ import unicode_literals + +import mopidy +from mopidy import ext +from mopidy.exceptions import ExtensionError + + +__doc__ = """ The HTTP frontends lets you control Mopidy through HTTP and WebSockets, e.g. from a web based client. @@ -18,8 +25,10 @@ from a web based client. Setup ===== -When this frontend is included in :attr:`mopidy.settings.FRONTENDS`, it starts -a web server at the port specified by :attr:`mopidy.settings.HTTP_SERVER_PORT`. +The frontend is enabled by default if all dependencies are available. + +When it is enabled it starts a web server at the port specified by +:attr:`mopidy.settings.HTTP_SERVER_PORT`. .. warning:: Security @@ -357,15 +366,14 @@ event listeners, and delete the object like this: Example to get started with --------------------------- -1. Create an empty directory for your web client. +1. Make sure that you've installed all dependencies required by the HTTP + frontend. -2. Change the setting :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR` to point +2. Create an empty directory for your web client. + +3. Change the setting :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR` to point to your new directory. -3. Make sure that you've included - ``mopidy.frontends.http.HttpFrontend`` in - :attr:`mopidy.settings.FRONTENDS`. - 4. Start/restart Mopidy. 5. Create a file in the directory named ``index.html`` containing e.g. "Hello, @@ -477,5 +485,29 @@ Example to get started with and all events that are emitted. """ -# flake8: noqa -from .actor import HttpFrontend + +class Extension(ext.Extension): + + name = 'Mopidy-HTTP' + version = mopidy.__version__ + + def get_default_config(self): + return '[ext.http]' + + def validate_config(self, config): + pass + + def validate_environment(self): + try: + import cherrypy # noqa + except ImportError as e: + raise ExtensionError('Library cherrypy not found', e) + + try: + import ws4py # noqa + except ImportError as e: + raise ExtensionError('Library ws4py not found', e) + + def get_frontend_classes(self): + from .actor import HttpFrontend + return [HttpFrontend] diff --git a/mopidy/frontends/lastfm/__init__.py b/mopidy/frontends/lastfm/__init__.py new file mode 100644 index 00000000..439ada50 --- /dev/null +++ b/mopidy/frontends/lastfm/__init__.py @@ -0,0 +1,50 @@ +from __future__ import unicode_literals + +import mopidy +from mopidy import ext +from mopidy.exceptions import ExtensionError + + +__doc__ = """ +Frontend which scrobbles the music you play to your `Last.fm +`_ profile. + +.. note:: + + This frontend requires a free user account at Last.fm. + +**Dependencies:** + +.. literalinclude:: ../../../requirements/lastfm.txt + +**Settings:** + +- :attr:`mopidy.settings.LASTFM_USERNAME` +- :attr:`mopidy.settings.LASTFM_PASSWORD` + +**Usage:** + +The frontend is enabled by default if all dependencies are available. +""" + + +class Extension(ext.Extension): + + name = 'Mopidy-Lastfm' + version = mopidy.__version__ + + def get_default_config(self): + return '[ext.lastfm]' + + def validate_config(self, config): + pass + + def validate_environment(self): + try: + import pylast # noqa + except ImportError as e: + raise ExtensionError('pylast library not found', e) + + def get_frontend_classes(self): + from .actor import LastfmFrontend + return [LastfmFrontend] diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm/actor.py similarity index 87% rename from mopidy/frontends/lastfm.py rename to mopidy/frontends/lastfm/actor.py index 61dc306c..60a909e0 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm/actor.py @@ -1,27 +1,3 @@ -""" -Frontend which scrobbles the music you play to your `Last.fm -`_ profile. - -.. note:: - - This frontend requires a free user account at Last.fm. - -**Dependencies:** - -.. literalinclude:: ../../../requirements/lastfm.txt - -**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. -""" - from __future__ import unicode_literals import logging diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 6b4eacc8..5cb8b8c0 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -1,4 +1,10 @@ -"""The MPD server frontend. +from __future__ import unicode_literals + +import mopidy +from mopidy import ext + + +__doc__ = """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 @@ -16,9 +22,7 @@ original MPD server. **Usage:** -Make sure :attr:`mopidy.settings.FRONTENDS` includes -``mopidy.frontends.mpd.MpdFrontend``. By default, the setting includes the MPD -frontend. +The frontend is enabled by default. **Limitations:** @@ -44,7 +48,21 @@ near future: - Live update of the music database is not supported """ -from __future__ import unicode_literals -# flake8: noqa -from .actor import MpdFrontend +class Extension(ext.Extension): + + name = 'Mopidy-MPD' + version = mopidy.__version__ + + def get_default_config(self): + return '[ext.mpd]' + + def validate_config(self, config): + pass + + def validate_environment(self): + pass + + def get_frontend_classes(self): + from .actor import MpdFrontend + return [MpdFrontend] diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 2be6efea..940c4210 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -1,4 +1,11 @@ -""" +from __future__ import unicode_literals + +import mopidy +from mopidy import ext +from mopidy.exceptions import ExtensionError + + +__doc__ = """ Frontend which lets you control Mopidy through the Media Player Remote Interfacing Specification (`MPRIS `_) D-Bus interface. @@ -25,9 +32,7 @@ An example of an MPRIS client is the `Ubuntu Sound Menu **Usage:** -Make sure :attr:`mopidy.settings.FRONTENDS` includes -``mopidy.frontends.mpris.MprisFrontend``. By default, the setting includes the -MPRIS frontend. +The frontend is enabled by default if all dependencies are available. **Testing the frontend** @@ -50,7 +55,24 @@ Now you can control Mopidy through the player object. Examples: player.Quit(dbus_interface='org.mpris.MediaPlayer2') """ -from __future__ import unicode_literals -# flake8: noqa -from .actor import MprisFrontend +class Extension(ext.Extension): + + name = 'Mopidy-MPRIS' + version = mopidy.__version__ + + def get_default_config(self): + return '[ext.mpris]' + + def validate_config(self, config): + pass + + def validate_environment(self): + try: + import dbus # noqa + except ImportError as e: + raise ExtensionError('Library dbus not found', e) + + def get_frontend_classes(self): + from .actor import MprisFrontend + return [MprisFrontend] diff --git a/mopidy/settings.py b/mopidy/settings.py index d0d279c2..cde6430a 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -9,25 +9,6 @@ All available settings and their default values. from __future__ import unicode_literals -#: 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.local.LocalBackend', -#: u'mopidy.backends.spotify.SpotifyBackend', -#: u'mopidy.backends.stream.StreamBackend', -#: ) -BACKENDS = ( - 'mopidy.backends.local.LocalBackend', - 'mopidy.backends.spotify.SpotifyBackend', - 'mopidy.backends.stream.StreamBackend', -) - #: The log format used for informational logging. #: #: See http://docs.python.org/2/library/logging.html#formatter-objects for @@ -58,22 +39,6 @@ DEBUG_LOG_FILENAME = 'mopidy.log' #: DESKTOP_FILE = u'/usr/share/applications/mopidy.desktop' DESKTOP_FILE = '/usr/share/applications/mopidy.desktop' -#: List of server frontends to use. See :ref:`frontend-implementations` for -#: available frontends. -#: -#: Default:: -#: -#: FRONTENDS = ( -#: u'mopidy.frontends.mpd.MpdFrontend', -#: u'mopidy.frontends.lastfm.LastfmFrontend', -#: u'mopidy.frontends.mpris.MprisFrontend', -#: ) -FRONTENDS = ( - 'mopidy.frontends.mpd.MpdFrontend', - 'mopidy.frontends.lastfm.LastfmFrontend', - 'mopidy.frontends.mpris.MprisFrontend', -) - #: Which address Mopidy's HTTP server should bind to. #: #: Used by :mod:`mopidy.frontends.http`. diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 8ae61e5b..051f0f1c 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -123,7 +123,6 @@ def validate_settings(defaults, settings): changed = { 'DUMP_LOG_FILENAME': 'DEBUG_LOG_FILENAME', 'DUMP_LOG_FORMAT': 'DEBUG_LOG_FORMAT', - 'FRONTEND': 'FRONTENDS', 'GSTREAMER_AUDIO_SINK': 'OUTPUT', 'LOCAL_MUSIC_FOLDER': 'LOCAL_MUSIC_PATH', 'LOCAL_OUTPUT_OVERRIDE': 'OUTPUT', @@ -143,16 +142,9 @@ def validate_settings(defaults, settings): } must_be_iterable = [ - 'BACKENDS', - 'FRONTENDS', 'STREAM_PROTOCOLS', ] - must_have_value_set = [ - 'BACKENDS', - 'FRONTENDS', - ] - for setting, value in settings.iteritems(): if setting in changed: if changed[setting] is None: @@ -182,9 +174,6 @@ def validate_settings(defaults, settings): 'Must be a tuple. ' "Remember the comma after single values: (u'value',)") - elif setting in must_have_value_set and not value: - errors[setting] = 'Must be set.' - elif setting not in defaults and not setting.startswith('CUSTOM_'): errors[setting] = 'Unknown setting.' suggestion = did_you_mean(setting, defaults) diff --git a/requirements/core.txt b/requirements/core.txt index 9ffac2cf..d8e81e61 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -1,2 +1,5 @@ +setuptools +# Available as python-setuptools in Debian/Ubuntu + Pykka >= 1.1 # Available as python-pykka from apt.mopidy.com diff --git a/setup.py b/setup.py index 5840ca53..8d3d6d5a 100644 --- a/setup.py +++ b/setup.py @@ -1,101 +1,59 @@ -""" -Most of this file is taken from the Django project, which is BSD licensed. -""" - from __future__ import unicode_literals -from distutils.core import setup -from distutils.command.install_data import install_data -from distutils.command.install import INSTALL_SCHEMES -import os import re -import sys + +from setuptools import setup, find_packages -def get_version(): - init_py = open('mopidy/__init__.py').read() +def get_version(filename): + init_py = open(filename).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 - # with MacOS 10.5 has an Apple-specific fix for this in - # distutils.command.install_data#306. It fixes install_lib but not - # install_data, which is why we roll our own install_data class. - - def finalize_options(self): - # By the time finalize_options is called, install.install_lib is set to - # the fixed directory, so we set the installdir to install_lib. The - # install_data class uses ('install_data', 'install_dir') instead. - 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 - platform-neutral way. - """ - if result is None: - result = [] - head, tail = os.path.split(path) - if head == '': - return [tail] + result - if head == path: - 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/ -# thread/35ec7b2fed36eaec/2105ee4d9e8042cb -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 = [], [] -root_dir = os.path.dirname(__file__) -if root_dir != b'': - os.chdir(root_dir) -project_dir = b'mopidy' - -for dirpath, dirnames, filenames in os.walk(project_dir): - # Ignore dirnames that start with '.' - for i, dirname in enumerate(dirnames): - if dirname.startswith(b'.'): - del dirnames[i] - if b'__init__.py' in filenames: - packages.append(b'.'.join(fullsplit(dirpath))) - elif filenames: - data_files.append([ - dirpath, [os.path.join(dirpath, f) for f in filenames]]) - - setup( name='Mopidy', - version=get_version(), - author='Stein Magnus Jodal', - author_email='stein.magnus@jodal.no', - packages=packages, - package_data={b'mopidy': ['backends/spotify/spotify_appkey.key']}, - cmdclass=cmdclasses, - data_files=data_files, - scripts=['bin/mopidy', 'bin/mopidy-scan'], + version=get_version('mopidy/__init__.py'), url='http://www.mopidy.com/', license='Apache License, Version 2.0', + author='Stein Magnus Jodal', + author_email='stein.magnus@jodal.no', description='Music server with MPD and Spotify support', long_description=open('README.rst').read(), + packages=find_packages(exclude=['tests', 'tests.*']), + zip_safe=False, + include_package_data=True, + install_requires=[ + 'setuptools', + 'Pykka >= 1.1', + ], + extras_require={ + b'spotify': ['pyspotify >= 1.9, < 1.11'], + b'lastfm': ['pylast >= 0.5.7'], + b'http': ['cherrypy >= 3.2.2', 'ws4py >= 0.2.3'], + b'external_mixers': ['pyserial'], + }, + test_suite='nose.collector', + tests_require=[ + 'nose', + 'mock >= 0.7', + 'unittest2', + ], + entry_points={ + b'console_scripts': [ + 'mopidy = mopidy.__main__:main', + 'mopidy-scan = mopidy.scanner:main', + ], + b'mopidy.extension': [ + 'http = mopidy.frontends.http:Extension [http]', + 'lastfm = mopidy.frontends.lastfm:Extension [lastfm]', + 'local = mopidy.backends.local:Extension', + 'mpd = mopidy.frontends.mpd:Extension', + 'mpris = mopidy.frontends.mpris:Extension', + 'spotify = mopidy.backends.spotify:Extension [spotify]', + 'stream = mopidy.backends.stream:Extension', + ], + }, classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: No Input/Output (Daemon)', diff --git a/tests/backends/local/events_test.py b/tests/backends/local/events_test.py index 79d2780b..b35fad1a 100644 --- a/tests/backends/local/events_test.py +++ b/tests/backends/local/events_test.py @@ -1,12 +1,12 @@ from mopidy import settings -from mopidy.backends.local import LocalBackend +from mopidy.backends.local import actor from tests import unittest, path_to_data_dir from tests.backends.base import events class LocalBackendEventsTest(events.BackendEventsTest, unittest.TestCase): - backend_class = LocalBackend + backend_class = actor.LocalBackend def setUp(self): settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache') diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index 7324d85f..7bf8d565 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals from mopidy import settings -from mopidy.backends.local import LocalBackend +from mopidy.backends.local import actor from tests import unittest, path_to_data_dir from tests.backends.base.library import LibraryControllerTest @@ -9,7 +9,7 @@ from tests.backends.base.library import LibraryControllerTest class LocalLibraryControllerTest(LibraryControllerTest, unittest.TestCase): - backend_class = LocalBackend + backend_class = actor.LocalBackend def setUp(self): settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('library_tag_cache') diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 9a306ee0..8d997d2e 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals from mopidy import settings -from mopidy.backends.local import LocalBackend +from mopidy.backends.local import actor from mopidy.core import PlaybackState from mopidy.models import Track from mopidy.utils.path import path_to_uri @@ -12,12 +12,11 @@ from tests.backends.local import generate_song class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): - backend_class = LocalBackend + backend_class = actor.LocalBackend tracks = [ Track(uri=generate_song(i), length=4464) for i in range(1, 4)] def setUp(self): - settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache') super(LocalPlaybackControllerTest, self).setUp() diff --git a/tests/backends/local/playlists_test.py b/tests/backends/local/playlists_test.py index 70ed27d6..f3794cee 100644 --- a/tests/backends/local/playlists_test.py +++ b/tests/backends/local/playlists_test.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import os from mopidy import settings -from mopidy.backends.local import LocalBackend +from mopidy.backends.local import actor from mopidy.models import Track from mopidy.utils.path import path_to_uri @@ -16,7 +16,7 @@ from tests.backends.local import generate_song class LocalPlaylistsControllerTest( PlaylistsControllerTest, unittest.TestCase): - backend_class = LocalBackend + backend_class = actor.LocalBackend def setUp(self): settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache') diff --git a/tests/backends/local/tracklist_test.py b/tests/backends/local/tracklist_test.py index 735043d6..0c47a5db 100644 --- a/tests/backends/local/tracklist_test.py +++ b/tests/backends/local/tracklist_test.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals from mopidy import settings -from mopidy.backends.local import LocalBackend +from mopidy.backends.local import actor from mopidy.models import Track from tests import unittest, path_to_data_dir @@ -10,12 +10,11 @@ from tests.backends.local import generate_song class LocalTracklistControllerTest(TracklistControllerTest, unittest.TestCase): - backend_class = LocalBackend + backend_class = actor.LocalBackend tracks = [ Track(uri=generate_song(i), length=4464) for i in range(1, 4)] def setUp(self): - settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache') super(LocalTracklistControllerTest, self).setUp() diff --git a/tests/exceptions_test.py b/tests/exceptions_test.py new file mode 100644 index 00000000..2bc838d7 --- /dev/null +++ b/tests/exceptions_test.py @@ -0,0 +1,25 @@ +from __future__ import unicode_literals + +from mopidy import exceptions + +from tests import unittest + + +class ExceptionsTest(unittest.TestCase): + def test_exception_can_include_message_string(self): + exc = exceptions.MopidyException('foo') + + self.assertEqual(exc.message, 'foo') + self.assertEqual(str(exc), 'foo') + + def test_settings_error_is_a_mopidy_exception(self): + self.assert_(issubclass( + exceptions.SettingsError, exceptions.MopidyException)) + + def test_optional_dependency_error_is_a_mopidy_exception(self): + self.assert_(issubclass( + exceptions.OptionalDependencyError, exceptions.MopidyException)) + + def test_extension_error_is_a_mopidy_exception(self): + self.assert_(issubclass( + exceptions.ExtensionError, exceptions.MopidyException)) diff --git a/tests/ext_test.py b/tests/ext_test.py new file mode 100644 index 00000000..ac238ca5 --- /dev/null +++ b/tests/ext_test.py @@ -0,0 +1,34 @@ +from __future__ import unicode_literals + +from mopidy.ext import Extension + +from tests import unittest + + +class ExtensionTest(unittest.TestCase): + def setUp(self): + self.ext = Extension() + + def test_name_is_none(self): + self.assertIsNone(self.ext.name) + + def test_version_is_none(self): + self.assertIsNone(self.ext.version) + + def test_get_default_config_raises_not_implemented(self): + self.assertRaises(NotImplementedError, self.ext.get_default_config) + + def test_validate_config_raises_not_implemented(self): + self.assertRaises(NotImplementedError, self.ext.validate_config, None) + + def test_validate_environment_does_nothing_by_default(self): + self.assertIsNone(self.ext.validate_environment()) + + def test_get_frontend_classes_returns_an_empty_list(self): + self.assertListEqual(self.ext.get_frontend_classes(), []) + + def test_get_backend_classes_returns_an_empty_list(self): + self.assertListEqual(self.ext.get_backend_classes(), []) + + def test_register_gstreamer_elements_does_nothing_by_default(self): + self.assertIsNone(self.ext.register_gstreamer_elements()) diff --git a/tests/frontends/http/events_test.py b/tests/frontends/http/events_test.py index 5c064e93..77438fd4 100644 --- a/tests/frontends/http/events_test.py +++ b/tests/frontends/http/events_test.py @@ -12,7 +12,7 @@ import mock from mopidy.exceptions import OptionalDependencyError try: - from mopidy.frontends.http import HttpFrontend + from mopidy.frontends.http import actor except OptionalDependencyError: pass @@ -24,7 +24,7 @@ from tests import unittest @mock.patch('cherrypy.engine.publish') class HttpEventsTest(unittest.TestCase): def setUp(self): - self.http = HttpFrontend(core=mock.Mock()) + self.http = actor.HttpFrontend(core=mock.Mock()) def test_track_playback_paused_is_broadcasted(self, publish): publish.reset_mock() diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py index f1add1b3..78e40071 100644 --- a/tests/frontends/mpris/events_test.py +++ b/tests/frontends/mpris/events_test.py @@ -8,7 +8,7 @@ from mopidy.exceptions import OptionalDependencyError from mopidy.models import Playlist, TlTrack try: - from mopidy.frontends.mpris import MprisFrontend, objects + from mopidy.frontends.mpris import actor, objects except OptionalDependencyError: pass @@ -19,7 +19,7 @@ from tests import unittest class BackendEventsTest(unittest.TestCase): def setUp(self): # As a plain class, not an actor: - self.mpris_frontend = MprisFrontend(core=None) + self.mpris_frontend = actor.MprisFrontend(core=None) self.mpris_object = mock.Mock(spec=objects.MprisObject) self.mpris_frontend.mpris_object = self.mpris_object diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 51f0d89c..2c13066c 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -11,8 +11,6 @@ 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, @@ -75,26 +73,6 @@ 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'], 'Must be set.') - - def test_empty_backends_list_returns_error(self): - result = setting_utils.validate_settings( - self.defaults, {'BACKENDS': []}) - self.assertEqual( - result['BACKENDS'], 'Must be set.') - - def test_noniterable_multivalue_setting_returns_error(self): - result = setting_utils.validate_settings( - self.defaults, {'FRONTENDS': ('this is not a tuple')}) - self.assertEqual( - result['FRONTENDS'], - 'Must be a tuple. ' - "Remember the comma after single values: (u'value',)") - class SettingsProxyTest(unittest.TestCase): def setUp(self):