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