diff --git a/README.rst b/README.rst
index c063de79..13ab0f92 100644
--- a/README.rst
+++ b/README.rst
@@ -9,11 +9,9 @@ in Spotify's vast archive, manage playlists, and play music, you can use most
platforms, including Windows, Mac OS X, Linux, Android and iOS.
To install Mopidy, check out
-`the installation docs `_.
+`the installation docs `_.
-- `Documentation for the latest release `_
-- `Documentation for the development version
- `_
+- `Documentation `_
- `Source code `_
- `Issue tracker `_
- IRC: ``#mopidy`` at `irc.freenode.net `_
diff --git a/docs/_templates/layout.html b/docs/_templates/layout.html
deleted file mode 100644
index d6cb00e9..00000000
--- a/docs/_templates/layout.html
+++ /dev/null
@@ -1,15 +0,0 @@
-{% extends "!layout.html" %}
-
-{% block footer %}
-{{ super() }}
-
-
-{% endblock %}
diff --git a/docs/_themes/nature/static/nature.css_t b/docs/_themes/nature/static/nature.css_t
deleted file mode 100644
index b6c0f22e..00000000
--- a/docs/_themes/nature/static/nature.css_t
+++ /dev/null
@@ -1,236 +0,0 @@
-/**
- * Sphinx stylesheet -- default theme
- * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- */
-
-@import url("basic.css");
-
-/* -- page layout ----------------------------------------------------------- */
-
-body {
- font-family: Arial, sans-serif;
- font-size: 100%;
- background-color: #111111;
- color: #555555;
- margin: 0;
- padding: 0;
-}
-
-div.documentwrapper {
- float: left;
- width: 100%;
-}
-
-div.bodywrapper {
- margin: 0 0 0 300px;
-}
-
-hr{
- border: 1px solid #B1B4B6;
-}
-
-div.document {
- background-color: #eeeeee;
-}
-
-div.body {
- background-color: #ffffff;
- color: #3E4349;
- padding: 1em 30px 30px 30px;
- font-size: 0.9em;
-}
-
-div.footer {
- color: #555;
- width: 100%;
- padding: 13px 0;
- text-align: center;
- font-size: 75%;
-}
-
-div.footer a {
- color: #444444;
-}
-
-div.related {
- background-color: #6BA81E;
- line-height: 36px;
- color: #ffffff;
- text-shadow: 0px 1px 0 #444444;
- font-size: 1.1em;
-}
-
-div.related a {
- color: #E2F3CC;
-}
-
-div.related .right {
- font-size: 0.9em;
-}
-
-div.sphinxsidebar {
- font-size: 0.9em;
- line-height: 1.5em;
- width: 300px
-}
-
-div.sphinxsidebarwrapper{
- padding: 20px 0;
-}
-
-div.sphinxsidebar h3,
-div.sphinxsidebar h4 {
- font-family: Arial, sans-serif;
- color: #222222;
- font-size: 1.2em;
- font-weight: bold;
- margin: 0;
- padding: 5px 10px;
- text-shadow: 1px 1px 0 white
-}
-
-div.sphinxsidebar h3 a {
- color: #444444;
-}
-
-div.sphinxsidebar p {
- color: #888888;
- padding: 5px 20px;
- margin: 0.5em 0px;
-}
-
-div.sphinxsidebar p.topless {
-}
-
-div.sphinxsidebar ul {
- margin: 10px 10px 10px 20px;
- padding: 0;
- color: #000000;
-}
-
-div.sphinxsidebar a {
- color: #444444;
-}
-
-div.sphinxsidebar a:hover {
- color: #E32E00;
-}
-
-div.sphinxsidebar input {
- border: 1px solid #cccccc;
- font-family: sans-serif;
- font-size: 1.1em;
- padding: 0.15em 0.3em;
-}
-
-div.sphinxsidebar input[type=text]{
- margin-left: 20px;
-}
-
-/* -- body styles ----------------------------------------------------------- */
-
-a {
- color: #005B81;
- text-decoration: none;
-}
-
-a:hover {
- color: #E32E00;
-}
-
-div.body h1,
-div.body h2,
-div.body h3,
-div.body h4,
-div.body h5,
-div.body h6 {
- font-family: Arial, sans-serif;
- font-weight: normal;
- color: #212224;
- margin: 30px 0px 10px 0px;
- padding: 5px 0 5px 0px;
- text-shadow: 0px 1px 0 white;
- border-bottom: 1px solid #C8D5E3;
-}
-
-div.body h1 { margin-top: 0; font-size: 200%; }
-div.body h2 { font-size: 150%; }
-div.body h3 { font-size: 120%; }
-div.body h4 { font-size: 110%; }
-div.body h5 { font-size: 100%; }
-div.body h6 { font-size: 100%; }
-
-a.headerlink {
- color: #c60f0f;
- font-size: 0.8em;
- padding: 0 4px 0 4px;
- text-decoration: none;
-}
-
-a.headerlink:hover {
- background-color: #c60f0f;
- color: white;
-}
-
-div.body p, div.body dd, div.body li {
- line-height: 1.8em;
-}
-
-div.admonition p.admonition-title + p {
- display: inline;
-}
-
-div.highlight{
- background-color: white;
-}
-
-div.note {
- background-color: #eeeeee;
- border: 1px solid #cccccc;
-}
-
-div.seealso {
- background-color: #ffffcc;
- border: 1px solid #ffff66;
-}
-
-div.topic {
- background-color: #fafafa;
- border-width: 0;
-}
-
-div.warning {
- background-color: #ffe4e4;
- border: 1px solid #ff6666;
-}
-
-p.admonition-title {
- display: inline;
-}
-
-p.admonition-title:after {
- content: ":";
-}
-
-pre {
- padding: 10px;
- background-color: #eeeeee;
- color: #222222;
- line-height: 1.5em;
- font-size: 1.1em;
- margin: 1.5em 0 1.5em 0;
- -webkit-box-shadow: 0px 0px 4px #d8d8d8;
- -moz-box-shadow: 0px 0px 4px #d8d8d8;
- box-shadow: 0px 0px 4px #d8d8d8;
-}
-
-tt {
- color: #222222;
- padding: 1px 2px;
- font-size: 1.2em;
- font-family: monospace;
-}
-
-#table-of-contents ul {
- padding-left: 2em;
-}
diff --git a/docs/_themes/nature/static/pygments.css b/docs/_themes/nature/static/pygments.css
deleted file mode 100644
index 652b7612..00000000
--- a/docs/_themes/nature/static/pygments.css
+++ /dev/null
@@ -1,54 +0,0 @@
-.c { color: #999988; font-style: italic } /* Comment */
-.k { font-weight: bold } /* Keyword */
-.o { font-weight: bold } /* Operator */
-.cm { color: #999988; font-style: italic } /* Comment.Multiline */
-.cp { color: #999999; font-weight: bold } /* Comment.preproc */
-.c1 { color: #999988; font-style: italic } /* Comment.Single */
-.gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */
-.ge { font-style: italic } /* Generic.Emph */
-.gr { color: #aa0000 } /* Generic.Error */
-.gh { color: #999999 } /* Generic.Heading */
-.gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */
-.go { color: #111 } /* Generic.Output */
-.gp { color: #555555 } /* Generic.Prompt */
-.gs { font-weight: bold } /* Generic.Strong */
-.gu { color: #aaaaaa } /* Generic.Subheading */
-.gt { color: #aa0000 } /* Generic.Traceback */
-.kc { font-weight: bold } /* Keyword.Constant */
-.kd { font-weight: bold } /* Keyword.Declaration */
-.kp { font-weight: bold } /* Keyword.Pseudo */
-.kr { font-weight: bold } /* Keyword.Reserved */
-.kt { color: #445588; font-weight: bold } /* Keyword.Type */
-.m { color: #009999 } /* Literal.Number */
-.s { color: #bb8844 } /* Literal.String */
-.na { color: #008080 } /* Name.Attribute */
-.nb { color: #999999 } /* Name.Builtin */
-.nc { color: #445588; font-weight: bold } /* Name.Class */
-.no { color: #ff99ff } /* Name.Constant */
-.ni { color: #800080 } /* Name.Entity */
-.ne { color: #990000; font-weight: bold } /* Name.Exception */
-.nf { color: #990000; font-weight: bold } /* Name.Function */
-.nn { color: #555555 } /* Name.Namespace */
-.nt { color: #000080 } /* Name.Tag */
-.nv { color: purple } /* Name.Variable */
-.ow { font-weight: bold } /* Operator.Word */
-.mf { color: #009999 } /* Literal.Number.Float */
-.mh { color: #009999 } /* Literal.Number.Hex */
-.mi { color: #009999 } /* Literal.Number.Integer */
-.mo { color: #009999 } /* Literal.Number.Oct */
-.sb { color: #bb8844 } /* Literal.String.Backtick */
-.sc { color: #bb8844 } /* Literal.String.Char */
-.sd { color: #bb8844 } /* Literal.String.Doc */
-.s2 { color: #bb8844 } /* Literal.String.Double */
-.se { color: #bb8844 } /* Literal.String.Escape */
-.sh { color: #bb8844 } /* Literal.String.Heredoc */
-.si { color: #bb8844 } /* Literal.String.Interpol */
-.sx { color: #bb8844 } /* Literal.String.Other */
-.sr { color: #808000 } /* Literal.String.Regex */
-.s1 { color: #bb8844 } /* Literal.String.Single */
-.ss { color: #bb8844 } /* Literal.String.Symbol */
-.bp { color: #999999 } /* Name.Builtin.Pseudo */
-.vc { color: #ff99ff } /* Name.Variable.Class */
-.vg { color: #ff99ff } /* Name.Variable.Global */
-.vi { color: #ff99ff } /* Name.Variable.Instance */
-.il { color: #009999 } /* Literal.Number.Integer.Long */
\ No newline at end of file
diff --git a/docs/_themes/nature/theme.conf b/docs/_themes/nature/theme.conf
deleted file mode 100644
index 1cc40044..00000000
--- a/docs/_themes/nature/theme.conf
+++ /dev/null
@@ -1,4 +0,0 @@
-[theme]
-inherit = basic
-stylesheet = nature.css
-pygments_style = tango
diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst
index dc53cca2..af0cc991 100644
--- a/docs/api/frontends.rst
+++ b/docs/api/frontends.rst
@@ -7,7 +7,7 @@ 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
+ `_ actor, called the "main actor" from here
on.
- It MAY use additional actors to implement whatever it does, and using actors
in frontend implementations is encouraged.
diff --git a/docs/changes.rst b/docs/changes.rst
index dab00c08..76309461 100644
--- a/docs/changes.rst
+++ b/docs/changes.rst
@@ -4,14 +4,146 @@ Changes
This change log is used to track all major changes to Mopidy.
+v0.8 (in development)
+=====================
-v0.6.0 (in development)
-=======================
+**Changes**
+
+- Added tools/debug-proxy.py to tee client requests to two backends and diff
+ responses. Intended as a developer tool for checking for MPD protocol changes
+ and various client support. Requires gevent, which currently is not a
+ dependency of Mopidy.
+
+- 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. (Fixes: :issue:`162`)
+
+- Removed most traces of multiple outputs support. Having this feature
+ currently seems to be more trouble than what it is worth.
+ :attr:`mopidy.settings.OUTPUTS` setting is no longer supported, and has been
+ replaced with :attr:`mopidy.settings.OUTPUT` which is a GStreamer
+ bin descriped in the same format as gst-launch expects. Default value is
+ ``autoaudiosink``.
+
+
+v0.7.3 (2012-08-11)
+===================
+
+A small maintenance release to fix a crash affecting a few users, and a couple
+of small adjustments to the Spotify backend.
+
+**Changes**
+
+- Fixed crash when logging :exc:`IOError` exceptions on systems using languages
+ with non-ASCII characters, like French.
+
+- Move the default location of the Spotify cache from `~/.cache/mopidy` to
+ `~/.cache/mopidy/spotify`. You can change this by setting
+ :attr:`mopidy.settings.SPOTIFY_CACHE_PATH`.
+
+- Reduce time required to update the Spotify cache on startup. One one
+ system/Spotify account, the time from clean cache to ready for use was
+ reduced from 35s to 12s.
+
+
+v0.7.2 (2012-05-07)
+===================
+
+This is a maintenance release to make Mopidy 0.7 build on systems without all
+of Mopidy's runtime dependencies, like Launchpad PPAs.
+
+**Changes**
+
+- Change from version tuple at :attr:`mopidy.VERSION` to :pep:`386` compliant
+ version string at :attr:`mopidy.__version__` to conform to :pep:`396`.
+
+
+v0.7.1 (2012-04-22)
+===================
+
+This is a maintenance release to make Mopidy 0.7 work with pyspotify >= 1.7.
+
+**Changes**
+
+- Don't override pyspotify's ``notify_main_thread`` callback. The default
+ implementation is sensible, while our override did nothing.
+
+
+v0.7.0 (2012-02-25)
+===================
+
+Not a big release with regard to features, but this release got some
+performance improvements over v0.6, especially for slower Atom systems. It also
+fixes a couple of other bugs, including one which made Mopidy crash when using
+GStreamer from the prereleases of Ubuntu 12.04.
+
+**Changes**
+
+- The MPD command ``playlistinfo`` is now faster, thanks to John Bäckstrand.
+
+- Added the method
+ :meth:`mopidy.backends.base.CurrentPlaylistController.length()`,
+ :meth:`mopidy.backends.base.CurrentPlaylistController.index()`, and
+ :meth:`mopidy.backends.base.CurrentPlaylistController.slice()` to reduce the
+ need for copying the entire current playlist from one thread to another.
+ Thanks to John Bäckstrand for pinpointing the issue.
+
+- Fix crash on creation of config and cache directories if intermediate
+ directories does not exist. This was especially the case on OS X, where
+ ``~/.config`` doesn't exist for most users.
+
+- Fix ``gst.LinkError`` which appeared when using newer versions of GStreamer,
+ e.g. on Ubuntu 12.04 Alpha. (Fixes: :issue:`144`)
+
+- Fix crash on mismatching quotation in ``list`` MPD queries. (Fixes:
+ :issue:`137`)
+
+- Volume is now reported to be the same as the volume was set to, also when
+ internal rounding have been done due to
+ :attr:`mopidy.settings.MIXER_MAX_VOLUME` has been set to cap the volume. This
+ should make it possible to manage capped volume from clients that only
+ increase volume with one step at a time, like ncmpcpp does.
+
+
+v0.6.1 (2011-12-28)
+===================
+
+This is a maintenance release to make Mopidy 0.6 work with pyspotify >= 1.5,
+which Mopidy's develop branch have supported for a long time. This should also
+make the Debian packages work out of the box again.
+
+**Important changes**
+
+- pyspotify 1.5 or greater is required.
+
+**Changes**
+
+- Spotify playlist folder boundaries are now properly detected. In other words,
+ if you use playlist folders, you will no longer get lots of log messages
+ about bad playlists.
+
+
+
+v0.6.0 (2011-10-09)
+===================
+
+The development of Mopidy have been quite slow for the last couple of months,
+but we do have some goodies to release which have been idling in the
+develop branch since the warmer days of the summer. This release brings support
+for the MPD ``idle`` command, which makes it possible for a client wait for
+updates from the server instead of polling every second. Also, we've added
+support for the MPRIS standard, so that Mopidy can be controlled over D-Bus
+from e.g. the Ubuntu Sound Menu.
+
+Please note that 0.6.0 requires some updated dependencies, as listed under
+*Important changes* below.
**Important changes**
- Pykka 0.12.3 or greater is required.
+- pyspotify 1.4 or greater is required.
+
- All config, data, and cache locations are now based on the XDG spec.
- This means that your settings file will need to be moved from
@@ -29,7 +161,7 @@ v0.6.0 (in development)
- A new frontend :mod:`mopidy.frontends.mpris` have been added. It exposes
Mopidy through the `MPRIS interface `_ over D-Bus. In
- practice, this makes it possible to control Mopidy thorugh the `Ubuntu Sound
+ practice, this makes it possible to control Mopidy through the `Ubuntu Sound
Menu `_.
**Changes**
@@ -50,6 +182,19 @@ v0.6.0 (in development)
- Unescape all incoming MPD requests. (Fixes: :issue:`113`)
+- Increase the maximum number of results returned by Spotify searches from 32
+ to 100.
+
+- Send Spotify search queries to pyspotify as unicode objects, as required by
+ pyspotify 1.4. (Fixes: :issue:`129`)
+
+- Add setting :attr:`mopidy.settings.MPD_SERVER_MAX_CONNECTIONS`. (Fixes:
+ :issue:`134`)
+
+- Remove `destroy()` methods from backend controller and provider APIs, as it
+ was not in use and actually not called by any code. Will reintroduce when
+ needed.
+
v0.5.0 (2011-06-15)
===================
@@ -153,7 +298,7 @@ This is a bug fix release fixing audio problems on older GStreamer and some
minor bugs.
-**Bugfixes**
+**Bug fixes**
- Fix broken audio on at least GStreamer 0.10.30, which affects Ubuntu 10.10.
The GStreamer `appsrc` bin wasn't being linked due to lack of default caps.
@@ -198,7 +343,7 @@ loading from Mopidy 0.3.0 is still present.
**Important changes**
-- Mopidy now depends on `Pykka `_ >=0.12. If you
+- Mopidy now depends on `Pykka `_ >=0.12. If you
install from APT, Pykka will automatically be installed. If you are not
installing from APT, you may install Pykka from PyPI::
@@ -280,7 +425,7 @@ v0.3.1 (2011-01-22)
A couple of fixes to the 0.3.0 release is needed to get a smooth installation.
-**Bugfixes**
+**Bug fixes**
- The Spotify application key was missing from the Python package.
@@ -449,7 +594,7 @@ v0.2.1 (2011-01-07)
This is a maintenance release without any new features.
-**Bugfixes**
+**Bug fixes**
- Fix crash in :mod:`mopidy.frontends.lastfm` which occurred at playback if
either :mod:`pylast` was not installed or the Last.fm scrobbling was not
@@ -779,7 +924,7 @@ As always, report problems at our IRC channel or our issue tracker. Thanks!
- Merged the ``gstreamer`` branch from Thomas Adamcik:
- - More than 200 new tests, and thus several bugfixes to existing code.
+ - More than 200 new tests, and thus several bug fixes to existing code.
- Several new generic features, like shuffle, consume, and playlist repeat.
(Fixes: :issue:`3`)
- **[Work in Progress]** A new backend for playing music from a local music
diff --git a/docs/conf.py b/docs/conf.py
index aeada340..a33a8f2d 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -11,7 +11,50 @@
# All configuration values have a default; values that are commented out
# serve to show the default.
-import sys, os
+import os
+import re
+import sys
+
+class Mock(object):
+ def __init__(self, *args, **kwargs):
+ pass
+
+ def __call__(self, *args, **kwargs):
+ return Mock()
+
+ @classmethod
+ def __getattr__(self, name):
+ if name in ('__file__', '__path__'):
+ return '/dev/null'
+ elif name[0] == name[0].upper():
+ return type(name, (), {})
+ else:
+ return Mock()
+
+MOCK_MODULES = [
+ 'alsaaudio',
+ 'dbus',
+ 'dbus.mainloop',
+ 'dbus.mainloop.glib',
+ 'dbus.service',
+ 'glib',
+ 'gobject',
+ 'gst',
+ 'pygst',
+ 'pykka',
+ 'pykka.actor',
+ 'pykka.future',
+ 'pykka.registry',
+ 'pylast',
+ 'serial',
+]
+for mod_name in MOCK_MODULES:
+ sys.modules[mod_name] = Mock()
+
+def get_version():
+ init_py = open('../mopidy/__init__.py').read()
+ metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", init_py))
+ return metadata['version']
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
@@ -19,14 +62,15 @@ import sys, os
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + '/../'))
-import mopidy
+# When RTD builds the project, it sets the READTHEDOCS environment variable to
+# the string True.
+on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
# -- 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.inheritance_diagram',
+extensions = ['sphinx.ext.autodoc', 'sphinx.ext.graphviz',
'sphinx.ext.extlinks', 'sphinx.ext.viewcode']
# Add any paths that contain templates here, relative to this directory.
@@ -43,14 +87,14 @@ master_doc = 'index'
# General information about the project.
project = u'Mopidy'
-copyright = u'2010-2011, Stein Magnus Jodal and contributors'
+copyright = u'2010-2012, Stein Magnus Jodal and contributors'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The full version, including alpha/beta/rc tags.
-release = mopidy.get_version()
+release = get_version()
# The short X.Y version.
version = '.'.join(release.split('.')[:2])
@@ -97,7 +141,7 @@ modindex_common_prefix = ['mopidy.']
# The theme to use for HTML and HTML Help pages. Major themes that come with
# Sphinx are currently 'default' and 'sphinxdoc'.
-html_theme = 'nature'
+html_theme = 'default'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
@@ -116,7 +160,8 @@ html_theme_path = ['_themes']
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
-html_logo = '_static/mopidy.png'
+if on_rtd:
+ html_logo = '_static/mopidy.png'
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
@@ -130,7 +175,7 @@ html_static_path = ['_static']
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
-html_last_updated_fmt = '%b %d, %Y'
+#html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
@@ -202,4 +247,4 @@ latex_documents = [
needs_sphinx = '1.0'
-extlinks = {'issue': ('http://github.com/mopidy/mopidy/issues/%s', 'GH-')}
+extlinks = {'issue': ('https://github.com/mopidy/mopidy/issues/%s', 'GH-')}
diff --git a/docs/development/contributing.rst b/docs/development/contributing.rst
index 9ea3533f..782d2f20 100644
--- a/docs/development/contributing.rst
+++ b/docs/development/contributing.rst
@@ -74,7 +74,7 @@ Running tests
To run tests, you need a couple of dependencies. They can be installed through
Debian/Ubuntu package management::
- sudo aptitude install python-coverage python-mock python-nose
+ sudo apt-get install python-coverage python-mock python-nose
Or, they can be installed using ``pip``::
@@ -126,7 +126,7 @@ from the documentation files, you need some additional dependencies.
You can install them through Debian/Ubuntu package management::
- sudo aptitude install python-sphinx python-pygraphviz graphviz
+ sudo apt-get install python-sphinx python-pygraphviz graphviz
Then, to generate docs::
@@ -134,18 +134,8 @@ Then, to generate docs::
make # For help on available targets
make html # To generate HTML docs
-.. note::
-
- The documentation at http://www.mopidy.com/ is automatically updated when a
- documentation update is pushed to ``mopidy/mopidy`` at GitHub.
-
- Documentation generated from the ``master`` branch is published at
- http://www.mopidy.com/docs/master/, and will always be valid for the latest
- release.
-
- Documentation generated from the ``develop`` branch is published at
- http://www.mopidy.com/docs/develop/, and will always be valid for the
- latest development snapshot.
+The documentation at http://docs.mopidy.com/ is automatically updated when a
+documentation update is pushed to ``mopidy/mopidy`` at GitHub.
Creating releases
diff --git a/docs/index.rst b/docs/index.rst
index 769aed20..7e757de0 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -19,9 +19,7 @@ please create an issue in the `issue tracker
Project resources
=================
-- `Documentation for the latest release `_
-- `Documentation for the development version
- `_
+- `Documentation `_
- `Source code `_
- `Issue tracker `_
- IRC: ``#mopidy`` at `irc.freenode.net `_
diff --git a/docs/installation/gstreamer.rst b/docs/installation/gstreamer.rst
index 08e16378..546b53ba 100644
--- a/docs/installation/gstreamer.rst
+++ b/docs/installation/gstreamer.rst
@@ -2,19 +2,21 @@
GStreamer installation
**********************
-To use the Mopidy, you first need to install GStreamer and its Python bindings.
+To use the Mopidy, you first need to install GStreamer and the GStreamer Python
+bindings.
-Installing GStreamer
-====================
-
-On Linux
---------
+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 \
@@ -24,30 +26,67 @@ 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.
-On OS X from Homebrew
----------------------
+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
+============================
.. note::
- We have created GStreamer formulas for Homebrew to make the GStreamer
- installation easy for you, but not all our formulas have been merged into
- Homebrew's master branch yet. You should either fetch the formula files
- from `Homebrew's issue #1612
- `_ yourself, or fall
- back to using MacPorts.
+ 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.
-To install GStreamer on OS X using Homebrew::
+The following is currently the shortest path to installing GStreamer with
+Python bindings on OS X using Homebrew.
- brew install gst-python gst-plugins-good gst-plugins-ugly
+#. Install `Homebrew `_.
+#. Download our Homebrew formulas for ``pycairo``, ``pygobject``, ``pygtk``,
+ and ``gst-python``::
-On OS X from MacPorts
----------------------
+ curl -o $(brew --prefix)/Library/Formula/pycairo.rb \
+ https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/pycairo.rb
+ curl -o $(brew --prefix)/Library/Formula/pygobject.rb \
+ https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/pygobject.rb
+ curl -o $(brew --prefix)/Library/Formula/pygtk.rb \
+ https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/pygtk.rb
+ curl -o $(brew --prefix)/Library/Formula/gst-python.rb \
+ https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/gst-python.rb
-To install GStreamer on OS X using MacPorts::
+#. Install the required packages::
- sudo port install py26-gst-python gstreamer-plugins-good \
- gstreamer-plugins-ugly
+ 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.6/site-packages:$PYTHONPATH
+
+ Or, you can prefix the Mopidy command every time you run it::
+
+ PYTHONPATH=$(brew --prefix)/lib/python2.6/site-packages mopidy
+
+ Note that you need to replace ``python2.6`` with ``python2.7`` if that's
+ the Python version you are using. To find your Python version, run::
+
+ python --version
Testing the installation
@@ -73,12 +112,9 @@ Using a custom audio sink
=========================
If you for some reason want to use some other GStreamer audio sink than
-``autoaudiosink``, you can add ``mopidy.outputs.custom.CustomOutput`` to the
-:attr:`mopidy.settings.OUTPUTS` setting, and set the
-:attr:`mopidy.settings.CUSTOM_OUTPUT` setting to a partial GStreamer pipeline
-description describing the GStreamer sink you want to use.
+``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::
- OUTPUTS = (u'mopidy.outputs.custom.CustomOutput',)
- CUSTOM_OUTPUT = u'oss4sink'
+ OUTPUT = u'oss4sink'
diff --git a/docs/installation/index.rst b/docs/installation/index.rst
index 198ac9e8..fae50a1b 100644
--- a/docs/installation/index.rst
+++ b/docs/installation/index.rst
@@ -18,33 +18,36 @@ Requirements
gstreamer
libspotify
-If you install Mopidy from the APT archive, as described below, you can skip
-the dependency installation part.
+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.
-Otherwise, make sure you got the required dependencies installed.
+- Hard dependencies:
-- Python >= 2.6, < 3
+ - Python >= 2.6, < 3
-- `Pykka `_ >= 0.12.3
+ - Pykka >= 0.12.3::
-- GStreamer >= 0.10, with Python bindings. See :doc:`gstreamer`.
+ sudo pip install -U pykka
-- Mixer dependencies: The default mixer does not require any additional
- dependencies. If you use another mixer, see the mixer's docs for any
- additional requirements.
-
-- Dependencies for at least one Mopidy backend:
-
- - The default backend, :mod:`mopidy.backends.spotify`, requires libspotify
- and pyspotify. See :doc:`libspotify`.
-
- - The local backend, :mod:`mopidy.backends.local`, requires no additional
- dependencies.
+ - GStreamer 0.10.x, with Python bindings. See :doc:`gstreamer`.
- Optional dependencies:
- - To use the Last.FM scrobbler, see :mod:`mopidy.frontends.lastfm` for
- additional requirements.
+ - 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
+
+ - Some custom mixers (but not the default one) require additional
+ dependencies. See the docs for each mixer.
Install latest stable release
@@ -97,8 +100,8 @@ install Mopidy from PyPI using Pip.
#. Then, you need to install Pip::
- sudo aptitude install python-setuptools python-pip # On Ubuntu/Debian
- sudo easy_install pip # On OS X
+ 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::
@@ -109,8 +112,6 @@ install Mopidy from PyPI using Pip.
#. Next, you need to set a couple of :doc:`settings `, and then
you're ready to :doc:`run Mopidy `.
-If you for some reason can't use Pip, try ``easy_install`` instead.
-
Install development version
===========================
@@ -131,8 +132,8 @@ Mopidy's ``develop`` branch.
#. Then, you need to install Pip::
- sudo aptitude install python-setuptools python-pip # On Ubuntu/Debian
- sudo easy_install pip # On OS X
+ 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::
@@ -154,7 +155,7 @@ If you want to contribute to Mopidy, you should install Mopidy using Git.
#. Then install Git, if haven't already::
- sudo aptitude install git-core # On Ubuntu/Debian
+ 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::
diff --git a/docs/installation/libspotify.rst b/docs/installation/libspotify.rst
index 2728be94..223e4ed7 100644
--- a/docs/installation/libspotify.rst
+++ b/docs/installation/libspotify.rst
@@ -12,12 +12,6 @@ install libspotify and `pyspotify `_.
This backend requires a paid `Spotify premium account
`_.
-.. note::
-
- This product uses SPOTIFY CORE but is not endorsed, certified or otherwise
- approved in any way by Spotify. Spotify is the registered trade mark of the
- Spotify Group.
-
Installing libspotify
=====================
@@ -26,23 +20,20 @@ Installing libspotify
On Linux from APT archive
-------------------------
-If you run a Debian based Linux distribution, like Ubuntu, see
-http://apt.mopidy.com/ for how to the Mopidy APT archive as a software source
-on your installation. Then, simply run::
-
- sudo apt-get install libspotify8
-
-When libspotify has been installed, continue with
-:ref:`pyspotify_installation`.
+If you install from APT, jump directly to :ref:`pyspotify_installation` below.
On Linux from source
--------------------
-Download and install libspotify 0.0.8 for your OS and CPU architecture from
-https://developer.spotify.com/en/libspotify/.
+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.
-For 64-bit Linux the process is as follows::
+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
@@ -50,6 +41,9 @@ For 64-bit Linux the process is as follows::
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`.
@@ -66,7 +60,7 @@ libspotify::
To update your existing libspotify installation using Homebrew::
brew update
- brew install `brew outdated`
+ brew upgrade
When libspotify has been installed, continue with
:ref:`pyspotify_installation`.
@@ -84,29 +78,35 @@ by installing pyspotify.
On Linux from APT archive
-------------------------
-Assuming that you've already set up http://apt.mopidy.com/ as a software
-source, run::
+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
-If you haven't already installed libspotify, this command will install both
-libspotify and pyspotify for you.
+This command will install both libspotify and pyspotify for you.
-On Linux/OS X from source
+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
-On OS X no additional dependencies are needed.
-
Then get, build, and install the latest releast of pyspotify using ``pip``::
sudo pip install -U pyspotify
-Or using the older ``easy_install``::
- sudo easy_install 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/licenses.rst b/docs/licenses.rst
index 7f4ed0ce..11e0a906 100644
--- a/docs/licenses.rst
+++ b/docs/licenses.rst
@@ -8,7 +8,7 @@ contributed what, please refer to our git repository.
Source code license
===================
-Copyright 2009-2011 Stein Magnus Jodal and contributors
+Copyright 2009-2012 Stein Magnus Jodal and contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -26,7 +26,7 @@ limitations under the License.
Documentation license
=====================
-Copyright 2010-2011 Stein Magnus Jodal and contributors
+Copyright 2010-2012 Stein Magnus Jodal and contributors
This work is licensed under the Creative Commons Attribution-ShareAlike 3.0
Unported License. To view a copy of this license, visit
diff --git a/docs/modules/frontends/mpd.rst b/docs/modules/frontends/mpd.rst
index b0c7e3c5..0ce138a2 100644
--- a/docs/modules/frontends/mpd.rst
+++ b/docs/modules/frontends/mpd.rst
@@ -2,8 +2,6 @@
:mod:`mopidy.frontends.mpd` -- MPD server
*****************************************
-.. inheritance-diagram:: mopidy.frontends.mpd
-
.. automodule:: mopidy.frontends.mpd
:synopsis: MPD server frontend
:members:
@@ -12,8 +10,6 @@
MPD dispatcher
==============
-.. inheritance-diagram:: mopidy.frontends.mpd.dispatcher
-
.. automodule:: mopidy.frontends.mpd.dispatcher
:synopsis: MPD request dispatcher
:members:
diff --git a/docs/modules/gstreamer.rst b/docs/modules/gstreamer.rst
index adbf5fda..205b0a3e 100644
--- a/docs/modules/gstreamer.rst
+++ b/docs/modules/gstreamer.rst
@@ -2,8 +2,6 @@
:mod:`mopidy.gstreamer` -- GStreamer adapter
********************************************
-.. inheritance-diagram:: mopidy.gstreamer
-
.. automodule:: mopidy.gstreamer
:synopsis: GStreamer adapter
:members:
diff --git a/docs/modules/mixers/alsa.rst b/docs/modules/mixers/alsa.rst
index 05f429eb..e8b7ed6c 100644
--- a/docs/modules/mixers/alsa.rst
+++ b/docs/modules/mixers/alsa.rst
@@ -2,8 +2,6 @@
:mod:`mopidy.mixers.alsa` -- ALSA mixer for Linux
*************************************************
-.. inheritance-diagram:: mopidy.mixers.alsa
-
.. automodule:: mopidy.mixers.alsa
:synopsis: ALSA mixer for Linux
:members:
diff --git a/docs/modules/mixers/denon.rst b/docs/modules/mixers/denon.rst
index ac944ccc..7fb2d6cc 100644
--- a/docs/modules/mixers/denon.rst
+++ b/docs/modules/mixers/denon.rst
@@ -2,8 +2,6 @@
:mod:`mopidy.mixers.denon` -- Hardware mixer for Denon amplifiers
*****************************************************************
-.. inheritance-diagram:: mopidy.mixers.denon
-
.. automodule:: mopidy.mixers.denon
:synopsis: Hardware mixer for Denon amplifiers
:members:
diff --git a/docs/modules/mixers/dummy.rst b/docs/modules/mixers/dummy.rst
index 6665f949..8ac18e10 100644
--- a/docs/modules/mixers/dummy.rst
+++ b/docs/modules/mixers/dummy.rst
@@ -2,8 +2,6 @@
:mod:`mopidy.mixers.dummy` -- Dummy mixer for testing
*****************************************************
-.. inheritance-diagram:: mopidy.mixers.dummy
-
.. automodule:: mopidy.mixers.dummy
:synopsis: Dummy mixer for testing
:members:
diff --git a/docs/modules/mixers/gstreamer_software.rst b/docs/modules/mixers/gstreamer_software.rst
index ef8cc310..98e09f44 100644
--- a/docs/modules/mixers/gstreamer_software.rst
+++ b/docs/modules/mixers/gstreamer_software.rst
@@ -2,8 +2,6 @@
:mod:`mopidy.mixers.gstreamer_software` -- Software mixer for all platforms
***************************************************************************
-.. inheritance-diagram:: mopidy.mixers.gstreamer_software
-
.. automodule:: mopidy.mixers.gstreamer_software
:synopsis: Software mixer for all platforms
:members:
diff --git a/docs/modules/mixers/nad.rst b/docs/modules/mixers/nad.rst
index d441b3fd..56291cbb 100644
--- a/docs/modules/mixers/nad.rst
+++ b/docs/modules/mixers/nad.rst
@@ -2,8 +2,6 @@
:mod:`mopidy.mixers.nad` -- Hardware mixer for NAD amplifiers
*************************************************************
-.. inheritance-diagram:: mopidy.mixers.nad
-
.. automodule:: mopidy.mixers.nad
:synopsis: Hardware mixer for NAD amplifiers
:members:
diff --git a/docs/modules/mixers/osa.rst b/docs/modules/mixers/osa.rst
index 14bf9a49..a4363cb4 100644
--- a/docs/modules/mixers/osa.rst
+++ b/docs/modules/mixers/osa.rst
@@ -2,8 +2,6 @@
:mod:`mopidy.mixers.osa` -- Osa mixer for OS X
**********************************************
-.. inheritance-diagram:: mopidy.mixers.osa
-
.. automodule:: mopidy.mixers.osa
:synopsis: Osa mixer for OS X
:members:
diff --git a/docs/modules/outputs.rst b/docs/modules/outputs.rst
index 87d23dab..f80c16e3 100644
--- a/docs/modules/outputs.rst
+++ b/docs/modules/outputs.rst
@@ -4,11 +4,8 @@
The following GStreamer audio outputs implements the :ref:`output-api`.
-.. inheritance-diagram:: mopidy.outputs.custom
.. autoclass:: mopidy.outputs.custom.CustomOutput
-.. inheritance-diagram:: mopidy.outputs.local
.. autoclass:: mopidy.outputs.local.LocalOutput
-.. inheritance-diagram:: mopidy.outputs.shoutcast
.. autoclass:: mopidy.outputs.shoutcast.ShoutcastOutput
diff --git a/docs/running.rst b/docs/running.rst
index 4912512f..6c8d0ede 100644
--- a/docs/running.rst
+++ b/docs/running.rst
@@ -10,4 +10,11 @@ When Mopidy says ``MPD server running at [127.0.0.1]:6600`` it's ready to
accept connections by any MPD client. Check out our non-exhaustive
:doc:`/clients/mpd` list to find recommended clients.
-To stop Mopidy, press ``CTRL+C``.
+To stop Mopidy, press ``CTRL+C`` in the terminal where you started Mopidy.
+
+Mopidy will also shut down properly if you send it the TERM signal, e.g. by
+using ``kill``::
+
+ kill `ps ax | grep mopidy | grep -v grep | cut -d' ' -f1`
+
+This can be useful e.g. if you create init script for managing Mopidy.
diff --git a/docs/settings.rst b/docs/settings.rst
index 76eb6315..f754bb5e 100644
--- a/docs/settings.rst
+++ b/docs/settings.rst
@@ -10,10 +10,10 @@ changes you may want to do, and a complete listing of available settings.
Changing settings
=================
-Mopidy reads settings from the file ``~/.mopidy/settings.py``, where ``~``
-means your *home directory*. If your username is ``alice`` and you are running
-Linux, the settings file should probably be at
-``/home/alice/.mopidy/settings.py``.
+Mopidy reads settings from the file ``~/.config/mopidy/settings.py``, where
+``~`` means your *home directory*. If your username is ``alice`` and you are
+running Linux, the settings file should probably be at
+``/home/alice/.config/mopidy/settings.py``.
You can either create the settings file yourself, or run the ``mopidy``
command, and it will create an empty settings file for you.
@@ -22,7 +22,7 @@ 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.
-A complete ``~/.mopidy/settings.py`` may look as simple as this::
+A complete ``~/.config/mopidy/settings.py`` may look as simple as this::
MPD_SERVER_HOSTNAME = u'::'
SPOTIFY_USERNAME = u'alice'
@@ -77,7 +77,7 @@ To make a ``tag_cache`` of your local music available for Mopidy:
mopidy --list-settings
-#. Scan your music library. Currently the command outputs the ``tag_cache`` to
+#. Scan your music library. The command outputs the ``tag_cache`` to
``stdout``, which means that you will need to redirect the output to a file
yourself::
@@ -157,18 +157,17 @@ server simultaneously. To use the SHOUTcast output, do the following:
#. Install, configure and start the Icecast server. It can be found in the
``icecast2`` package in Debian/Ubuntu.
-#. Add ``mopidy.outputs.shoutcast.ShoutcastOutput`` output to the
- :attr:`mopidy.settings.OUTPUTS` setting.
+#. Set :attr:`mopidy.settings.OUTPUT` to ``lame ! shout2send`` (an ogg-vorbis
+ encoder could be used instead of lame).
-#. Check the default values for the following settings, and alter them to match
- your Icecast setup if needed:
+#. You might also need to change the shout2send default settings, run
+ ``gst-inspect-0.10 shout2send`` to see the available settings. Most likely
+ you want to change ``ip``, ``username``, ``password`` and ``mount``. For
+ example, to set the password use: ``lame ! shout2send password="s3cret"``.
- - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_HOSTNAME`
- - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PORT`
- - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_USERNAME`
- - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PASSWORD`
- - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_MOUNT`
- - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_ENCODER`
+Other advanced setups are also possible for outputs. Basically anything you can
+get a gst-lauch command to output to can be plugged into
+:attr:`mopidy.settings.OUTPUT``.
Available settings
diff --git a/mopidy/__init__.py b/mopidy/__init__.py
index 1d820fd0..11293446 100644
--- a/mopidy/__init__.py
+++ b/mopidy/__init__.py
@@ -1,25 +1,25 @@
-import platform
import sys
if not (2, 6) <= sys.version_info < (3,):
sys.exit(u'Mopidy requires Python >= 2.6, < 3')
-import glib
import os
-
+import platform
from subprocess import PIPE, Popen
-VERSION = (0, 6, 0)
+import glib
-DATA_PATH = os.path.join(glib.get_user_data_dir(), 'mopidy')
-CACHE_PATH = os.path.join(glib.get_user_cache_dir(), 'mopidy')
-SETTINGS_PATH = os.path.join(glib.get_user_config_dir(), 'mopidy')
+__version__ = '0.7.3'
+
+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')
def get_version():
try:
return get_git_version()
except EnvironmentError:
- return get_plain_version()
+ return __version__
def get_git_version():
process = Popen(['git', 'describe'], stdout=PIPE, stderr=PIPE)
@@ -30,9 +30,6 @@ def get_git_version():
version = version[1:]
return version
-def get_plain_version():
- return '.'.join(map(str, VERSION))
-
def get_platform():
return platform.platform()
diff --git a/mopidy/backends/base/current_playlist.py b/mopidy/backends/base/current_playlist.py
index e89c23d5..d7e6c331 100644
--- a/mopidy/backends/base/current_playlist.py
+++ b/mopidy/backends/base/current_playlist.py
@@ -21,10 +21,6 @@ class CurrentPlaylistController(object):
self._cp_tracks = []
self._version = 0
- def destroy(self):
- """Cleanup after component."""
- pass
-
@property
def cp_tracks(self):
"""
@@ -32,7 +28,7 @@ class CurrentPlaylistController(object):
Read-only.
"""
- return [copy(ct) for ct in self._cp_tracks]
+ return [copy(cp_track) for cp_track in self._cp_tracks]
@property
def tracks(self):
@@ -41,7 +37,14 @@ class CurrentPlaylistController(object):
Read-only.
"""
- return [ct[1] for ct in self._cp_tracks]
+ return [cp_track.track for cp_track in self._cp_tracks]
+
+ @property
+ def length(self):
+ """
+ Length of the current playlist.
+ """
+ return len(self._cp_tracks)
@property
def version(self):
@@ -120,9 +123,9 @@ class CurrentPlaylistController(object):
matches = self._cp_tracks
for (key, value) in criteria.iteritems():
if key == 'cpid':
- matches = filter(lambda ct: ct[0] == value, matches)
+ matches = filter(lambda ct: ct.cpid == value, matches)
else:
- matches = filter(lambda ct: getattr(ct[1], key) == value,
+ matches = filter(lambda ct: getattr(ct.track, key) == value,
matches)
if len(matches) == 1:
return matches[0]
@@ -133,6 +136,19 @@ class CurrentPlaylistController(object):
else:
raise LookupError(u'"%s" match multiple tracks' % criteria_string)
+ def index(self, cp_track):
+ """
+ Get index of the given (CPID integer, :class:`mopidy.models.Track`)
+ two-tuple in the current playlist.
+
+ Raises :exc:`ValueError` if not found.
+
+ :param cp_track: track to find the index of
+ :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`)
+ :rtype: int
+ """
+ return self._cp_tracks.index(cp_track)
+
def move(self, start, end, to_position):
"""
Move the tracks in the slice ``[start:end]`` to ``to_position``.
@@ -172,7 +188,6 @@ class CurrentPlaylistController(object):
:param criteria: on or more criteria to match by
:type criteria: dict
- :type track: :class:`mopidy.models.Track`
"""
cp_track = self.get(**criteria)
position = self._cp_tracks.index(cp_track)
@@ -208,6 +223,19 @@ class CurrentPlaylistController(object):
self._cp_tracks = before + shuffled + after
self.version += 1
+ def slice(self, start, end):
+ """
+ Returns a slice of the current playlist, limited by the given
+ start and end positions.
+
+ :param start: position of first track to include in slice
+ :type start: int
+ :param end: position after last track to include in slice
+ :type end: int
+ :rtype: two-tuple of (CPID integer, :class:`mopidy.models.Track`)
+ """
+ return [copy(cp_track) for cp_track in self._cp_tracks[start:end]]
+
def _trigger_playlist_changed(self):
logger.debug(u'Triggering playlist changed event')
BackendListener.send('playlist_changed')
diff --git a/mopidy/backends/base/library.py b/mopidy/backends/base/library.py
index a30ed412..9e3afe9a 100644
--- a/mopidy/backends/base/library.py
+++ b/mopidy/backends/base/library.py
@@ -16,10 +16,6 @@ class LibraryController(object):
self.backend = backend
self.provider = provider
- def destroy(self):
- """Cleanup after component."""
- self.provider.destroy()
-
def find_exact(self, **query):
"""
Search the library for tracks where ``field`` is ``values``.
@@ -89,14 +85,6 @@ class BaseLibraryProvider(object):
def __init__(self, backend):
self.backend = backend
- def destroy(self):
- """
- Cleanup after component.
-
- *MAY be implemented by subclasses.*
- """
- pass
-
def find_exact(self, **query):
"""
See :meth:`mopidy.backends.base.LibraryController.find_exact`.
diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py
index 57a7ad85..16ac75d1 100644
--- a/mopidy/backends/base/playback.py
+++ b/mopidy/backends/base/playback.py
@@ -2,8 +2,6 @@ import logging
import random
import time
-from pykka.registry import ActorRegistry
-
from mopidy.listeners import BackendListener
logger = logging.getLogger('mopidy.backends.base')
@@ -82,12 +80,6 @@ class PlaybackController(object):
self.play_time_accumulated = 0
self.play_time_started = None
- def destroy(self):
- """
- Cleanup after component.
- """
- self.provider.destroy()
-
def _get_cpid(self, cp_track):
if cp_track is None:
return None
@@ -559,14 +551,6 @@ class BasePlaybackProvider(object):
def __init__(self, backend):
self.backend = backend
- def destroy(self):
- """
- Cleanup after component.
-
- *MAY be implemented by subclasses.*
- """
- pass
-
def pause(self):
"""
Pause playback.
diff --git a/mopidy/backends/base/stored_playlists.py b/mopidy/backends/base/stored_playlists.py
index aca78a8c..0ce2e196 100644
--- a/mopidy/backends/base/stored_playlists.py
+++ b/mopidy/backends/base/stored_playlists.py
@@ -17,10 +17,6 @@ class StoredPlaylistsController(object):
self.backend = backend
self.provider = provider
- def destroy(self):
- """Cleanup after component."""
- self.provider.destroy()
-
@property
def playlists(self):
"""
@@ -133,14 +129,6 @@ class BaseStoredPlaylistsProvider(object):
self.backend = backend
self._playlists = []
- def destroy(self):
- """
- Cleanup after component.
-
- *MAY be implemented by subclass.*
- """
- pass
-
@property
def playlists(self):
"""
@@ -201,4 +189,3 @@ class BaseStoredPlaylistsProvider(object):
*MUST be implemented by subclass.*
"""
raise NotImplementedError
-
diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py
index e689f666..e8638a3a 100644
--- a/mopidy/backends/local/__init__.py
+++ b/mopidy/backends/local/__init__.py
@@ -21,7 +21,7 @@ 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 = glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC)
+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')
@@ -67,7 +67,8 @@ class LocalBackend(ThreadingActor, Backend):
def on_start(self):
gstreamer_refs = ActorRegistry.get_by_class(GStreamer)
- assert len(gstreamer_refs) == 1, 'Expected exactly one running gstreamer.'
+ assert len(gstreamer_refs) == 1, \
+ 'Expected exactly one running GStreamer.'
self.gstreamer = gstreamer_refs[0].proxy()
diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py
index be7ab8a8..3b610a94 100644
--- a/mopidy/backends/local/translator.py
+++ b/mopidy/backends/local/translator.py
@@ -4,6 +4,7 @@ import os
logger = logging.getLogger('mopidy.backends.local.translator')
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):
@@ -33,8 +34,8 @@ def parse_m3u(file_path):
try:
with open(file_path) as m3u:
contents = m3u.readlines()
- except IOError, e:
- logger.error('Couldn\'t open m3u: %s', e)
+ except IOError as error:
+ logger.error('Couldn\'t open m3u: %s', locale_decode(error))
return uris
for line in contents:
@@ -61,8 +62,8 @@ def parse_mpd_tag_cache(tag_cache, music_dir=''):
try:
with open(tag_cache) as library:
contents = library.read()
- except IOError, e:
- logger.error('Could not open tag cache: %s', e)
+ except IOError as error:
+ logger.error('Could not open tag cache: %s', locale_decode(error))
return tracks
current = {}
diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py
index 02ccd802..56775926 100644
--- a/mopidy/backends/spotify/__init__.py
+++ b/mopidy/backends/spotify/__init__.py
@@ -10,7 +10,6 @@ from mopidy.gstreamer import GStreamer
logger = logging.getLogger('mopidy.backends.spotify')
-ENCODING = 'utf-8'
BITRATES = {96: 2, 160: 0, 320: 1}
class SpotifyBackend(ThreadingActor, Backend):
@@ -32,8 +31,8 @@ class SpotifyBackend(ThreadingActor, Backend):
**Dependencies:**
- - libspotify == 0.0.8 (libspotify8 package from apt.mopidy.com)
- - pyspotify == 1.3 (python-spotify package from apt.mopidy.com)
+ - libspotify >= 10, < 11 (libspotify10 package from apt.mopidy.com)
+ - pyspotify >= 1.5 (python-spotify package from apt.mopidy.com)
**Settings:**
@@ -78,12 +77,16 @@ class SpotifyBackend(ThreadingActor, Backend):
def on_start(self):
gstreamer_refs = ActorRegistry.get_by_class(GStreamer)
- assert len(gstreamer_refs) == 1, 'Expected exactly one running gstreamer.'
+ assert len(gstreamer_refs) == 1, \
+ 'Expected exactly one running GStreamer.'
self.gstreamer = gstreamer_refs[0].proxy()
logger.info(u'Mopidy uses SPOTIFY(R) CORE')
self.spotify = self._connect()
+ def on_stop(self):
+ self.spotify.logout()
+
def _connect(self):
from .session_manager import SpotifySessionManager
diff --git a/mopidy/backends/spotify/container_manager.py b/mopidy/backends/spotify/container_manager.py
index 520cfb68..27a4d78a 100644
--- a/mopidy/backends/spotify/container_manager.py
+++ b/mopidy/backends/spotify/container_manager.py
@@ -13,13 +13,15 @@ class SpotifyContainerManager(PyspotifyContainerManager):
def container_loaded(self, container, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: playlist container loaded')
+
self.session_manager.refresh_stored_playlists()
- playlist_container = self.session_manager.session.playlist_container()
- for playlist in playlist_container:
- self.session_manager.playlist_manager.watch(playlist)
- logger.debug(u'Watching %d playlist(s) for changes',
- len(playlist_container))
+ count = 0
+ for playlist in self.session_manager.session.playlist_container():
+ if playlist.type() == 'playlist':
+ self.session_manager.playlist_manager.watch(playlist)
+ count += 1
+ logger.debug(u'Watching %d playlist(s) for changes', count)
def playlist_added(self, container, playlist, position, userdata):
"""Callback used by pyspotify"""
diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py
index 40d4a099..a080c7bd 100644
--- a/mopidy/backends/spotify/library.py
+++ b/mopidy/backends/spotify/library.py
@@ -4,7 +4,6 @@ import Queue
from spotify import Link, SpotifyError
from mopidy.backends.base import BaseLibraryProvider
-from mopidy.backends.spotify import ENCODING
from mopidy.backends.spotify.translator import SpotifyTranslator
from mopidy.models import Playlist
@@ -55,7 +54,7 @@ class SpotifyLibraryProvider(BaseLibraryProvider):
spotify_query = u' '.join(spotify_query)
logger.debug(u'Spotify search query: %s' % spotify_query)
queue = Queue.Queue()
- self.backend.spotify.search(spotify_query.encode(ENCODING), queue)
+ self.backend.spotify.search(spotify_query, queue)
try:
return queue.get(timeout=3) # XXX What is an reasonable timeout?
except Queue.Empty:
diff --git a/mopidy/backends/spotify/playlist_manager.py b/mopidy/backends/spotify/playlist_manager.py
index f72ac4ca..05f9514d 100644
--- a/mopidy/backends/spotify/playlist_manager.py
+++ b/mopidy/backends/spotify/playlist_manager.py
@@ -27,7 +27,8 @@ class SpotifyPlaylistManager(PyspotifyPlaylistManager):
def tracks_removed(self, playlist, tracks, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: '
- u'%d track(s) removed from playlist "%s"', len(tracks), playlist.name())
+ u'%d track(s) removed from playlist "%s"',
+ len(tracks), playlist.name())
self.session_manager.refresh_stored_playlists()
def playlist_renamed(self, playlist, userdata):
diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py
index 9c8853e6..481f7a94 100644
--- a/mopidy/backends/spotify/session_manager.py
+++ b/mopidy/backends/spotify/session_manager.py
@@ -1,4 +1,3 @@
-import glib
import logging
import os
import threading
@@ -24,8 +23,9 @@ logger = logging.getLogger('mopidy.backends.spotify.session_manager')
class SpotifySessionManager(BaseThread, PyspotifySessionManager):
- cache_location = settings.SPOTIFY_CACHE_PATH or CACHE_PATH
- settings_location = settings.SPOTIFY_CACHE_PATH or CACHE_PATH
+ cache_location = (settings.SPOTIFY_CACHE_PATH
+ or os.path.join(CACHE_PATH, 'spotify'))
+ settings_location = cache_location
appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key')
user_agent = 'Mopidy %s' % get_version()
@@ -43,6 +43,8 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
self.container_manager = None
self.playlist_manager = None
+ self._initial_data_receive_completed = False
+
def run_inside_try(self):
self.setup()
self.connect()
@@ -97,10 +99,6 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
"""Callback used by pyspotify"""
logger.debug(u'User message: %s', message.strip())
- def notify_main_thread(self, session):
- """Callback used by pyspotify"""
- logger.debug(u'notify_main_thread() called')
-
def music_delivery(self, session, frames, frame_size, num_frames,
sample_type, sample_rate, channels):
"""Callback used by pyspotify"""
@@ -130,6 +128,17 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
def log_message(self, session, data):
"""Callback used by pyspotify"""
logger.debug(u'System message: %s' % data.strip())
+ if 'offline-mgr' in data and 'files unlocked' in data:
+ # XXX This is a very very fragile and ugly hack, but we get no
+ # proper event when libspotify is done with initial data loading.
+ # We delay the expensive refresh of Mopidy's stored playlists until
+ # this message arrives. This way, we avoid doing the refresh once
+ # for every playlist or other change. This reduces the time from
+ # startup until the Spotify backend is ready from 35s to 12s in one
+ # test with clean Spotify cache. In cases with an outdated cache
+ # the time improvements should be a lot better.
+ self._initial_data_receive_completed = True
+ self.refresh_stored_playlists()
def end_of_track(self, session):
"""Callback used by pyspotify"""
@@ -139,10 +148,11 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
def refresh_stored_playlists(self):
"""Refresh the stored playlists in the backend with fresh meta data
from Spotify"""
- playlists = []
- for spotify_playlist in self.session.playlist_container():
- playlists.append(
- SpotifyTranslator.to_mopidy_playlist(spotify_playlist))
+ if not self._initial_data_receive_completed:
+ logger.debug(u'Still getting data; skipped refresh of playlists')
+ return
+ playlists = map(SpotifyTranslator.to_mopidy_playlist,
+ self.session.playlist_container())
playlists = filter(None, playlists)
self.backend.stored_playlists.playlists = playlists
logger.debug(u'Refreshed %d stored playlist(s)', len(playlists))
@@ -151,9 +161,18 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
"""Search method used by Mopidy backend"""
def callback(results, userdata=None):
# TODO Include results from results.albums(), etc. too
+ # 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()])
queue.put(playlist)
self.connected.wait()
- self.session.search(query, callback)
+ self.session.search(query, callback, track_count=100,
+ album_count=0, artist_count=0)
+
+ def logout(self):
+ """Log out from spotify"""
+ logger.debug(u'Logging out from Spotify')
+ if self.session:
+ self.session.logout()
diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py
index 95287d77..2f47a42b 100644
--- a/mopidy/backends/spotify/translator.py
+++ b/mopidy/backends/spotify/translator.py
@@ -4,7 +4,6 @@ import logging
from spotify import Link, SpotifyError
from mopidy import settings
-from mopidy.backends.spotify import ENCODING
from mopidy.models import Artist, Album, Track, Playlist
logger = logging.getLogger('mopidy.backends.spotify.translator')
@@ -31,9 +30,10 @@ class SpotifyTranslator(object):
uri = str(Link.from_track(spotify_track, 0))
if not spotify_track.is_loaded():
return Track(uri=uri, name=u'[loading...]')
- if (spotify_track.album() is not None and
- dt.MINYEAR <= int(spotify_track.album().year()) <= dt.MAXYEAR):
- date = dt.date(spotify_track.album().year(), 1, 1)
+ spotify_album = spotify_track.album()
+ if (spotify_album is not None and spotify_album.is_loaded()
+ and dt.MINYEAR <= int(spotify_album.year()) <= dt.MAXYEAR):
+ date = dt.date(spotify_album.year(), 1, 1)
else:
date = None
return Track(
@@ -51,9 +51,8 @@ class SpotifyTranslator(object):
def to_mopidy_playlist(cls, spotify_playlist):
if not spotify_playlist.is_loaded():
return Playlist(name=u'[loading...]')
- # FIXME Replace this try-except with a check on the playlist type,
- # which is currently not supported by pyspotify, to avoid handling
- # playlist folder boundaries like normal playlists.
+ if spotify_playlist.type() != 'playlist':
+ return
try:
return Playlist(
uri=str(Link.from_playlist(spotify_playlist)),
@@ -63,5 +62,4 @@ class SpotifyTranslator(object):
if str(Link.from_track(t, 0))],
)
except SpotifyError, e:
- logger.info(u'Failed translating Spotify playlist '
- '(probably a playlist folder boundary): %s', e)
+ logger.warning(u'Failed translating Spotify playlist: %s', e)
diff --git a/mopidy/core.py b/mopidy/core.py
index 08c5e0d7..596e0fe5 100644
--- a/mopidy/core.py
+++ b/mopidy/core.py
@@ -12,14 +12,12 @@ gobject.threads_init()
# so that GStreamer doesn't hijack e.g. ``--help``.
# NOTE This naive fix does not support values like ``bar`` in
# ``--gst-foo bar``. Use equals to pass values, like ``--gst-foo=bar``.
-def is_gst_arg(arg):
- return arg.startswith('--gst') or arg == '--help-gst'
+def is_gst_arg(argument):
+ return argument.startswith('--gst') or argument == '--help-gst'
gstreamer_args = [arg for arg in sys.argv[1:] if is_gst_arg(arg)]
mopidy_args = [arg for arg in sys.argv[1:] if not is_gst_arg(arg)]
sys.argv[1:] = gstreamer_args
-from pykka.registry import ActorRegistry
-
from mopidy import (get_version, settings, OptionalDependencyError,
SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE)
from mopidy.gstreamer import GStreamer
diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py
index 125457cd..0e79024b 100644
--- a/mopidy/frontends/lastfm.py
+++ b/mopidy/frontends/lastfm.py
@@ -37,6 +37,7 @@ class LastfmFrontend(ThreadingActor, BackendListener):
"""
def __init__(self):
+ 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 a2faedc2..e8b2aabe 100644
--- a/mopidy/frontends/mpd/__init__.py
+++ b/mopidy/frontends/mpd/__init__.py
@@ -5,7 +5,7 @@ from pykka import registry, actor
from mopidy import listeners, settings
from mopidy.frontends.mpd import dispatcher, protocol
-from mopidy.utils import network, process, log
+from mopidy.utils import locale_decode, log, network, process
logger = logging.getLogger('mopidy.frontends.mpd')
@@ -25,13 +25,15 @@ class MpdFrontend(actor.ThreadingActor, listeners.BackendListener):
"""
def __init__(self):
+ super(MpdFrontend, self).__init__()
hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME)
port = settings.MPD_SERVER_PORT
try:
- network.Server(hostname, port, protocol=MpdSession)
- except IOError, e:
- logger.error(u'MPD server startup failed: %s', e)
+ network.Server(hostname, port, protocol=MpdSession,
+ 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)
diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py
index cab014a8..2b012c7c 100644
--- a/mopidy/frontends/mpd/dispatcher.py
+++ b/mopidy/frontends/mpd/dispatcher.py
@@ -90,7 +90,7 @@ class MpdDispatcher(object):
def _authenticate_filter(self, request, response, filter_chain):
if self.authenticated:
return self._call_next_filter(request, response, filter_chain)
- elif settings.MPD_SERVER_PASSWORD is None:
+ elif settings.MPD_SERVER_PASSWORD is None:
self.authenticated = True
return self._call_next_filter(request, response, filter_chain)
else:
@@ -161,6 +161,7 @@ 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):
@@ -241,11 +242,11 @@ class MpdContext(object):
"""
The backend. An instance of :class:`mopidy.backends.base.Backend`.
"""
- if self._backend is not None:
- return self._backend
- backend_refs = ActorRegistry.get_by_class(Backend)
- assert len(backend_refs) == 1, 'Expected exactly one running backend.'
- self._backend = backend_refs[0].proxy()
+ 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
@property
@@ -253,9 +254,8 @@ class MpdContext(object):
"""
The mixer. An instance of :class:`mopidy.mixers.base.BaseMixer`.
"""
- if self._mixer is not None:
- return self._mixer
- mixer_refs = ActorRegistry.get_by_class(BaseMixer)
- assert len(mixer_refs) == 1, 'Expected exactly one running mixer.'
- self._mixer = mixer_refs[0].proxy()
+ if self._mixer is None:
+ mixer_refs = ActorRegistry.get_by_class(BaseMixer)
+ assert len(mixer_refs) == 1, 'Expected exactly one running mixer.'
+ self._mixer = mixer_refs[0].proxy()
return self._mixer
diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py
index c7136804..c60cbc4a 100644
--- a/mopidy/frontends/mpd/protocol/current_playlist.py
+++ b/mopidy/frontends/mpd/protocol/current_playlist.py
@@ -1,7 +1,8 @@
from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError,
MpdNotImplemented)
from mopidy.frontends.mpd.protocol import handle_request
-from mopidy.frontends.mpd.translator import tracks_to_mpd_format
+from mopidy.frontends.mpd.translator import (track_to_mpd_format,
+ tracks_to_mpd_format)
@handle_request(r'^add "(?P[^"]*)"$')
def add(context, uri):
@@ -54,8 +55,7 @@ def addid(context, uri, songpos=None):
track = context.backend.library.lookup(uri).get()
if track is None:
raise MpdNoExistError(u'No such song', command=u'addid')
- if songpos and songpos > len(
- context.backend.current_playlist.tracks.get()):
+ if songpos and songpos > context.backend.current_playlist.length.get():
raise MpdArgError(u'Bad song index', command=u'addid')
cp_track = context.backend.current_playlist.add(track,
at_position=songpos).get()
@@ -74,8 +74,8 @@ def delete_range(context, start, end=None):
if end is not None:
end = int(end)
else:
- end = len(context.backend.current_playlist.tracks.get())
- cp_tracks = context.backend.current_playlist.cp_tracks.get()[start:end]
+ end = context.backend.current_playlist.length.get()
+ cp_tracks = context.backend.current_playlist.slice(start, end).get()
if not cp_tracks:
raise MpdArgError(u'Bad song index', command=u'delete')
for (cpid, _) in cp_tracks:
@@ -86,7 +86,8 @@ def delete_songpos(context, songpos):
"""See :meth:`delete_range`"""
try:
songpos = int(songpos)
- (cpid, _) = context.backend.current_playlist.cp_tracks.get()[songpos]
+ (cpid, _) = context.backend.current_playlist.slice(
+ songpos, songpos + 1).get()[0]
context.backend.current_playlist.remove(cpid=cpid)
except IndexError:
raise MpdArgError(u'Bad song index', command=u'delete')
@@ -130,7 +131,7 @@ def move_range(context, start, to, end=None):
``TO`` in the playlist.
"""
if end is None:
- end = len(context.backend.current_playlist.tracks.get())
+ end = context.backend.current_playlist.length.get()
start = int(start)
end = int(end)
to = int(to)
@@ -157,8 +158,7 @@ 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.cp_tracks.get().index(
- cp_track)
+ position = context.backend.current_playlist.index(cp_track).get()
context.backend.current_playlist.move(position, position + 1, to)
@handle_request(r'^playlist$')
@@ -193,10 +193,8 @@ def playlistfind(context, tag, needle):
if tag == 'filename':
try:
cp_track = context.backend.current_playlist.get(uri=needle).get()
- (cpid, track) = cp_track
- position = context.backend.current_playlist.cp_tracks.get().index(
- cp_track)
- return track.mpd_format(cpid=cpid, position=position)
+ position = context.backend.current_playlist.index(cp_track).get()
+ return track_to_mpd_format(cp_track, position=position)
except LookupError:
return None
raise MpdNotImplemented # TODO
@@ -215,18 +213,16 @@ def playlistid(context, cpid=None):
try:
cpid = int(cpid)
cp_track = context.backend.current_playlist.get(cpid=cpid).get()
- position = context.backend.current_playlist.cp_tracks.get().index(
- cp_track)
- return cp_track.track.mpd_format(position=position, cpid=cpid)
+ position = context.backend.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:
- cpids = [ct[0] for ct in
- context.backend.current_playlist.cp_tracks.get()]
return tracks_to_mpd_format(
- context.backend.current_playlist.tracks.get(), cpids=cpids)
+ context.backend.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,
@@ -245,36 +241,22 @@ def playlistinfo(context, songpos=None,
- uses negative indexes, like ``playlistinfo "-1"``, to request
the entire playlist
"""
- if songpos == "-1":
- songpos = None
-
if songpos is not None:
songpos = int(songpos)
- start = songpos
- end = songpos + 1
- if start == -1:
- end = None
- cpids = [ct[0] for ct in
- context.backend.current_playlist.cp_tracks.get()]
- return tracks_to_mpd_format(
- context.backend.current_playlist.tracks.get(),
- start, end, cpids=cpids)
+ cp_track = context.backend.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 <= len(
- context.backend.current_playlist.tracks.get())):
+ if not (0 <= start <= context.backend.current_playlist.length.get()):
raise MpdArgError(u'Bad song index', command=u'playlistinfo')
if end is not None:
end = int(end)
- if end > len(context.backend.current_playlist.tracks.get()):
+ if end > context.backend.current_playlist.length.get():
end = None
- cpids = [ct[0] for ct in
- context.backend.current_playlist.cp_tracks.get()]
- return tracks_to_mpd_format(
- context.backend.current_playlist.tracks.get(),
- start, end, cpids=cpids)
+ cp_tracks = context.backend.current_playlist.cp_tracks.get()
+ return tracks_to_mpd_format(cp_tracks, start, end)
@handle_request(r'^playlistsearch "(?P[^"]+)" "(?P[^"]+)"$')
@handle_request(r'^playlistsearch (?P\S+) "(?P[^"]+)"$')
@@ -313,10 +295,8 @@ def plchanges(context, version):
"""
# XXX Naive implementation that returns all tracks as changed
if int(version) < context.backend.current_playlist.version:
- cpids = [ct[0] for ct in
- context.backend.current_playlist.cp_tracks.get()]
return tracks_to_mpd_format(
- context.backend.current_playlist.tracks.get(), cpids=cpids)
+ context.backend.current_playlist.cp_tracks.get())
@handle_request(r'^plchangesposid "(?P\d+)"$')
def plchangesposid(context, version):
@@ -392,7 +372,6 @@ def swapid(context, cpid1, cpid2):
cpid2 = int(cpid2)
cp_track1 = context.backend.current_playlist.get(cpid=cpid1).get()
cp_track2 = context.backend.current_playlist.get(cpid=cpid2).get()
- cp_tracks = context.backend.current_playlist.cp_tracks.get()
- position1 = cp_tracks.index(cp_track1)
- position2 = cp_tracks.index(cp_track2)
+ position1 = context.backend.current_playlist.index(cp_track1).get()
+ position2 = context.backend.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 0343b3ab..cde2754a 100644
--- a/mopidy/frontends/mpd/protocol/music_db.py
+++ b/mopidy/frontends/mpd/protocol/music_db.py
@@ -1,8 +1,9 @@
import re
import shlex
-from mopidy.frontends.mpd.protocol import handle_request, stored_playlists
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):
"""
@@ -68,7 +69,8 @@ def find(context, mpd_query):
- also uses the search type "date".
"""
query = _build_query(mpd_query)
- return context.backend.library.find_exact(**query).get().mpd_format()
+ return playlist_to_mpd_format(
+ context.backend.library.find_exact(**query).get())
@handle_request(r'^findadd '
r'(?P("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? '
@@ -187,8 +189,14 @@ def _list_build_query(field, mpd_query):
"""Converts a ``list`` query to a Mopidy query."""
if mpd_query is None:
return {}
- # shlex does not seem to be friends with unicode objects
- tokens = shlex.split(mpd_query.encode('utf-8'))
+ try:
+ # shlex does not seem to be friends with unicode objects
+ tokens = shlex.split(mpd_query.encode('utf-8'))
+ except ValueError as error:
+ if error.message == 'No closing quotation':
+ raise MpdArgError(u'Invalid unquoted character', command=u'list')
+ else:
+ raise error
tokens = [t.decode('utf-8') for t in tokens]
if len(tokens) == 1:
if field == u'album':
@@ -324,7 +332,8 @@ def search(context, mpd_query):
- also uses the search type "date".
"""
query = _build_query(mpd_query)
- return context.backend.library.search(**query).get().mpd_format()
+ return playlist_to_mpd_format(
+ context.backend.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 63cfe649..948083a8 100644
--- a/mopidy/frontends/mpd/protocol/playback.py
+++ b/mopidy/frontends/mpd/protocol/playback.py
@@ -178,7 +178,8 @@ def playpos(context, songpos):
if songpos == -1:
return _play_minus_one(context)
try:
- cp_track = context.backend.current_playlist.cp_tracks.get()[songpos]
+ cp_track = context.backend.current_playlist.slice(
+ songpos, songpos + 1).get()[0]
return context.backend.playback.play(cp_track).get()
except IndexError:
raise MpdArgError(u'Bad song index', command=u'play')
@@ -191,8 +192,8 @@ def _play_minus_one(context):
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.cp_tracks.get():
- cp_track = context.backend.current_playlist.cp_tracks.get()[0]
+ 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()
else:
return # Fail silently
diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py
index 20a66775..f32c46c8 100644
--- a/mopidy/frontends/mpd/protocol/status.py
+++ b/mopidy/frontends/mpd/protocol/status.py
@@ -1,8 +1,9 @@
import pykka.future
from mopidy.backends.base import PlaybackController
-from mopidy.frontends.mpd.protocol import handle_request
from mopidy.frontends.mpd.exceptions import MpdNotImplemented
+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',
@@ -32,9 +33,8 @@ def currentsong(context):
"""
current_cp_track = context.backend.playback.current_cp_track.get()
if current_cp_track is not None:
- return current_cp_track.track.mpd_format(
- position=context.backend.playback.current_playlist_position.get(),
- cpid=current_cp_track.cpid)
+ position = context.backend.playback.current_playlist_position.get()
+ return track_to_mpd_format(current_cp_track, position=position)
@handle_request(r'^idle$')
@handle_request(r'^idle (?P.+)$')
@@ -166,7 +166,7 @@ def status(context):
decimal places for millisecond precision.
"""
futures = {
- 'current_playlist.tracks': context.backend.current_playlist.tracks,
+ 'current_playlist.length': context.backend.current_playlist.length,
'current_playlist.version': context.backend.current_playlist.version,
'mixer.volume': context.mixer.volume,
'playback.consume': context.backend.playback.consume,
@@ -213,7 +213,7 @@ def _status_consume(futures):
return 0
def _status_playlist_length(futures):
- return len(futures['current_playlist.tracks'].get())
+ return futures['current_playlist.length'].get()
def _status_playlist_version(futures):
return futures['current_playlist.version'].get()
diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py
index 0a157f66..bb39d328 100644
--- a/mopidy/frontends/mpd/protocol/stored_playlists.py
+++ b/mopidy/frontends/mpd/protocol/stored_playlists.py
@@ -1,7 +1,8 @@
import datetime as dt
-from mopidy.frontends.mpd.protocol import handle_request
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):
@@ -40,7 +41,7 @@ def listplaylistinfo(context, name):
"""
try:
playlist = context.backend.stored_playlists.get(name=name).get()
- return playlist.mpd_format()
+ return playlist_to_mpd_format(playlist)
except LookupError:
raise MpdNoExistError(
u'No such playlist', command=u'listplaylistinfo')
diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py
index 562b2d2d..6ae32c9e 100644
--- a/mopidy/frontends/mpd/translator.py
+++ b/mopidy/frontends/mpd/translator.py
@@ -2,26 +2,28 @@ import os
import re
from mopidy import settings
-from mopidy.utils.path import mtime as get_mtime
from mopidy.frontends.mpd import protocol
-from mopidy.utils.path import uri_to_path, split_path
+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, cpid=None):
+def track_to_mpd_format(track, position=None):
"""
Format track for output to MPD client.
:param track: the track
- :type track: :class:`mopidy.models.Track`
+ :type track: :class:`mopidy.models.Track` or :class:`mopidy.models.CpTrack`
:param position: track's position in playlist
:type position: integer
- :param cpid: track's CPID (current playlist ID)
- :type cpid: integer
:param key: if we should set key
:type key: boolean
:param mtime: if we should set mtime
:type mtime: boolean
:rtype: list of two-tuples
"""
+ if isinstance(track, CpTrack):
+ (cpid, track) = track
+ else:
+ (cpid, track) = (None, track)
result = [
('file', track.uri or ''),
('Time', track.length and (track.length // 1000) or 0),
@@ -88,14 +90,15 @@ 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, cpids=None):
+def tracks_to_mpd_format(tracks, start=0, end=None):
"""
Format list of tracks for output to MPD client.
Optionally limit output to the slice ``[start:end]`` of the list.
:param tracks: the tracks
- :type tracks: list of :class:`mopidy.models.Track`
+ :type tracks: list of :class:`mopidy.models.Track` or
+ :class:`mopidy.models.CpTrack`
:param start: position of first track to include in output
:type start: int (positive or negative)
:param end: position after last track to include in output
@@ -106,11 +109,10 @@ def tracks_to_mpd_format(tracks, start=0, end=None, cpids=None):
end = len(tracks)
tracks = tracks[start:end]
positions = range(start, end)
- cpids = cpids and cpids[start:end] or [None for _ in tracks]
- assert len(tracks) == len(positions) == len(cpids)
+ assert len(tracks) == len(positions)
result = []
- for track, position, cpid in zip(tracks, positions, cpids):
- result.append(track_to_mpd_format(track, position, cpid))
+ for track, position in zip(tracks, positions):
+ result.append(track_to_mpd_format(track, position))
return result
def playlist_to_mpd_format(playlist, *args, **kwargs):
diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py
index 579038ca..0f5d35c5 100644
--- a/mopidy/frontends/mpris/__init__.py
+++ b/mopidy/frontends/mpris/__init__.py
@@ -57,6 +57,7 @@ class MprisFrontend(ThreadingActor, BackendListener):
"""
def __init__(self):
+ super(MprisFrontend, self).__init__()
self.indicate_server = None
self.mpris_object = None
diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py
index 77278778..9ed1fe2c 100644
--- a/mopidy/frontends/mpris/objects.py
+++ b/mopidy/frontends/mpris/objects.py
@@ -23,7 +23,6 @@ from mopidy.utils.process import exit_process
# Must be done before dbus.SessionBus() is called
gobject.threads_init()
dbus.mainloop.glib.threads_init()
-dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
BUS_NAME = 'org.mpris.MediaPlayer2.mopidy'
OBJECT_PATH = '/org/mpris/MediaPlayer2'
@@ -81,7 +80,9 @@ class MprisObject(dbus.service.Object):
def _connect_to_dbus(self):
logger.debug(u'Connecting to D-Bus...')
- bus_name = dbus.service.BusName(BUS_NAME, dbus.SessionBus())
+ mainloop = dbus.mainloop.glib.DBusGMainLoop()
+ bus_name = dbus.service.BusName(BUS_NAME,
+ dbus.SessionBus(mainloop=mainloop))
logger.info(u'Connected to D-Bus')
return bus_name
diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py
index 4ded2f95..03d79265 100644
--- a/mopidy/gstreamer.py
+++ b/mopidy/gstreamer.py
@@ -7,21 +7,11 @@ import logging
from pykka.actor import ThreadingActor
from pykka.registry import ActorRegistry
-from mopidy import settings
-from mopidy.utils import get_class
+from mopidy import settings, utils
from mopidy.backends.base import Backend
logger = logging.getLogger('mopidy.gstreamer')
-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""")
-
class GStreamer(ThreadingActor):
"""
@@ -29,42 +19,50 @@ class GStreamer(ThreadingActor):
**Settings:**
- - :attr:`mopidy.settings.OUTPUTS`
+ - :attr:`mopidy.settings.OUTPUT`
"""
def __init__(self):
+ super(GStreamer, 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._pipeline = None
self._source = None
- self._tee = None
self._uridecodebin = None
- self._outputs = []
- self._handlers = {}
+ self._output = None
def on_start(self):
self._setup_pipeline()
- self._setup_outputs()
+ self._setup_output()
self._setup_message_processor()
def _setup_pipeline(self):
description = ' ! '.join([
'uridecodebin name=uri',
- 'audioconvert name=convert',
- 'tee name=tee'])
+ 'audioconvert name=convert'])
logger.debug(u'Setting up base GStreamer pipeline: %s', description)
self._pipeline = gst.parse_launch(description)
- self._tee = self._pipeline.get_by_name('tee')
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('convert').get_pad('sink'))
- def _setup_outputs(self):
- for output in settings.OUTPUTS:
- get_class(output)(self).connect()
+ def _setup_output(self):
+ self._output = gst.parse_bin_from_description(settings.OUTPUT, True)
+ self._pipeline.add(self._output)
+ gst.element_link_many(self._pipeline.get_by_name('convert'),
+ self._output)
+ logger.debug('Output set to %s', settings.OUTPUT)
def _setup_message_processor(self):
bus = self._pipeline.get_bus()
@@ -74,19 +72,17 @@ class GStreamer(ThreadingActor):
def _on_new_source(self, element, pad):
self._source = element.get_property('source')
try:
- self._source.set_property('caps', default_caps)
+ 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.src in self._handlers:
- if self._handlers[message.src](message):
- return # Message was handeled by output
-
if message.type == gst.MESSAGE_EOS:
logger.debug(u'GStreamer signalled end-of-stream. '
'Telling backend ...')
@@ -299,103 +295,3 @@ class GStreamer(ThreadingActor):
event = gst.event_new_tag(taglist)
self._pipeline.send_event(event)
-
- def connect_output(self, output):
- """
- Connect output to pipeline.
-
- :param output: output to connect to the pipeline
- :type output: :class:`gst.Bin`
- """
- self._pipeline.add(output)
- output.sync_state_with_parent() # Required to add to running pipe
- gst.element_link_many(self._tee, output)
- self._outputs.append(output)
- logger.debug('GStreamer added %s', output.get_name())
-
- def list_outputs(self):
- """
- Get list with the name of all active outputs.
-
- :rtype: list of strings
- """
- return [output.get_name() for output in self._outputs]
-
- def remove_output(self, output):
- """
- Remove output from our pipeline.
-
- :param output: output to remove from the pipeline
- :type output: :class:`gst.Bin`
- """
- if output not in self._outputs:
- raise LookupError('Ouput %s not present in pipeline'
- % output.get_name)
- teesrc = output.get_pad('sink').get_peer()
- handler = teesrc.add_event_probe(self._handle_event_probe)
-
- struct = gst.Structure('mopidy-unlink-tee')
- struct.set_value('handler', handler)
-
- event = gst.event_new_custom(gst.EVENT_CUSTOM_DOWNSTREAM, struct)
- self._tee.send_event(event)
-
- def _handle_event_probe(self, teesrc, event):
- if event.type == gst.EVENT_CUSTOM_DOWNSTREAM and event.has_name('mopidy-unlink-tee'):
- data = self._get_structure_data(event.get_structure())
-
- output = teesrc.get_peer().get_parent()
-
- teesrc.unlink(teesrc.get_peer())
- teesrc.remove_event_probe(data['handler'])
-
- output.set_state(gst.STATE_NULL)
- self._pipeline.remove(output)
-
- logger.warning('Removed %s', output.get_name())
- return False
- return True
-
- def _get_structure_data(self, struct):
- # Ugly hack to get around missing get_value in pygst bindings :/
- data = {}
- def get_data(key, value):
- data[key] = value
- struct.foreach(get_data)
- return data
-
- def connect_message_handler(self, element, handler):
- """
- Attach custom message handler for given element.
-
- Hook to allow outputs (or other code) to register custom message
- handlers for all messages coming from the element in question.
-
- In the case of outputs, :meth:`mopidy.outputs.BaseOutput.on_connect`
- should be used to attach such handlers and care should be taken to
- remove them in :meth:`mopidy.outputs.BaseOutput.on_remove` using
- :meth:`remove_message_handler`.
-
- The handler callback will only be given the message in question, and
- is free to ignore the message. However, if the handler wants to prevent
- the default handling of the message it should return :class:`True`
- indicating that the message has been handled.
-
- Note that there can only be one handler per element.
-
- :param element: element to watch messages from
- :type element: :class:`gst.Element`
- :param handler: callable that takes :class:`gst.Message` and returns
- :class:`True` if the message has been handeled
- :type handler: callable
- """
- self._handlers[element] = handler
-
- def remove_message_handler(self, element):
- """
- Remove custom message handler.
-
- :param element: element to remove message handling from.
- :type element: :class:`gst.Element`
- """
- self._handlers.pop(element, None)
diff --git a/mopidy/mixers/alsa.py b/mopidy/mixers/alsa.py
index ae4bd031..acb12e66 100644
--- a/mopidy/mixers/alsa.py
+++ b/mopidy/mixers/alsa.py
@@ -23,6 +23,7 @@ class AlsaMixer(ThreadingActor, BaseMixer):
"""
def __init__(self):
+ super(AlsaMixer, self).__init__()
self._mixer = None
def on_start(self):
diff --git a/mopidy/mixers/base.py b/mopidy/mixers/base.py
index 8798076a..82783be1 100644
--- a/mopidy/mixers/base.py
+++ b/mopidy/mixers/base.py
@@ -2,7 +2,7 @@ import logging
from mopidy import listeners, settings
-logger = logging.getLogger('mopdy.mixers')
+logger = logging.getLogger('mopidy.mixers')
class BaseMixer(object):
"""
@@ -21,19 +21,30 @@ class BaseMixer(object):
Integer in range [0, 100]. :class:`None` if unknown. Values below 0 is
equal to 0. Values above 100 is equal to 100.
"""
+ if not hasattr(self, '_user_volume'):
+ self._user_volume = 0
volume = self.get_volume()
- if volume is None:
- return None
- return int(volume / self.amplification_factor)
+ if volume is None or not self.amplification_factor < 1:
+ return volume
+ else:
+ user_volume = int(volume / self.amplification_factor)
+ if (user_volume - 1) <= self._user_volume <= (user_volume + 1):
+ return self._user_volume
+ else:
+ return user_volume
@volume.setter
def volume(self, volume):
- volume = int(int(volume) * self.amplification_factor)
+ if not hasattr(self, '_user_volume'):
+ self._user_volume = 0
+ volume = int(volume)
if volume < 0:
volume = 0
elif volume > 100:
volume = 100
- self.set_volume(volume)
+ self._user_volume = volume
+ real_volume = int(volume * self.amplification_factor)
+ self.set_volume(real_volume)
self._trigger_volume_changed()
def get_volume(self):
diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py
index d0dc5f54..b0abbdb9 100644
--- a/mopidy/mixers/denon.py
+++ b/mopidy/mixers/denon.py
@@ -25,8 +25,9 @@ class DenonMixer(ThreadingActor, BaseMixer):
- :attr:`mopidy.settings.MIXER_EXT_PORT` -- Example: ``/dev/ttyUSB0``
"""
- def __init__(self, *args, **kwargs):
- self._device = kwargs.get('device', None)
+ def __init__(self, device=None):
+ super(DenonMixer, self).__init__()
+ self._device = device
self._levels = ['99'] + ["%(#)02d" % {'#': v} for v in range(0, 99)]
self._volume = 0
diff --git a/mopidy/mixers/dummy.py b/mopidy/mixers/dummy.py
index 23f96c4c..7262e83c 100644
--- a/mopidy/mixers/dummy.py
+++ b/mopidy/mixers/dummy.py
@@ -6,6 +6,7 @@ class DummyMixer(ThreadingActor, BaseMixer):
"""Mixer which just stores and reports the chosen volume."""
def __init__(self):
+ super(DummyMixer, self).__init__()
self._volume = None
def get_volume(self):
diff --git a/mopidy/mixers/gstreamer_software.py b/mopidy/mixers/gstreamer_software.py
index 523c3387..a38692db 100644
--- a/mopidy/mixers/gstreamer_software.py
+++ b/mopidy/mixers/gstreamer_software.py
@@ -8,6 +8,7 @@ class GStreamerSoftwareMixer(ThreadingActor, BaseMixer):
"""Mixer which uses GStreamer to control volume in software."""
def __init__(self):
+ super(GStreamerSoftwareMixer, self).__init__()
self.output = None
def on_start(self):
diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py
index 4dbf27be..78473308 100644
--- a/mopidy/mixers/nad.py
+++ b/mopidy/mixers/nad.py
@@ -37,6 +37,7 @@ class NadMixer(ThreadingActor, BaseMixer):
"""
def __init__(self):
+ super(NadMixer, self).__init__()
self._volume_cache = None
self._nad_talker = NadTalker.start().proxy()
@@ -71,6 +72,7 @@ class NadTalker(ThreadingActor):
_nad_volume = None
def __init__(self):
+ super(NadTalker, self).__init__()
self._device = None
def on_start(self):
diff --git a/mopidy/models.py b/mopidy/models.py
index ed323b71..9a508ba7 100644
--- a/mopidy/models.py
+++ b/mopidy/models.py
@@ -185,10 +185,6 @@ class Track(ImmutableObject):
self.__dict__['artists'] = frozenset(kwargs.pop('artists', []))
super(Track, self).__init__(*args, **kwargs)
- def mpd_format(self, *args, **kwargs):
- from mopidy.frontends.mpd import translator
- return translator.track_to_mpd_format(self, *args, **kwargs)
-
class Playlist(ImmutableObject):
"""
@@ -224,7 +220,3 @@ class Playlist(ImmutableObject):
def length(self):
"""The number of tracks in the playlist. Read-only."""
return len(self.tracks)
-
- def mpd_format(self, *args, **kwargs):
- from mopidy.frontends.mpd import translator
- return translator.playlist_to_mpd_format(self, *args, **kwargs)
diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py
deleted file mode 100644
index ba242c4b..00000000
--- a/mopidy/outputs/__init__.py
+++ /dev/null
@@ -1,105 +0,0 @@
-import pygst
-pygst.require('0.10')
-import gst
-
-import logging
-
-logger = logging.getLogger('mopidy.outputs')
-
-class BaseOutput(object):
- """Base class for pluggable audio outputs."""
-
- MESSAGE_EOS = gst.MESSAGE_EOS
- MESSAGE_ERROR = gst.MESSAGE_ERROR
- MESSAGE_WARNING = gst.MESSAGE_WARNING
-
- def __init__(self, gstreamer):
- self.gstreamer = gstreamer
- self.bin = self._build_bin()
- self.bin.set_name(self.get_name())
-
- self.modify_bin()
-
- def _build_bin(self):
- description = 'queue ! %s' % self.describe_bin()
- logger.debug('Creating new output: %s', description)
- return gst.parse_bin_from_description(description, True)
-
- def connect(self):
- """Attach output to GStreamer pipeline."""
- self.gstreamer.connect_output(self.bin)
- self.on_connect()
-
- def on_connect(self):
- """
- Called after output has been connected to GStreamer pipeline.
-
- *MAY be implemented by subclass.*
- """
- pass
-
- def remove(self):
- """Remove output from GStreamer pipeline."""
- self.gstreamer.remove_output(self.bin)
- self.on_remove()
-
- def on_remove(self):
- """
- Called after output has been removed from GStreamer pipeline.
-
- *MAY be implemented by subclass.*
- """
- pass
-
- def get_name(self):
- """
- Get name of the output. Defaults to the output's class name.
-
- *MAY be implemented by subclass.*
-
- :rtype: string
- """
- return self.__class__.__name__
-
- def modify_bin(self):
- """
- Modifies ``self.bin`` before it is installed if needed.
-
- Overriding this method allows for outputs to modify the constructed bin
- before it is installed. This can for instance be a good place to call
- `set_properties` on elements that need to be configured.
-
- *MAY be implemented by subclass.*
- """
- pass
-
- def describe_bin(self):
- """
- Return string describing the output bin in :command:`gst-launch`
- format.
-
- For simple cases this can just be a sink such as ``autoaudiosink``,
- or it can be a chain like ``element1 ! element2 ! sink``. See the
- manpage of :command:`gst-launch` for details on the format.
-
- *MUST be implemented by subclass.*
-
- :rtype: string
- """
- raise NotImplementedError
-
- def set_properties(self, element, properties):
- """
- Helper method for setting of properties on elements.
-
- Will call :meth:`gst.Element.set_property` on ``element`` for each key
- in ``properties`` that has a value that is not :class:`None`.
-
- :param element: element to set properties on
- :type element: :class:`gst.Element`
- :param properties: properties to set on element
- :type properties: dict
- """
- for key, value in properties.items():
- if value is not None:
- element.set_property(key, value)
diff --git a/mopidy/outputs/custom.py b/mopidy/outputs/custom.py
deleted file mode 100644
index 09239a44..00000000
--- a/mopidy/outputs/custom.py
+++ /dev/null
@@ -1,34 +0,0 @@
-from mopidy import settings
-from mopidy.outputs import BaseOutput
-
-class CustomOutput(BaseOutput):
- """
- Custom output for using alternate setups.
-
- This output is intended to handle two main cases:
-
- 1. Simple things like switching which sink to use. Say :class:`LocalOutput`
- doesn't work for you and you want to switch to ALSA, simple. Set
- :attr:`mopidy.settings.CUSTOM_OUTPUT` to ``alsasink`` and you are good
- to go. Some possible sinks include:
-
- - alsasink
- - osssink
- - pulsesink
- - ...and many more
-
- 2. Advanced setups that require complete control of the output bin. For
- these cases setup :attr:`mopidy.settings.CUSTOM_OUTPUT` with a
- :command:`gst-launch` compatible string describing the target setup.
-
- **Dependencies:**
-
- - None
-
- **Settings:**
-
- - :attr:`mopidy.settings.CUSTOM_OUTPUT`
- """
-
- def describe_bin(self):
- return settings.CUSTOM_OUTPUT
diff --git a/mopidy/outputs/local.py b/mopidy/outputs/local.py
deleted file mode 100644
index 62b26e3f..00000000
--- a/mopidy/outputs/local.py
+++ /dev/null
@@ -1,20 +0,0 @@
-from mopidy.outputs import BaseOutput
-
-class LocalOutput(BaseOutput):
- """
- Basic output to local audio sink.
-
- This output will normally tell GStreamer to choose whatever it thinks is
- best for your system. In other words this is usually a sane choice.
-
- **Dependencies:**
-
- - None
-
- **Settings:**
-
- - None
- """
-
- def describe_bin(self):
- return 'volume ! autoaudiosink'
diff --git a/mopidy/outputs/shoutcast.py b/mopidy/outputs/shoutcast.py
deleted file mode 100644
index ffe09aae..00000000
--- a/mopidy/outputs/shoutcast.py
+++ /dev/null
@@ -1,58 +0,0 @@
-import logging
-
-from mopidy import settings
-from mopidy.outputs import BaseOutput
-
-logger = logging.getLogger('mopidy.outputs.shoutcast')
-
-class ShoutcastOutput(BaseOutput):
- """
- Shoutcast streaming output.
-
- This output allows for streaming to an icecast server or anything else that
- supports Shoutcast. The output supports setting for: server address, port,
- mount point, user, password and encoder to use. Please see
- :class:`mopidy.settings` for details about settings.
-
- **Dependencies:**
-
- - A SHOUTcast/Icecast server
-
- **Settings:**
-
- - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_HOSTNAME`
- - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PORT`
- - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_USERNAME`
- - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PASSWORD`
- - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_MOUNT`
- - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_ENCODER`
- """
-
- def describe_bin(self):
- return 'audioconvert ! %s ! shout2send name=shoutcast' \
- % settings.SHOUTCAST_OUTPUT_ENCODER
-
- def modify_bin(self):
- self.set_properties(self.bin.get_by_name('shoutcast'), {
- u'ip': settings.SHOUTCAST_OUTPUT_HOSTNAME,
- u'port': settings.SHOUTCAST_OUTPUT_PORT,
- u'mount': settings.SHOUTCAST_OUTPUT_MOUNT,
- u'username': settings.SHOUTCAST_OUTPUT_USERNAME,
- u'password': settings.SHOUTCAST_OUTPUT_PASSWORD,
- })
-
- def on_connect(self):
- self.gstreamer.connect_message_handler(
- self.bin.get_by_name('shoutcast'), self.message_handler)
-
- def on_remove(self):
- self.gstreamer.remove_message_handler(
- self.bin.get_by_name('shoutcast'))
-
- def message_handler(self, message):
- if message.type != self.MESSAGE_ERROR:
- return False
- error, debug = message.parse_error()
- logger.warning('%s (%s)', error, debug)
- self.remove()
- return True
diff --git a/mopidy/settings.py b/mopidy/settings.py
index b1e0c791..0bb04823 100644
--- a/mopidy/settings.py
+++ b/mopidy/settings.py
@@ -26,14 +26,6 @@ BACKENDS = (
#: details on the format.
CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(message)s'
-#: Which GStreamer bin description to use in
-#: :class:`mopidy.outputs.custom.CustomOutput`.
-#:
-#: Default::
-#:
-#: CUSTOM_OUTPUT = u'fakesink'
-CUSTOM_OUTPUT = u'fakesink'
-
#: The log format used for debug logging.
#:
#: See http://docs.python.org/library/logging.html#formatter-objects for
@@ -180,17 +172,17 @@ MPD_SERVER_PORT = 6600
#: Default: :class:`None`, which means no password required.
MPD_SERVER_PASSWORD = None
-#: List of outputs to use. See :mod:`mopidy.outputs` for all available
-#: backends
+#: The maximum number of concurrent connections the MPD server will accept.
+#:
+#: Default: 20
+MPD_SERVER_MAX_CONNECTIONS = 20
+
+#: Output to use. See :mod:`mopidy.outputs` for all available backends
#:
#: Default::
#:
-#: OUTPUTS = (
-#: u'mopidy.outputs.local.LocalOutput',
-#: )
-OUTPUTS = (
- u'mopidy.outputs.local.LocalOutput',
-)
+#: OUTPUT = u'autoaudiosink'
+OUTPUT = u'autoaudiosink'
#: Hostname of the SHOUTcast server which Mopidy should stream audio to.
#:
diff --git a/mopidy/utils/__init__.py b/mopidy/utils/__init__.py
index 9d7532a0..567c7301 100644
--- a/mopidy/utils/__init__.py
+++ b/mopidy/utils/__init__.py
@@ -1,9 +1,12 @@
+import locale
import logging
import os
import sys
logger = logging.getLogger('mopidy.utils')
+
+# TODO: user itertools.chain.from_iterable(the_list)?
def flatten(the_list):
result = []
for element in the_list:
@@ -13,19 +16,28 @@ def flatten(the_list):
result.append(element)
return result
+
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('.')]
- class_name = name[name.rindex('.') + 1:]
+ cls_name = name[name.rindex('.') + 1:]
try:
module = import_module(module_name)
- class_object = getattr(module, class_name)
+ cls = getattr(module, cls_name)
except (ImportError, AttributeError):
raise ImportError("Couldn't load: %s" % name)
- return class_object
+ return cls
+
+
+def locale_decode(bytestr):
+ try:
+ return unicode(bytestr)
+ except UnicodeError:
+ return str(bytestr).decode(locale.getpreferredencoding())
diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py
index 5079fe7c..4b8a9ac9 100644
--- a/mopidy/utils/network.py
+++ b/mopidy/utils/network.py
@@ -9,6 +9,8 @@ from pykka import ActorDeadError
from pykka.actor import ThreadingActor
from pykka.registry import ActorRegistry
+from mopidy.utils import locale_decode
+
logger = logging.getLogger('mopidy.utils.server')
class ShouldRetrySocketCall(Exception):
@@ -21,9 +23,9 @@ def try_ipv6_socket():
try:
socket.socket(socket.AF_INET6).close()
return True
- except IOError, e:
+ except IOError as error:
logger.debug(u'Platform supports IPv6, but socket '
- 'creation failed, disabling: %s', e)
+ 'creation failed, disabling: %s', locale_decode(error))
return False
#: Boolean value that indicates if creating an IPv6 socket will succeed.
@@ -297,6 +299,7 @@ class LineProtocol(ThreadingActor):
encoding = 'utf-8'
def __init__(self, connection):
+ super(LineProtocol, self).__init__()
self.connection = connection
self.prevent_timeout = False
self.recv_buffer = ''
diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py
index 8bd39f06..5d99ac12 100644
--- a/mopidy/utils/path.py
+++ b/mopidy/utils/path.py
@@ -8,9 +8,12 @@ logger = logging.getLogger('mopidy.utils.path')
def get_or_create_folder(folder):
folder = os.path.expanduser(folder)
- if not os.path.isdir(folder):
+ if os.path.isfile(folder):
+ raise OSError('A file with the same name as the desired ' \
+ 'dir, "%s", already exists.' % folder)
+ elif not os.path.isdir(folder):
logger.info(u'Creating dir %s', folder)
- os.mkdir(folder, 0755)
+ os.makedirs(folder, 0755)
return folder
def get_or_create_file(filename):
diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py
index fca4f337..8060c667 100644
--- a/mopidy/utils/settings.py
+++ b/mopidy/utils/settings.py
@@ -2,7 +2,6 @@
from __future__ import absolute_import
from copy import copy
import getpass
-import glib
import logging
import os
from pprint import pformat
@@ -113,6 +112,7 @@ def validate_settings(defaults, settings):
errors = {}
changed = {
+ 'CUSTOM_OUTPUT': 'OUTPUT',
'DUMP_LOG_FILENAME': 'DEBUG_LOG_FILENAME',
'DUMP_LOG_FORMAT': 'DEBUG_LOG_FORMAT',
'FRONTEND': 'FRONTENDS',
@@ -121,7 +121,6 @@ def validate_settings(defaults, settings):
'LOCAL_OUTPUT_OVERRIDE': 'CUSTOM_OUTPUT',
'LOCAL_PLAYLIST_FOLDER': 'LOCAL_PLAYLIST_PATH',
'LOCAL_TAG_CACHE': 'LOCAL_TAG_CACHE_FILE',
- 'OUTPUT': None,
'SERVER': None,
'SERVER_HOSTNAME': 'MPD_SERVER_HOSTNAME',
'SERVER_PORT': 'MPD_SERVER_PORT',
@@ -141,15 +140,23 @@ def validate_settings(defaults, settings):
if setting == 'BACKENDS':
if 'mopidy.backends.despotify.DespotifyBackend' in value:
- errors[setting] = (u'Deprecated setting value. ' +
- '"mopidy.backends.despotify.DespotifyBackend" is no ' +
- 'longer available.')
+ errors[setting] = (
+ u'Deprecated setting value. '
+ u'"mopidy.backends.despotify.DespotifyBackend" is no '
+ u'longer available.')
continue
+ if setting == 'OUTPUTS':
+ errors[setting] = (
+ u'Deprecated setting, please change to OUTPUT. OUTPUT expectes '
+ u'a GStreamer bin describing your desired output.')
+ continue
+
if setting == 'SPOTIFY_BITRATE':
if value not in (96, 160, 320):
- errors[setting] = (u'Unavailable Spotify bitrate. ' +
- u'Available bitrates are 96, 160, and 320.')
+ errors[setting] = (
+ u'Unavailable Spotify bitrate. Available bitrates are 96, '
+ u'160, and 320.')
if setting not in defaults:
errors[setting] = u'Unknown setting. Is it misspelled?'
diff --git a/pylintrc b/pylintrc
index d2f84b77..98e10416 100644
--- a/pylintrc
+++ b/pylintrc
@@ -18,6 +18,7 @@
# 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,E0102,E0202,E1101,R0201,R0801,R0903,R0904,R0921,W0141,W0142,W0613
+disable = C0103,C0111,E0102,E0202,E1101,R0201,R0801,R0903,R0904,R0921,W0141,W0142,W0511,W0613
diff --git a/requirements/tests.txt b/requirements/tests.txt
index 0bc8380f..e24edd3c 100644
--- a/requirements/tests.txt
+++ b/requirements/tests.txt
@@ -2,3 +2,5 @@ coverage
mock >= 0.7
nose
tox
+unittest2
+yappi
diff --git a/setup.py b/setup.py
index a8cf8ed1..ae6cc699 100644
--- a/setup.py
+++ b/setup.py
@@ -6,9 +6,13 @@ 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 mopidy import get_version
+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
diff --git a/tests/__main__.py b/tests/__main__.py
index e2bb3e72..69113580 100644
--- a/tests/__main__.py
+++ b/tests/__main__.py
@@ -1,4 +1,8 @@
import nose
+import yappi
-if __name__ == '__main__':
+try:
+ yappi.start()
nose.main()
+finally:
+ yappi.print_stats()
diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py
index c81f4a0d..e99cd56c 100644
--- a/tests/backends/base/current_playlist.py
+++ b/tests/backends/base/current_playlist.py
@@ -1,7 +1,7 @@
import mock
import random
-from mopidy.models import Playlist, Track
+from mopidy.models import CpTrack, Playlist, Track
from mopidy.gstreamer import GStreamer
from tests.backends.base import populate_playlist
@@ -18,6 +18,13 @@ class CurrentPlaylistControllerTest(object):
assert len(self.tracks) == 3, 'Need three tracks to run tests.'
+ def test_length(self):
+ self.assertEqual(0, len(self.controller.cp_tracks))
+ self.assertEqual(0, self.controller.length)
+ self.controller.append(self.tracks)
+ self.assertEqual(3, len(self.controller.cp_tracks))
+ self.assertEqual(3, self.controller.length)
+
def test_add(self):
for track in self.tracks:
cp_track = self.controller.add(track)
@@ -136,6 +143,18 @@ class CurrentPlaylistControllerTest(object):
self.assertEqual(self.playback.state, self.playback.STOPPED)
self.assertEqual(self.playback.current_track, None)
+ def test_index_returns_index_of_track(self):
+ cp_tracks = []
+ for track in self.tracks:
+ cp_tracks.append(self.controller.add(track))
+ self.assertEquals(0, self.controller.index(cp_tracks[0]))
+ self.assertEquals(1, self.controller.index(cp_tracks[1]))
+ self.assertEquals(2, self.controller.index(cp_tracks[2]))
+
+ def test_index_raises_value_error_if_item_not_found(self):
+ test = lambda: self.controller.index(CpTrack(0, Track()))
+ self.assertRaises(ValueError, test)
+
@populate_playlist
def test_move_single(self):
self.controller.move(0, 0, 2)
@@ -241,6 +260,18 @@ class CurrentPlaylistControllerTest(object):
self.assertEqual(self.tracks[0], shuffled_tracks[0])
self.assertEqual(set(self.tracks), set(shuffled_tracks))
+ @populate_playlist
+ def test_slice_returns_a_subset_of_tracks(self):
+ track_slice = self.controller.slice(1, 3)
+ self.assertEqual(2, len(track_slice))
+ self.assertEqual(self.tracks[1], track_slice[0].track)
+ self.assertEqual(self.tracks[2], track_slice[1].track)
+
+ @populate_playlist
+ def test_slice_returns_empty_list_if_indexes_outside_tracks_list(self):
+ self.assertEqual(0, len(self.controller.slice(7, 8)))
+ self.assertEqual(0, len(self.controller.slice(-1, 1)))
+
def test_version_does_not_change_when_appending_nothing(self):
version = self.controller.version
self.controller.append([])
diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py
index 591ef5ce..b54906be 100644
--- a/tests/frontends/mpd/protocol/__init__.py
+++ b/tests/frontends/mpd/protocol/__init__.py
@@ -8,9 +8,9 @@ from mopidy.mixers import dummy as mixer
from tests import unittest
-class MockConnetion(mock.Mock):
+class MockConnection(mock.Mock):
def __init__(self, *args, **kwargs):
- super(MockConnetion, self).__init__(*args, **kwargs)
+ super(MockConnection, self).__init__(*args, **kwargs)
self.host = mock.sentinel.host
self.port = mock.sentinel.port
self.response = []
@@ -25,7 +25,7 @@ class BaseTestCase(unittest.TestCase):
self.backend = backend.DummyBackend.start().proxy()
self.mixer = mixer.DummyMixer.start().proxy()
- self.connection = MockConnetion()
+ self.connection = MockConnection()
self.session = mpd.MpdSession(self.connection)
self.dispatcher = self.session.dispatcher
self.context = self.dispatcher.context
diff --git a/tests/frontends/mpd/protocol/current_playlist_test.py b/tests/frontends/mpd/protocol/current_playlist_test.py
index 343b230b..21889e82 100644
--- a/tests/frontends/mpd/protocol/current_playlist_test.py
+++ b/tests/frontends/mpd/protocol/current_playlist_test.py
@@ -271,14 +271,22 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase):
self.sendRequest(u'playlistinfo')
self.assertInResponse(u'Title: a')
+ self.assertInResponse(u'Pos: 0')
self.assertInResponse(u'Title: b')
+ self.assertInResponse(u'Pos: 1')
self.assertInResponse(u'Title: c')
+ self.assertInResponse(u'Pos: 2')
self.assertInResponse(u'Title: d')
+ self.assertInResponse(u'Pos: 3')
self.assertInResponse(u'Title: e')
+ self.assertInResponse(u'Pos: 4')
self.assertInResponse(u'Title: f')
+ self.assertInResponse(u'Pos: 5')
self.assertInResponse(u'OK')
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([
Track(name='a'), Track(name='b'), Track(name='c'),
Track(name='d'), Track(name='e'), Track(name='f'),
@@ -286,11 +294,17 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase):
self.sendRequest(u'playlistinfo "4"')
self.assertNotInResponse(u'Title: a')
+ self.assertNotInResponse(u'Pos: 0')
self.assertNotInResponse(u'Title: b')
+ self.assertNotInResponse(u'Pos: 1')
self.assertNotInResponse(u'Title: c')
+ self.assertNotInResponse(u'Pos: 2')
self.assertNotInResponse(u'Title: d')
+ self.assertNotInResponse(u'Pos: 3')
self.assertInResponse(u'Title: e')
+ self.assertInResponse(u'Pos: 4')
self.assertNotInResponse(u'Title: f')
+ self.assertNotInResponse(u'Pos: 5')
self.assertInResponse(u'OK')
def test_playlistinfo_with_negative_songpos_same_as_playlistinfo(self):
@@ -306,11 +320,17 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase):
self.sendRequest(u'playlistinfo "2:"')
self.assertNotInResponse(u'Title: a')
+ self.assertNotInResponse(u'Pos: 0')
self.assertNotInResponse(u'Title: b')
+ self.assertNotInResponse(u'Pos: 1')
self.assertInResponse(u'Title: c')
+ self.assertInResponse(u'Pos: 2')
self.assertInResponse(u'Title: d')
+ self.assertInResponse(u'Pos: 3')
self.assertInResponse(u'Title: e')
+ self.assertInResponse(u'Pos: 4')
self.assertInResponse(u'Title: f')
+ self.assertInResponse(u'Pos: 5')
self.assertInResponse(u'OK')
def test_playlistinfo_with_closed_range(self):
diff --git a/tests/frontends/mpd/protocol/regression_test.py b/tests/frontends/mpd/protocol/regression_test.py
index d4e4b2aa..7f214efa 100644
--- a/tests/frontends/mpd/protocol/regression_test.py
+++ b/tests/frontends/mpd/protocol/regression_test.py
@@ -146,3 +146,19 @@ class IssueGH113RegressionTest(protocol.BaseTestCase):
self.sendRequest(
r'listplaylistinfo "all lart spotify:track:\\w\\{22\\} pastes"')
self.assertInResponse('OK')
+
+
+class IssueGH137RegressionTest(protocol.BaseTestCase):
+ """
+ The issue: https://github.com/mopidy/mopidy/issues/137
+
+ How to reproduce:
+
+ - Send "list" query with mismatching quotes
+ """
+
+ def test(self):
+ 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/serializer_test.py b/tests/frontends/mpd/serializer_test.py
index 681ab20f..a20abaed 100644
--- a/tests/frontends/mpd/serializer_test.py
+++ b/tests/frontends/mpd/serializer_test.py
@@ -4,7 +4,7 @@ import os
from mopidy import settings
from mopidy.utils.path import mtime, uri_to_path
from mopidy.frontends.mpd import translator, protocol
-from mopidy.models import Album, Artist, Playlist, Track
+from mopidy.models import Album, Artist, CpTrack, Playlist, Track
from tests import unittest
@@ -45,17 +45,17 @@ class TrackMpdFormatTest(unittest.TestCase):
self.assert_(('Pos', 1) not in result)
def test_track_to_mpd_format_with_cpid(self):
- result = translator.track_to_mpd_format(Track(), cpid=1)
+ result = translator.track_to_mpd_format(CpTrack(1, Track()))
self.assert_(('Id', 1) not in result)
def test_track_to_mpd_format_with_position_and_cpid(self):
- result = translator.track_to_mpd_format(Track(), position=1, cpid=2)
+ result = translator.track_to_mpd_format(CpTrack(2, Track()), position=1)
self.assert_(('Pos', 1) in result)
self.assert_(('Id', 2) in result)
def test_track_to_mpd_format_for_nonempty_track(self):
result = translator.track_to_mpd_format(
- self.track, position=9, cpid=122)
+ CpTrack(122, self.track), position=9)
self.assert_(('file', 'a uri') in result)
self.assert_(('Time', 137) in result)
self.assert_(('Artist', 'an artist') in result)
diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py
index 90cdab6a..49e56226 100644
--- a/tests/frontends/mpris/events_test.py
+++ b/tests/frontends/mpris/events_test.py
@@ -1,11 +1,19 @@
+import sys
+
import mock
-from mopidy.frontends.mpris import MprisFrontend, objects
+from mopidy import OptionalDependencyError
from mopidy.models import Track
+try:
+ from mopidy.frontends.mpris import MprisFrontend, objects
+except OptionalDependencyError:
+ pass
+
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
diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py
index a966403e..24c426fb 100644
--- a/tests/frontends/mpris/player_interface_test.py
+++ b/tests/frontends/mpris/player_interface_test.py
@@ -1,11 +1,18 @@
+import sys
+
import mock
+from mopidy import OptionalDependencyError
from mopidy.backends.dummy import DummyBackend
from mopidy.backends.base.playback import PlaybackController
-from mopidy.frontends.mpris import objects
from mopidy.mixers.dummy import DummyMixer
from mopidy.models import Album, Artist, Track
+try:
+ from mopidy.frontends.mpris import objects
+except OptionalDependencyError:
+ pass
+
from tests import unittest
PLAYING = PlaybackController.PLAYING
@@ -13,6 +20,7 @@ PAUSED = PlaybackController.PAUSED
STOPPED = PlaybackController.STOPPED
+@unittest.skipUnless(sys.platform.startswith('linux'), 'requires Linux')
class PlayerInterfaceTest(unittest.TestCase):
def setUp(self):
objects.MprisObject._connect_to_dbus = mock.Mock()
diff --git a/tests/frontends/mpris/root_interface_test.py b/tests/frontends/mpris/root_interface_test.py
index 443efdd3..1e54fc15 100644
--- a/tests/frontends/mpris/root_interface_test.py
+++ b/tests/frontends/mpris/root_interface_test.py
@@ -1,12 +1,19 @@
+import sys
+
import mock
-from mopidy import settings
+from mopidy import OptionalDependencyError, settings
from mopidy.backends.dummy import DummyBackend
-from mopidy.frontends.mpris import objects
+
+try:
+ from mopidy.frontends.mpris import objects
+except OptionalDependencyError:
+ pass
from tests import unittest
+@unittest.skipUnless(sys.platform.startswith('linux'), 'requires Linux')
class RootInterfaceTest(unittest.TestCase):
def setUp(self):
objects.exit_process = mock.Mock()
diff --git a/tests/gstreamer_test.py b/tests/gstreamer_test.py
index 66e0995e..012c9002 100644
--- a/tests/gstreamer_test.py
+++ b/tests/gstreamer_test.py
@@ -6,8 +6,6 @@ from mopidy.utils.path import path_to_uri
from tests import unittest, path_to_data_dir
-# TODO BaseOutputTest?
-
@unittest.skipIf(sys.platform == 'win32',
'Our Windows build server does not support GStreamer yet')
diff --git a/tests/mixers/denon_test.py b/tests/mixers/denon_test.py
index 7fec3c82..cdfe0772 100644
--- a/tests/mixers/denon_test.py
+++ b/tests/mixers/denon_test.py
@@ -34,7 +34,7 @@ class DenonMixerTest(BaseMixerTest, unittest.TestCase):
def setUp(self):
self.device = DenonMixerDeviceMock()
- self.mixer = DenonMixer(None, device=self.device)
+ self.mixer = DenonMixer(device=self.device)
def test_reopen_device(self):
self.device._open = False
diff --git a/tests/mixers/dummy_test.py b/tests/mixers/dummy_test.py
index 8ae8623c..f9418d7a 100644
--- a/tests/mixers/dummy_test.py
+++ b/tests/mixers/dummy_test.py
@@ -4,7 +4,7 @@ from tests import unittest
from tests.mixers.base_test import BaseMixerTest
-class DenonMixerTest(BaseMixerTest, unittest.TestCase):
+class DummyMixerTest(BaseMixerTest, unittest.TestCase):
mixer_class = DummyMixer
def test_set_volume_is_capped(self):
@@ -16,3 +16,8 @@ class DenonMixerTest(BaseMixerTest, unittest.TestCase):
self.mixer.amplification_factor = 0.5
self.mixer._volume = 50
self.assertEquals(self.mixer.volume, 100)
+
+ def test_get_volume_get_the_same_number_as_was_set(self):
+ self.mixer.amplification_factor = 0.5
+ self.mixer.volume = 13
+ self.assertEquals(self.mixer.volume, 13)
diff --git a/tests/utils/decode_test.py b/tests/utils/decode_test.py
new file mode 100644
index 00000000..edbfe651
--- /dev/null
+++ b/tests/utils/decode_test.py
@@ -0,0 +1,38 @@
+import mock
+
+from mopidy.utils import locale_decode
+
+from tests import unittest
+
+
+@mock.patch('mopidy.utils.locale.getpreferredencoding')
+class LocaleDecodeTest(unittest.TestCase):
+ def test_can_decode_utf8_strings_with_french_content(self, mock):
+ mock.return_value = 'UTF-8'
+
+ result = locale_decode(
+ '[Errno 98] Adresse d\xc3\xa9j\xc3\xa0 utilis\xc3\xa9e')
+
+ self.assertEquals(u'[Errno 98] Adresse d\xe9j\xe0 utilis\xe9e', result)
+
+ def test_can_decode_an_ioerror_with_french_content(self, mock):
+ mock.return_value = 'UTF-8'
+
+ error = IOError(98, 'Adresse d\xc3\xa9j\xc3\xa0 utilis\xc3\xa9e')
+ result = locale_decode(error)
+
+ self.assertEquals(u'[Errno 98] Adresse d\xe9j\xe0 utilis\xe9e', result)
+
+ def test_does_not_use_locale_to_decode_unicode_strings(self, mock):
+ mock.return_value = 'UTF-8'
+
+ locale_decode(u'abc')
+
+ self.assertFalse(mock.called)
+
+ def test_does_not_use_locale_to_decode_ascii_bytestrings(self, mock):
+ mock.return_value = 'UTF-8'
+
+ locale_decode('abc')
+
+ self.assertFalse(mock.called)
diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py
index ba1fcf97..19bae375 100644
--- a/tests/utils/path_test.py
+++ b/tests/utils/path_test.py
@@ -28,12 +28,32 @@ class GetOrCreateFolderTest(unittest.TestCase):
self.assert_(os.path.isdir(folder))
self.assertEqual(created, folder)
+ def test_creating_nested_folders(self):
+ level2_folder = os.path.join(self.parent, 'test')
+ level3_folder = os.path.join(self.parent, 'test', 'test')
+ self.assert_(not os.path.exists(level2_folder))
+ 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)
+ self.assert_(os.path.exists(level2_folder))
+ self.assert_(os.path.isdir(level2_folder))
+ self.assert_(os.path.exists(level3_folder))
+ self.assert_(os.path.isdir(level3_folder))
+ self.assertEqual(created, level3_folder)
+
def test_creating_existing_folder(self):
created = 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)
+ def test_create_folder_with_name_of_existing_file_throws_oserror(self):
+ 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)
+
class PathToFileURITest(unittest.TestCase):
def test_simple_path(self):
diff --git a/tests/version_test.py b/tests/version_test.py
index 4544349d..26045ac1 100644
--- a/tests/version_test.py
+++ b/tests/version_test.py
@@ -1,14 +1,14 @@
from distutils.version import StrictVersion as SV
import platform
-from mopidy import get_plain_version, get_platform, get_python
+from mopidy import __version__, get_platform, get_python
from tests import unittest
class VersionTest(unittest.TestCase):
def test_current_version_is_parsable_as_a_strict_version_number(self):
- SV(get_plain_version())
+ SV(__version__)
def test_versions_can_be_strictly_ordered(self):
self.assert_(SV('0.1.0a0') < SV('0.1.0a1'))
@@ -22,8 +22,13 @@ class VersionTest(unittest.TestCase):
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(get_plain_version()))
- self.assert_(SV(get_plain_version()) < SV('0.6.1'))
+ 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(__version__))
+ self.assert_(SV(__version__) < SV('0.8.0'))
def test_get_platform_contains_platform(self):
self.assert_(platform.platform() in get_platform())
diff --git a/tools/debug-proxy.py b/tools/debug-proxy.py
new file mode 100755
index 00000000..2f54ea36
--- /dev/null
+++ b/tools/debug-proxy.py
@@ -0,0 +1,190 @@
+#! /usr/bin/env python
+
+import argparse
+import difflib
+import sys
+
+from gevent import select, server, socket
+
+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"
+
+
+def proxy(client, address, reference_address, actual_address):
+ """Main handler code that gets called for each connection."""
+ client.setblocking(False)
+
+ reference = connect(reference_address)
+ actual = connect(actual_address)
+
+ if reference and actual:
+ loop(client, address, reference, actual)
+ else:
+ print 'Could not connect to one of the backends.'
+
+ for sock in (client, reference, actual):
+ close(sock)
+
+
+def connect(address):
+ """Connect to given address and set socket non blocking."""
+ try:
+ sock = socket.socket()
+ sock.connect(address)
+ sock.setblocking(False)
+ except socket.error:
+ return None
+ return sock
+
+
+def close(sock):
+ """Shutdown and close our sockets."""
+ try:
+ sock.shutdown(socket.SHUT_WR)
+ sock.close()
+ except socket.error:
+ pass
+
+
+def loop(client, address, reference, actual):
+ """Loop that handles one MPD reqeust/response pair per iteration."""
+
+ # Consume banners from backends
+ responses = dict()
+ disconnected = read([reference, actual], responses, find_response_end_token)
+ diff(address, '', responses[reference], responses[actual])
+
+ # We lost a backend, might as well give up.
+ if disconnected:
+ return
+
+ client.sendall(responses[reference])
+
+ while True:
+ responses = dict()
+
+ # Get the command from the client. Not sure how an if this will handle
+ # client sending multiple commands currently :/
+ disconnected = read([client], responses, find_request_end_token)
+
+ # We lost the client, might as well give up.
+ if disconnected:
+ return
+
+ # Send the entire command to both backends.
+ reference.sendall(responses[client])
+ actual.sendall(responses[client])
+
+ # Get the entire resonse from both backends.
+ 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])
+
+ # Give up if we lost a backend.
+ if disconnected:
+ return
+
+
+def read(sockets, responses, find_end_token):
+ """Keep reading from sockets until they disconnet or we find our token."""
+
+ # This function doesn't go to well with idle when backends are out of sync.
+ disconnected = False
+
+ for sock in sockets:
+ responses.setdefault(sock, '')
+
+ while sockets:
+ for sock in select.select(sockets, [], [])[0]:
+ data = sock.recv(4096)
+ responses[sock] += data
+
+ if find_end_token(responses[sock]):
+ sockets.remove(sock)
+
+ if not data:
+ sockets.remove(sock)
+ disconnected = True
+
+ return disconnected
+
+
+def find_response_end_token(data):
+ """Find token that indicates the response is over."""
+ for line in data.splitlines(True):
+ if line.startswith(('OK', 'ACK')) and line.endswith('\n'):
+ return True
+ return False
+
+
+def find_request_end_token(data):
+ """Find token that indicates that request is over."""
+ lines = data.splitlines(True)
+ if not lines:
+ return False
+ elif 'command_list_ok_begin' == lines[0].strip():
+ return 'command_list_end' == lines[-1].strip()
+ else:
+ return lines[0].endswith('\n')
+
+
+def diff(address, command, reference_response, actual_response):
+ """Print command from client and a unified diff of the responses."""
+ sys.stdout.write('[%s]:%s\n%s' % (address[0], address[1], command))
+ for line in difflib.unified_diff(reference_response.splitlines(True),
+ actual_response.splitlines(True),
+ fromfile='Reference response',
+ tofile='Actual response'):
+
+ if line.startswith('+') and not line.startswith('+++'):
+ sys.stdout.write(GREEN)
+ elif line.startswith('-') and not line.startswith('---'):
+ sys.stdout.write(RED)
+ elif line.startswith('@@'):
+ sys.stdout.write(CYAN)
+
+ sys.stdout.write(line)
+ sys.stdout.write(RESET)
+
+ sys.stdout.flush()
+
+
+def parse_args():
+ """Handle flag parsing."""
+ parser = argparse.ArgumentParser(
+ description='Proxy and compare MPD protocol interactions.')
+ parser.add_argument('--listen', default=':6600', type=parse_address,
+ help='address:port to listen on.')
+ parser.add_argument('--reference', default=':6601', type=parse_address,
+ help='address:port for the reference backend.')
+ parser.add_argument('--actual', default=':6602', type=parse_address,
+ help='address:port for the actual backend.')
+
+ return parser.parse_args()
+
+
+def parse_address(address):
+ """Convert host:port or port to address to pass to connect."""
+ if ':' not in address:
+ return ('', int(address))
+ host, port = address.rsplit(':', 1)
+ return (host, int(port))
+
+
+if __name__ == '__main__':
+ args = parse_args()
+
+ def handle(client, address):
+ """Wrapper that adds reference and actual backends to proxy calls."""
+ return proxy(client, address, args.reference, args.actual)
+
+ try:
+ server.StreamServer(args.listen, handle).serve_forever()
+ except (KeyboardInterrupt, SystemExit):
+ pass