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/data/mopidy.desktop b/data/mopidy.desktop index 70257d58..88dd5ae4 100644 --- a/data/mopidy.desktop +++ b/data/mopidy.desktop @@ -8,3 +8,4 @@ TryExec=mopidy Exec=mopidy Terminal=true Categories=AudioVideo;Audio;Player;ConsoleOnly; +StartupNotify=true 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 792e4bc9..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. @@ -28,3 +28,4 @@ Frontend implementations * :mod:`mopidy.frontends.lastfm` * :mod:`mopidy.frontends.mpd` +* :mod:`mopidy.frontends.mpris` diff --git a/docs/changes.rst b/docs/changes.rst index b0d320eb..a4aae058 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -4,14 +4,144 @@ Changes This change log is used to track all major changes to Mopidy. +v0.7.3 (2012-08-11) +=================== -v0.6.0 (in development) -======================= +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 + ``~/.mopidy/settings.py`` to ``~/.config/mopidy/settings.py``. + - Your Spotify cache will now be stored in ``~/.cache/mopidy`` instead of + ``~/.mopidy/spotify_cache``. + - The local backend's ``tag_cache`` should now be in + ``~/.local/share/mopidy/tag_cache``, likewise your playlists will be in + ``~/.local/share/mopidy/playlists``. + - The local client now tries to lookup where your music is via XDG, it will + fall-back to ``~/music`` or use whatever setting you set manually. + +- The MPD command ``idle`` is now supported by Mopidy for the following + subsystems: player, playlist, options, and mixer. (Fixes: :issue:`32`) + +- 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 through the `Ubuntu Sound + Menu `_. + **Changes** - Replace :attr:`mopidy.backends.base.Backend.uri_handlers` with @@ -22,12 +152,26 @@ v0.6.0 (in development) wanting to receive events from the backend. This is a formalization of the ad hoc events the Last.fm scrobbler has already been using for some time. -- Fix metadata update in Shoutcast streaming (Fixes: :issue:`122`) +- Replaced all of the MPD network code that was provided by asyncore with + custom stack. This change was made to facilitate support for the ``idle`` + command, and to reduce the number of event loops being used. -- Multiple simultaneously playing outputs was considered more trouble than what - it is worth maintnance wise. Thus, this feature has been axed for now. - Switching outputs is still posible, but only one can be active at a time, and - it is still the case that switching during playback does not funtion. +- Fix metadata update in Shoutcast streaming. (Fixes: :issue:`122`) + +- 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) @@ -112,6 +256,18 @@ Please note that 0.5.0 requires some updated dependencies, as listed under - Found and worked around strange WMA metadata behaviour. +- Backend API: + + - Calling on :meth:`mopidy.backends.base.playback.PlaybackController.next` + and :meth:`mopidy.backends.base.playback.PlaybackController.previous` no + longer implies that playback should be started. The playback state--whether + playing, paused or stopped--will now be kept. + + - The method + :meth:`mopidy.backends.base.playback.PlaybackController.change_track` + has been added. Like ``next()``, and ``prev()``, it changes the current + track without changing the playback state. + v0.4.1 (2011-05-06) =================== @@ -120,7 +276,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. @@ -165,7 +321,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:: @@ -242,12 +398,12 @@ loading from Mopidy 0.3.0 is still present. the debug log, to ease debugging of issues with attached debug logs. -v0.3.1 (2010-01-22) +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. @@ -256,7 +412,7 @@ A couple of fixes to the 0.3.0 release is needed to get a smooth installation. installed if the installation is executed as the root user. -v0.3.0 (2010-01-22) +v0.3.0 (2011-01-22) =================== Mopidy 0.3.0 brings a bunch of small changes all over the place, but no large @@ -416,7 +572,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 @@ -746,7 +902,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/clients/mpd.rst b/docs/clients/mpd.rst index f5066210..4c789eba 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -20,9 +20,8 @@ A command line client. Version 0.14 had some issues with Mopidy (see ncmpc ----- -A console client. Uses the ``idle`` command heavily, which Mopidy doesn't -support yet (see :issue:`32`). If you want a console client, use ncmpcpp -instead. +A console client. Works with Mopidy 0.6 and upwards. Uses the ``idle`` MPD +command, but in a resource inefficient way. ncmpcpp @@ -48,15 +47,15 @@ from `Launchpad `_. Communication mode ^^^^^^^^^^^^^^^^^^ -In newer versions of ncmpcpp, like 0.5.5 shipped with Ubuntu 11.04, ncmcpp -defaults to "notifications" mode for MPD communications, which Mopidy currently -does not support. To workaround this limitation in Mopidy, edit the ncmpcpp -configuration file at ``~/.ncmpcpp/config`` and add the following setting:: +In newer versions of ncmpcpp, like ncmpcpp 0.5.5 shipped with Ubuntu 11.04, +ncmcpp defaults to "notifications" mode for MPD communications, which Mopidy +did not support before Mopidy 0.6. To workaround this limitation in earlier +versions of Mopidy, edit the ncmpcpp configuration file at +``~/.ncmpcpp/config`` and add the following setting:: mpd_communication_mode = "polling" -You can track the development of "notifications" mode support in Mopidy in -:issue:`32`. +If you use Mopidy 0.6 or newer, you don't need to change anything. Graphical clients 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 8f2ea07e..c6359f6f 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 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 6f69b2a9..0ce138a2 100644 --- a/docs/modules/frontends/mpd.rst +++ b/docs/modules/frontends/mpd.rst @@ -2,38 +2,14 @@ :mod:`mopidy.frontends.mpd` -- MPD server ***************************************** -.. inheritance-diagram:: mopidy.frontends.mpd - .. automodule:: mopidy.frontends.mpd :synopsis: MPD server frontend :members: -MPD server -========== - -.. inheritance-diagram:: mopidy.frontends.mpd.server - -.. automodule:: mopidy.frontends.mpd.server - :synopsis: MPD server - :members: - - -MPD session -=========== - -.. inheritance-diagram:: mopidy.frontends.mpd.session - -.. automodule:: mopidy.frontends.mpd.session - :synopsis: MPD client session - :members: - - MPD dispatcher ============== -.. inheritance-diagram:: mopidy.frontends.mpd.dispatcher - .. automodule:: mopidy.frontends.mpd.dispatcher :synopsis: MPD request dispatcher :members: diff --git a/docs/modules/frontends/mpris.rst b/docs/modules/frontends/mpris.rst new file mode 100644 index 00000000..05a6e287 --- /dev/null +++ b/docs/modules/frontends/mpris.rst @@ -0,0 +1,7 @@ +*********************************************** +:mod:`mopidy.frontends.mpris` -- MPRIS frontend +*********************************************** + +.. automodule:: mopidy.frontends.mpris + :synopsis: MPRIS frontend + :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 d3c9015e..980fcd4c 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:: @@ -92,7 +92,6 @@ To make a ``tag_cache`` of your local music available for Mopidy: .. _use_mpd_on_a_network: - Connecting from other machines on the network ============================================= @@ -120,6 +119,33 @@ file:: LASTFM_PASSWORD = u'mysecret' +.. _install_desktop_file: + +Controlling Mopidy through the Ubuntu Sound Menu +================================================ + +If you are running Ubuntu and installed Mopidy using the Debian package from +APT you should be able to control Mopidy through the `Ubuntu Sound Menu +`_ without any changes. + +If you installed Mopidy in any other way and want to control Mopidy through the +Ubuntu Sound Menu, you must install the ``mopidy.desktop`` file which can be +found in the ``data/`` dir of the Mopidy source into the +``/usr/share/applications`` dir by hand:: + + cd /path/to/mopidy/source + sudo cp data/mopidy.desktop /usr/share/applications/ + +After you have installed the file, start Mopidy in any way, and Mopidy should +appear in the Ubuntu Sound Menu. When you quit Mopidy, it will still be listed +in the Ubuntu Sound Menu, and may be restarted by selecting it there. + +The Ubuntu Sound Menu interacts with Mopidy's MPRIS frontend, +:mod:`mopidy.frontends.mpris`. The MPRIS frontend supports the minimum +requirements of the `MPRIS specification `_. The +``TrackList`` and the ``Playlists`` interfaces of the spec are not supported. + + Streaming audio through a SHOUTcast/Icecast server ================================================== diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 7b25c525..11293446 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,17 +1,25 @@ -import platform import sys if not (2, 6) <= sys.version_info < (3,): sys.exit(u'Mopidy requires Python >= 2.6, < 3') +import os +import platform from subprocess import PIPE, Popen -VERSION = (0, 6, 0) +import glib + +__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) @@ -22,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 2633f166..d7e6c331 100644 --- a/mopidy/backends/base/current_playlist.py +++ b/mopidy/backends/base/current_playlist.py @@ -2,6 +2,7 @@ from copy import copy import logging import random +from mopidy.listeners import BackendListener from mopidy.models import CpTrack logger = logging.getLogger('mopidy.backends.base') @@ -16,13 +17,10 @@ class CurrentPlaylistController(object): def __init__(self, backend): self.backend = backend + self.cp_id = 0 self._cp_tracks = [] self._version = 0 - def destroy(self): - """Cleanup after component.""" - pass - @property def cp_tracks(self): """ @@ -30,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): @@ -39,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): @@ -53,8 +58,9 @@ class CurrentPlaylistController(object): def version(self, version): self._version = version self.backend.playback.on_current_playlist_change() + self._trigger_playlist_changed() - def add(self, track, at_position=None): + def add(self, track, at_position=None, increase_version=True): """ Add the track to the end of, or at the given position in the current playlist. @@ -68,12 +74,14 @@ class CurrentPlaylistController(object): """ assert at_position <= len(self._cp_tracks), \ u'at_position can not be greater than playlist length' - cp_track = CpTrack(self.version, track) + cp_track = CpTrack(self.cp_id, track) if at_position is not None: self._cp_tracks.insert(at_position, cp_track) else: self._cp_tracks.append(cp_track) - self.version += 1 + if increase_version: + self.version += 1 + self.cp_id += 1 return cp_track def append(self, tracks): @@ -84,7 +92,10 @@ class CurrentPlaylistController(object): :type tracks: list of :class:`mopidy.models.Track` """ for track in tracks: - self.add(track) + self.add(track, increase_version=False) + + if tracks: + self.version += 1 def clear(self): """Clear the current playlist.""" @@ -112,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] @@ -125,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``. @@ -164,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) @@ -199,3 +222,20 @@ class CurrentPlaylistController(object): random.shuffle(shuffled) 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 088a5ad4..16ac75d1 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -2,12 +2,21 @@ import logging import random import time -from pykka.registry import ActorRegistry - from mopidy.listeners import BackendListener logger = logging.getLogger('mopidy.backends.base') + +def option_wrapper(name, default): + def get_option(self): + return getattr(self, name, default) + def set_option(self, value): + if getattr(self, name, default) != value: + self._trigger_options_changed() + return setattr(self, name, value) + return property(get_option, set_option) + + class PlaybackController(object): """ :param backend: the backend @@ -34,7 +43,7 @@ class PlaybackController(object): #: Tracks are removed from the playlist when they have been played. #: :class:`False` #: Tracks are not removed from the playlist. - consume = False + consume = option_wrapper('_consume', False) #: The currently playing or selected track. #: @@ -46,21 +55,21 @@ class PlaybackController(object): #: Tracks are selected at random from the playlist. #: :class:`False` #: Tracks are played in the order of the playlist. - random = False + random = option_wrapper('_random', False) #: :class:`True` #: The current playlist is played repeatedly. To repeat a single track, #: select both :attr:`repeat` and :attr:`single`. #: :class:`False` #: The current playlist is played once. - repeat = False + repeat = option_wrapper('_repeat', False) #: :class:`True` #: Playback is stopped after current song, unless in :attr:`repeat` #: mode. #: :class:`False` #: Playback continues after current song. - single = False + single = option_wrapper('_single', False) def __init__(self, backend, provider): self.backend = backend @@ -71,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 @@ -276,6 +279,9 @@ class PlaybackController(object): def state(self, new_state): (old_state, self._state) = (self.state, new_state) logger.debug(u'Changing state: %s -> %s', old_state, new_state) + + self._trigger_playback_state_changed() + # FIXME play_time stuff assumes backend does not have a better way of # handeling this stuff :/ if (old_state in (self.PLAYING, self.STOPPED) @@ -313,6 +319,26 @@ class PlaybackController(object): def _current_wall_time(self): return int(time.time() * 1000) + def change_track(self, cp_track, on_error_step=1): + """ + Change to the given track, keeping the current playback state. + + :param cp_track: track to change to + :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) + or :class:`None` + :param on_error_step: direction to step at play error, 1 for next + track (default), -1 for previous track + :type on_error_step: int, -1 or 1 + + """ + old_state = self.state + self.stop() + self.current_cp_track = cp_track + if old_state == self.PLAYING: + self.play(on_error_step=on_error_step) + elif old_state == self.PAUSED: + self.pause() + def on_end_of_track(self): """ Tell the playback controller that end of track is reached. @@ -326,7 +352,7 @@ class PlaybackController(object): original_cp_track = self.current_cp_track if self.cp_track_at_eot: - self._trigger_stopped_playing_event() + self._trigger_track_playback_ended() self.play(self.cp_track_at_eot) else: self.stop(clear_current_track=True) @@ -349,20 +375,23 @@ class PlaybackController(object): self.stop(clear_current_track=True) def next(self): - """Play the next track.""" - if self.state == self.STOPPED: - return + """ + Change to the next track. + The current playback state will be kept. If it was playing, playing + will continue. If it was paused, it will still be paused, etc. + """ if self.cp_track_at_next: - self._trigger_stopped_playing_event() - self.play(self.cp_track_at_next) + self._trigger_track_playback_ended() + self.change_track(self.cp_track_at_next) else: self.stop(clear_current_track=True) def pause(self): """Pause playback.""" - if self.state == self.PLAYING and self.provider.pause(): + if self.provider.pause(): self.state = self.PAUSED + self._trigger_track_playback_paused() def play(self, cp_track=None, on_error_step=1): """ @@ -379,15 +408,17 @@ class PlaybackController(object): if cp_track is not None: assert cp_track in self.backend.current_playlist.cp_tracks - - if cp_track is None and self.current_cp_track is None: - cp_track = self.cp_track_at_next - - if cp_track is None and self.state == self.PAUSED: - self.resume() + elif cp_track is None: + if self.state == self.PAUSED: + return self.resume() + elif self.current_cp_track is not None: + cp_track = self.current_cp_track + elif self.current_cp_track is None and on_error_step == 1: + cp_track = self.cp_track_at_next + elif self.current_cp_track is None and on_error_step == -1: + cp_track = self.cp_track_at_previous if cp_track is not None: - self.state = self.STOPPED self.current_cp_track = cp_track self.state = self.PLAYING if not self.provider.play(cp_track.track): @@ -402,21 +433,23 @@ class PlaybackController(object): if self.random and self.current_cp_track in self._shuffled: self._shuffled.remove(self.current_cp_track) - self._trigger_started_playing_event() + self._trigger_track_playback_started() def previous(self): - """Play the previous track.""" - if self.cp_track_at_previous is None: - return - if self.state == self.STOPPED: - return - self._trigger_stopped_playing_event() - self.play(self.cp_track_at_previous, on_error_step=-1) + """ + Change to the previous track. + + The current playback state will be kept. If it was playing, playing + will continue. If it was paused, it will still be paused, etc. + """ + self._trigger_track_playback_ended() + self.change_track(self.cp_track_at_previous, on_error_step=-1) def resume(self): """If paused, resume playing the current track.""" if self.state == self.PAUSED and self.provider.resume(): self.state = self.PLAYING + self._trigger_track_playback_resumed() def seek(self, time_position): """ @@ -443,7 +476,10 @@ class PlaybackController(object): self.play_time_started = self._current_wall_time self.play_time_accumulated = time_position - return self.provider.seek(time_position) + success = self.provider.seek(time_position) + if success: + self._trigger_seeked() + return success def stop(self, clear_current_track=False): """ @@ -454,37 +490,54 @@ class PlaybackController(object): :type clear_current_track: boolean """ if self.state != self.STOPPED: - self._trigger_stopped_playing_event() if self.provider.stop(): + self._trigger_track_playback_ended() self.state = self.STOPPED if clear_current_track: self.current_cp_track = None - def _trigger_started_playing_event(self): - logger.debug(u'Triggering started playing event') + def _trigger_track_playback_paused(self): + logger.debug(u'Triggering track playback paused event') if self.current_track is None: return - ActorRegistry.broadcast({ - 'command': 'pykka_call', - 'attr_path': ('started_playing',), - 'args': [], - 'kwargs': {'track': self.current_track}, - }, target_class=BackendListener) + BackendListener.send('track_playback_paused', + track=self.current_track, + time_position=self.time_position) - def _trigger_stopped_playing_event(self): - # TODO Test that this is called on next/prev/end-of-track - logger.debug(u'Triggering stopped playing event') + def _trigger_track_playback_resumed(self): + logger.debug(u'Triggering track playback resumed event') if self.current_track is None: return - ActorRegistry.broadcast({ - 'command': 'pykka_call', - 'attr_path': ('stopped_playing',), - 'args': [], - 'kwargs': { - 'track': self.current_track, - 'time_position': self.time_position, - }, - }, target_class=BackendListener) + BackendListener.send('track_playback_resumed', + track=self.current_track, + time_position=self.time_position) + + def _trigger_track_playback_started(self): + logger.debug(u'Triggering track playback started event') + if self.current_track is None: + return + BackendListener.send('track_playback_started', + track=self.current_track) + + def _trigger_track_playback_ended(self): + logger.debug(u'Triggering track playback ended event') + if self.current_track is None: + return + BackendListener.send('track_playback_ended', + track=self.current_track, + time_position=self.time_position) + + def _trigger_playback_state_changed(self): + logger.debug(u'Triggering playback state change event') + BackendListener.send('playback_state_changed') + + def _trigger_options_changed(self): + logger.debug(u'Triggering options changed event') + BackendListener.send('options_changed') + + def _trigger_seeked(self): + logger.debug(u'Triggering seeked event') + BackendListener.send('seeked') class BasePlaybackProvider(object): @@ -498,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 af80a8eb..e8638a3a 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -1,4 +1,5 @@ import glob +import glib import logging import os import shutil @@ -6,7 +7,7 @@ import shutil from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry -from mopidy import settings +from mopidy import settings, DATA_PATH from mopidy.backends.base import (Backend, CurrentPlaylistController, LibraryController, BaseLibraryProvider, PlaybackController, BasePlaybackProvider, StoredPlaylistsController, @@ -18,6 +19,14 @@ from .translator import parse_m3u, parse_mpd_tag_cache logger = logging.getLogger(u'mopidy.backends.local') +DEFAULT_PLAYLIST_PATH = os.path.join(DATA_PATH, 'playlists') +DEFAULT_TAG_CACHE_FILE = os.path.join(DATA_PATH, 'tag_cache') +DEFAULT_MUSIC_PATH = str(glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC)) + +if not DEFAULT_MUSIC_PATH or DEFAULT_MUSIC_PATH == os.path.expanduser(u'~'): + DEFAULT_MUSIC_PATH = os.path.expanduser(u'~/music') + + class LocalBackend(ThreadingActor, Backend): """ A backend for playing music from a local music archive. @@ -58,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() @@ -96,7 +106,7 @@ class LocalPlaybackProvider(BasePlaybackProvider): class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider): def __init__(self, *args, **kwargs): super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs) - self._folder = settings.LOCAL_PLAYLIST_PATH + self._folder = settings.LOCAL_PLAYLIST_PATH or DEFAULT_PLAYLIST_PATH self.refresh() def lookup(self, uri): @@ -173,8 +183,8 @@ class LocalLibraryProvider(BaseLibraryProvider): self.refresh() def refresh(self, uri=None): - tag_cache = settings.LOCAL_TAG_CACHE_FILE - music_folder = settings.LOCAL_MUSIC_PATH + tag_cache = settings.LOCAL_TAG_CACHE_FILE or DEFAULT_TAG_CACHE_FILE + music_folder = settings.LOCAL_MUSIC_PATH or DEFAULT_MUSIC_PATH tracks = parse_mpd_tag_cache(tag_cache, music_folder) 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 2c6509ed..481f7a94 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -6,7 +6,7 @@ from spotify.manager import SpotifySessionManager as PyspotifySessionManager from pykka.registry import ActorRegistry -from mopidy import get_version, settings +from mopidy import get_version, settings, CACHE_PATH from mopidy.backends.base import Backend from mopidy.backends.spotify import BITRATES from mopidy.backends.spotify.container_manager import SpotifyContainerManager @@ -21,9 +21,11 @@ logger = logging.getLogger('mopidy.backends.spotify.session_manager') # pylint: disable = R0901 # SpotifySessionManager: Too many ancestors (9/7) + class SpotifySessionManager(BaseThread, PyspotifySessionManager): - cache_location = settings.SPOTIFY_CACHE_PATH - settings_location = settings.SPOTIFY_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() @@ -41,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() @@ -95,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""" @@ -128,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""" @@ -137,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)) @@ -149,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 b3ce9070..596e0fe5 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -1,47 +1,48 @@ import logging import optparse +import os import signal import sys -import time + +import gobject +gobject.threads_init() # Extract any non-GStreamer arguments, and leave the GStreamer arguments for # processing by GStreamer. This needs to be done before GStreamer is imported, # 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) + SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE) from mopidy.gstreamer import GStreamer from mopidy.utils import get_class from mopidy.utils.log import setup_logging from mopidy.utils.path import get_or_create_folder, get_or_create_file -from mopidy.utils.process import (GObjectEventThread, exit_handler, - stop_remaining_actors, stop_actors_by_class) +from mopidy.utils.process import (exit_handler, stop_remaining_actors, + stop_actors_by_class) from mopidy.utils.settings import list_settings_optparse_callback logger = logging.getLogger('mopidy.core') def main(): signal.signal(signal.SIGTERM, exit_handler) + loop = gobject.MainLoop() try: options = parse_options() setup_logging(options.verbosity_level, options.save_debug_log) + check_old_folders() setup_settings(options.interactive) - setup_gobject_loop() setup_gstreamer() setup_mixer() setup_backend() setup_frontends() - while True: - time.sleep(1) + loop.run() except SettingsError as e: logger.error(e.message) except KeyboardInterrupt: @@ -49,6 +50,7 @@ def main(): except Exception as e: logger.exception(e) finally: + loop.quit() stop_frontends() stop_backend() stop_mixer() @@ -67,7 +69,7 @@ def parse_options(): action='store_const', const=0, dest='verbosity_level', help='less output (warning level)') parser.add_option('-v', '--verbose', - action='store_const', const=2, dest='verbosity_level', + action='count', default=1, dest='verbosity_level', help='more output (debug level)') parser.add_option('--save-debug-log', action='store_true', dest='save_debug_log', @@ -77,18 +79,26 @@ def parse_options(): help='list current settings') return parser.parse_args(args=mopidy_args)[0] +def check_old_folders(): + old_settings_folder = os.path.expanduser(u'~/.mopidy') + + if not os.path.isdir(old_settings_folder): + return + + logger.warning(u'Old settings folder found at %s, settings.py should be ' + 'moved to %s, any cache data should be deleted. See release notes ' + 'for further instructions.', old_settings_folder, SETTINGS_PATH) + def setup_settings(interactive): - get_or_create_folder('~/.mopidy/') - get_or_create_file('~/.mopidy/settings.py') + get_or_create_folder(SETTINGS_PATH) + get_or_create_folder(DATA_PATH) + get_or_create_file(SETTINGS_FILE) try: settings.validate(interactive) except SettingsError, e: logger.error(e.message) sys.exit(1) -def setup_gobject_loop(): - GObjectEventThread().start() - def setup_gstreamer(): GStreamer.start() diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index d50f8dd8..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 @@ -57,7 +58,7 @@ class LastfmFrontend(ThreadingActor, BackendListener): logger.error(u'Error during Last.fm setup: %s', e) self.stop() - def started_playing(self, track): + def track_playback_started(self, track): artists = ', '.join([a.name for a in track.artists]) duration = track.length and track.length // 1000 or 0 self.last_start_time = int(time.time()) @@ -74,7 +75,7 @@ class LastfmFrontend(ThreadingActor, BackendListener): pylast.MalformedResponseError, pylast.WSError) as e: logger.warning(u'Error submitting playing track to Last.fm: %s', e) - def stopped_playing(self, track, time_position): + def track_playback_ended(self, track, time_position): artists = ', '.join([a.name for a in track.artists]) duration = track.length and track.length // 1000 or 0 time_position = time_position // 1000 diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index f37b2deb..e8b2aabe 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -1,14 +1,15 @@ -import asyncore import logging +import sys -from pykka.actor import ThreadingActor +from pykka import registry, actor -from mopidy.frontends.mpd.server import MpdServer -from mopidy.utils.process import BaseThread +from mopidy import listeners, settings +from mopidy.frontends.mpd import dispatcher, protocol +from mopidy.utils import locale_decode, log, network, process logger = logging.getLogger('mopidy.frontends.mpd') -class MpdFrontend(ThreadingActor): +class MpdFrontend(actor.ThreadingActor, listeners.BackendListener): """ The MPD frontend. @@ -24,23 +25,86 @@ class MpdFrontend(ThreadingActor): """ def __init__(self): - self._thread = None + super(MpdFrontend, self).__init__() + hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME) + port = settings.MPD_SERVER_PORT + + try: + 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) + + def on_stop(self): + process.stop_actors_by_class(MpdSession) + + def send_idle(self, subsystem): + # FIXME this should be updated once pykka supports non-blocking calls + # on proxies or some similar solution + registry.ActorRegistry.broadcast({ + 'command': 'pykka_call', + 'attr_path': ('on_idle',), + 'args': [subsystem], + 'kwargs': {}, + }, target_class=MpdSession) + + def playback_state_changed(self): + self.send_idle('player') + + def playlist_changed(self): + self.send_idle('playlist') + + def options_changed(self): + self.send_idle('options') + + def volume_changed(self): + self.send_idle('mixer') + + +class MpdSession(network.LineProtocol): + """ + The MPD client session. Keeps track of a single client session. Any + requests from the client is passed on to the MPD request dispatcher. + """ + + terminator = protocol.LINE_TERMINATOR + encoding = protocol.ENCODING + delimeter = r'\r?\n' + + def __init__(self, connection): + super(MpdSession, self).__init__(connection) + self.dispatcher = dispatcher.MpdDispatcher(self) def on_start(self): - self._thread = MpdThread() - self._thread.start() + logger.info(u'New MPD connection from [%s]:%s', self.host, self.port) + self.send_lines([u'OK MPD %s' % protocol.VERSION]) - def on_receive(self, message): - pass # Ignore any messages + def on_line_received(self, line): + logger.debug(u'Request from [%s]:%s to %s: %s', self.host, self.port, + self.actor_urn, line) + response = self.dispatcher.handle_request(line) + if not response: + return -class MpdThread(BaseThread): - def __init__(self): - super(MpdThread, self).__init__() - self.name = u'MpdThread' + logger.debug(u'Response to [%s]:%s from %s: %s', self.host, self.port, + self.actor_urn, log.indent(self.terminator.join(response))) - def run_inside_try(self): - logger.debug(u'Starting MPD server thread') - server = MpdServer() - server.start() - asyncore.loop() + self.send_lines(response) + + def on_idle(self, subsystem): + self.dispatcher.handle_idle(subsystem) + + def decode(self, line): + try: + return super(MpdSession, self).decode(line.decode('string_escape')) + except ValueError: + logger.warning(u'Stopping actor due to unescaping error, data ' + 'supplied by client was not valid.') + self.stop() + + def close(self): + self.stop() diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 18f994de..2b012c7c 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -27,6 +27,8 @@ class MpdDispatcher(object): back to the MPD session. """ + _noidle = re.compile(r'^noidle$') + def __init__(self, session=None): self.authenticated = False self.command_list = False @@ -42,11 +44,28 @@ class MpdDispatcher(object): self._catch_mpd_ack_errors_filter, self._authenticate_filter, self._command_list_filter, + self._idle_filter, self._add_ok_filter, self._call_handler_filter, ] return self._call_next_filter(request, response, filter_chain) + def handle_idle(self, subsystem): + self.context.events.add(subsystem) + + subsystems = self.context.subscriptions.intersection( + self.context.events) + if not subsystems: + return + + response = [] + for subsystem in subsystems: + response.append(u'changed: %s' % subsystem) + response.append(u'OK') + self.context.subscriptions = set() + self.context.events = set() + self.context.session.send_lines(response) + def _call_next_filter(self, request, response, filter_chain): if filter_chain: next_filter = filter_chain.pop(0) @@ -71,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: @@ -108,6 +127,29 @@ class MpdDispatcher(object): and request != u'command_list_end') + ### Filter: idle + + def _idle_filter(self, request, response, filter_chain): + if self._is_currently_idle() and not self._noidle.match(request): + logger.debug(u'Client sent us %s, only %s is allowed while in ' + 'the idle state', repr(request), repr(u'noidle')) + self.context.session.close() + return [] + + if not self._is_currently_idle() and self._noidle.match(request): + return [] # noidle was called before idle + + response = self._call_next_filter(request, response, filter_chain) + + if self._is_currently_idle(): + return [] + else: + return response + + def _is_currently_idle(self): + return bool(self.context.subscriptions) + + ### Filter: add OK def _add_ok_filter(self, request, response, filter_chain): @@ -178,12 +220,20 @@ class MpdContext(object): #: The current :class:`MpdDispatcher`. dispatcher = None - #: The current :class:`mopidy.frontends.mpd.session.MpdSession`. + #: The current :class:`mopidy.frontends.mpd.MpdSession`. session = None + #: The active subsystems that have pending events. + events = None + + #: The subsytems that we want to be notified about in idle mode. + subscriptions = None + def __init__(self, dispatcher, session=None): self.dispatcher = dispatcher self.session = session + self.events = set() + self.subscriptions = set() self._backend = None self._mixer = None @@ -192,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 @@ -204,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..0d61c887 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): @@ -74,8 +75,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 +87,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') @@ -157,8 +159,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 +194,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 +214,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 +242,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.get(cpid=songpos).get() + 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 +296,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 +373,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/empty.py b/mopidy/frontends/mpd/protocol/empty.py index 0e418551..4cdafd87 100644 --- a/mopidy/frontends/mpd/protocol/empty.py +++ b/mopidy/frontends/mpd/protocol/empty.py @@ -1,6 +1,6 @@ from mopidy.frontends.mpd.protocol import handle_request -@handle_request(r'^$') +@handle_request(r'^[ ]*$') def empty(context): """The original MPD server returns ``OK`` on an empty request.""" pass 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/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index 3618f5e1..df13b4b4 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -11,28 +11,16 @@ def commands(context): Shows which commands the current user has access to. """ if context.dispatcher.authenticated: - command_names = [command.name for command in mpd_commands] + command_names = set([command.name for command in mpd_commands]) else: - command_names = [command.name for command in mpd_commands - if not command.auth_required] + command_names = set([command.name for command in mpd_commands + if not command.auth_required]) - # No permission to use - if 'kill' in command_names: - command_names.remove('kill') - - # Not shown by MPD in its command list - if 'command_list_begin' in command_names: - command_names.remove('command_list_begin') - if 'command_list_ok_begin' in command_names: - command_names.remove('command_list_ok_begin') - if 'command_list_end' in command_names: - command_names.remove('command_list_end') - if 'idle' in command_names: - command_names.remove('idle') - if 'noidle' in command_names: - command_names.remove('noidle') - if 'sticker' in command_names: - command_names.remove('sticker') + # No one is permited to use kill, rest of commands are not listed by MPD, + # so we shouldn't either. + command_names = command_names - set(['kill', 'command_list_begin', + 'command_list_ok_begin', 'command_list_ok_begin', 'command_list_end', + 'idle', 'noidle', 'sticker']) return [('command', command_name) for command_name in sorted(command_names)] diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index abbb8d7f..f32c46c8 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -1,8 +1,13 @@ 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', + 'player', 'playlist', 'stored_playlist', 'update', ] @handle_request(r'^clearerror$') def clearerror(context): @@ -28,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.+)$') @@ -67,12 +71,36 @@ def idle(context, subsystems=None): notifications when something changed in one of the specified subsystems. """ - pass # TODO + + if subsystems: + subsystems = subsystems.split() + else: + subsystems = SUBSYSTEMS + + for subsystem in subsystems: + context.subscriptions.add(subsystem) + + active = context.subscriptions.intersection(context.events) + if not active: + context.session.prevent_timeout = True + return + + response = [] + context.events = set() + context.subscriptions = set() + + for subsystem in active: + response.append(u'changed: %s' % subsystem) + return response @handle_request(r'^noidle$') def noidle(context): """See :meth:`_status_idle`.""" - pass # TODO + if not context.subscriptions: + return + context.subscriptions = set() + context.events = set() + context.session.prevent_timeout = False @handle_request(r'^stats$') def stats(context): @@ -125,15 +153,20 @@ def status(context): - ``nextsongid``: playlist songid of the next song to be played - ``time``: total time elapsed (of current playing/paused song) - ``elapsed``: Total time elapsed within the current song, but with - higher resolution. + higher resolution. - ``bitrate``: instantaneous bitrate in kbps - ``xfade``: crossfade in seconds - ``audio``: sampleRate``:bits``:channels - ``updatings_db``: job id - ``error``: if there is an error, returns message here + + *Clarifications based on experience implementing* + - ``volume``: can also be -1 if no output is set. + - ``elapsed``: Higher resolution means time in seconds with three + 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, @@ -180,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() @@ -214,11 +247,11 @@ def _status_state(futures): return u'pause' def _status_time(futures): - return u'%s:%s' % (_status_time_elapsed(futures) // 1000, + return u'%d:%d' % (futures['playback.time_position'].get() // 1000, _status_time_total(futures) // 1000) def _status_time_elapsed(futures): - return futures['playback.time_position'].get() + return u'%.3f' % (futures['playback.time_position'].get() / 1000.0) def _status_time_total(futures): current_cp_track = futures['playback.current_cp_track'].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/server.py b/mopidy/frontends/mpd/server.py deleted file mode 100644 index 62e443fb..00000000 --- a/mopidy/frontends/mpd/server.py +++ /dev/null @@ -1,38 +0,0 @@ -import asyncore -import logging -import sys - -from mopidy import settings -from mopidy.utils import network -from .session import MpdSession - -logger = logging.getLogger('mopidy.frontends.mpd.server') - -class MpdServer(asyncore.dispatcher): - """ - The MPD server. Creates a :class:`mopidy.frontends.mpd.session.MpdSession` - for each client connection. - """ - - def start(self): - """Start MPD server.""" - try: - self.set_socket(network.create_socket()) - self.set_reuse_addr() - hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME) - port = settings.MPD_SERVER_PORT - logger.debug(u'MPD server is binding to [%s]:%s', hostname, port) - self.bind((hostname, port)) - self.listen(1) - logger.info(u'MPD server running at [%s]:%s', hostname, port) - except IOError, e: - logger.error(u'MPD server startup failed: %s' % - str(e).decode('utf-8')) - sys.exit(1) - - def handle_accept(self): - """Called by asyncore when a new client connects.""" - (client_socket, client_socket_address) = self.accept() - logger.info(u'MPD client connection from [%s]:%s', - client_socket_address[0], client_socket_address[1]) - MpdSession(self, client_socket, client_socket_address) diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py deleted file mode 100644 index ce5d3be7..00000000 --- a/mopidy/frontends/mpd/session.py +++ /dev/null @@ -1,58 +0,0 @@ -import asynchat -import logging - -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.frontends.mpd.protocol import ENCODING, LINE_TERMINATOR, VERSION -from mopidy.utils.log import indent - -logger = logging.getLogger('mopidy.frontends.mpd.session') - -class MpdSession(asynchat.async_chat): - """ - The MPD client session. Keeps track of a single client session. Any - requests from the client is passed on to the MPD request dispatcher. - """ - - def __init__(self, server, client_socket, client_socket_address): - asynchat.async_chat.__init__(self, sock=client_socket) - self.server = server - self.client_address = client_socket_address[0] - self.client_port = client_socket_address[1] - self.input_buffer = [] - self.authenticated = False - self.set_terminator(LINE_TERMINATOR.encode(ENCODING)) - self.dispatcher = MpdDispatcher(session=self) - self.send_response([u'OK MPD %s' % VERSION]) - - def collect_incoming_data(self, data): - """Called by asynchat when new data arrives.""" - self.input_buffer.append(data) - - def found_terminator(self): - """Called by asynchat when a terminator is found in incoming data.""" - data = ''.join(self.input_buffer).strip() - self.input_buffer = [] - try: - self.send_response(self.handle_request(data)) - except UnicodeDecodeError as e: - logger.warning(u'Received invalid data: %s', e) - - def handle_request(self, request): - """Handle the request using the MPD command handlers.""" - request = request.decode(ENCODING) - logger.debug(u'Request from [%s]:%s: %s', self.client_address, - self.client_port, indent(request)) - return self.dispatcher.handle_request(request) - - def send_response(self, response): - """ - Format a response from the MPD command handlers and send it to the - client. - """ - if response: - response = LINE_TERMINATOR.join(response) - logger.debug(u'Response to [%s]:%s: %s', self.client_address, - self.client_port, indent(response)) - response = u'%s%s' % (response, LINE_TERMINATOR) - data = response.encode(ENCODING) - self.push(data) 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 new file mode 100644 index 00000000..0f5d35c5 --- /dev/null +++ b/mopidy/frontends/mpris/__init__.py @@ -0,0 +1,131 @@ +import logging + +logger = logging.getLogger('mopidy.frontends.mpris') + +try: + import indicate +except ImportError as import_error: + indicate = None + logger.debug(u'Startup notification will not be sent (%s)', import_error) + +from pykka.actor import ThreadingActor + +from mopidy import settings +from mopidy.frontends.mpris import objects +from mopidy.listeners import BackendListener + + +class MprisFrontend(ThreadingActor, BackendListener): + """ + Frontend which lets you control Mopidy through the Media Player Remote + Interfacing Specification (`MPRIS `_) D-Bus + interface. + + An example of an MPRIS client is the `Ubuntu Sound Menu + `_. + + **Dependencies:** + + - D-Bus Python bindings. The package is named ``python-dbus`` in + Ubuntu/Debian. + - ``libindicate`` Python bindings is needed to expose Mopidy in e.g. the + Ubuntu Sound Menu. The package is named ``python-indicate`` in + Ubuntu/Debian. + - An ``.desktop`` file for Mopidy installed at the path set in + :attr:`mopidy.settings.DESKTOP_FILE`. See :ref:`install_desktop_file` for + details. + + **Testing the frontend** + + To test, start Mopidy, and then run the following in a Python shell:: + + import dbus + bus = dbus.SessionBus() + player = bus.get_object('org.mpris.MediaPlayer2.mopidy', + '/org/mpris/MediaPlayer2') + + Now you can control Mopidy through the player object. Examples: + + - To get some properties from Mopidy, run:: + + props = player.GetAll('org.mpris.MediaPlayer2', + dbus_interface='org.freedesktop.DBus.Properties') + + - To quit Mopidy through D-Bus, run:: + + player.Quit(dbus_interface='org.mpris.MediaPlayer2') + """ + + def __init__(self): + super(MprisFrontend, self).__init__() + self.indicate_server = None + self.mpris_object = None + + def on_start(self): + try: + self.mpris_object = objects.MprisObject() + self._send_startup_notification() + except Exception as e: + logger.error(u'MPRIS frontend setup failed (%s)', e) + self.stop() + + def on_stop(self): + logger.debug(u'Removing MPRIS object from D-Bus connection...') + if self.mpris_object: + self.mpris_object.remove_from_connection() + self.mpris_object = None + logger.debug(u'Removed MPRIS object from D-Bus connection') + + def _send_startup_notification(self): + """ + Send startup notification using libindicate to make Mopidy appear in + e.g. `Ubuntu's sound menu `_. + + A reference to the libindicate server is kept for as long as Mopidy is + running. When Mopidy exits, the server will be unreferenced and Mopidy + will automatically be unregistered from e.g. the sound menu. + """ + if not indicate: + return + logger.debug(u'Sending startup notification...') + self.indicate_server = indicate.Server() + self.indicate_server.set_type('music.mopidy') + self.indicate_server.set_desktop_file(settings.DESKTOP_FILE) + self.indicate_server.show() + logger.debug(u'Startup notification sent') + + def _emit_properties_changed(self, *changed_properties): + if self.mpris_object is None: + return + props_with_new_values = [ + (p, self.mpris_object.Get(objects.PLAYER_IFACE, p)) + for p in changed_properties] + self.mpris_object.PropertiesChanged(objects.PLAYER_IFACE, + dict(props_with_new_values), []) + + def track_playback_paused(self, track, time_position): + logger.debug(u'Received track playback paused event') + self._emit_properties_changed('PlaybackStatus') + + def track_playback_resumed(self, track, time_position): + logger.debug(u'Received track playback resumed event') + self._emit_properties_changed('PlaybackStatus') + + def track_playback_started(self, track): + logger.debug(u'Received track playback started event') + self._emit_properties_changed('PlaybackStatus', 'Metadata') + + def track_playback_ended(self, track, time_position): + logger.debug(u'Received track playback ended event') + self._emit_properties_changed('PlaybackStatus', 'Metadata') + + def volume_changed(self): + logger.debug(u'Received volume changed event') + self._emit_properties_changed('Volume') + + def seeked(self): + logger.debug(u'Received seeked event') + if self.mpris_object is None: + return + self.mpris_object.Seeked( + self.mpris_object.Get(objects.PLAYER_IFACE, 'Position')) diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py new file mode 100644 index 00000000..9ed1fe2c --- /dev/null +++ b/mopidy/frontends/mpris/objects.py @@ -0,0 +1,437 @@ +import logging +import os + +logger = logging.getLogger('mopidy.frontends.mpris') + +try: + import dbus + import dbus.mainloop.glib + import dbus.service + import gobject +except ImportError as import_error: + from mopidy import OptionalDependencyError + raise OptionalDependencyError(import_error) + +from pykka.registry import ActorRegistry + +from mopidy import settings +from mopidy.backends.base import Backend +from mopidy.backends.base.playback import PlaybackController +from mopidy.mixers.base import BaseMixer +from mopidy.utils.process import exit_process + +# Must be done before dbus.SessionBus() is called +gobject.threads_init() +dbus.mainloop.glib.threads_init() + +BUS_NAME = 'org.mpris.MediaPlayer2.mopidy' +OBJECT_PATH = '/org/mpris/MediaPlayer2' +ROOT_IFACE = 'org.mpris.MediaPlayer2' +PLAYER_IFACE = 'org.mpris.MediaPlayer2.Player' + + +class MprisObject(dbus.service.Object): + """Implements http://www.mpris.org/2.1/spec/""" + + properties = None + + def __init__(self): + self._backend = None + self._mixer = None + self.properties = { + ROOT_IFACE: self._get_root_iface_properties(), + PLAYER_IFACE: self._get_player_iface_properties(), + } + bus_name = self._connect_to_dbus() + super(MprisObject, self).__init__(bus_name, OBJECT_PATH) + + def _get_root_iface_properties(self): + return { + 'CanQuit': (True, None), + 'CanRaise': (False, None), + # NOTE Change if adding optional track list support + 'HasTrackList': (False, None), + 'Identity': ('Mopidy', None), + 'DesktopEntry': (self.get_DesktopEntry, None), + 'SupportedUriSchemes': (self.get_SupportedUriSchemes, None), + # NOTE Return MIME types supported by local backend if support for + # reporting supported MIME types is added + 'SupportedMimeTypes': (dbus.Array([], signature='s'), None), + } + + def _get_player_iface_properties(self): + return { + 'PlaybackStatus': (self.get_PlaybackStatus, None), + 'LoopStatus': (self.get_LoopStatus, self.set_LoopStatus), + 'Rate': (1.0, self.set_Rate), + 'Shuffle': (self.get_Shuffle, self.set_Shuffle), + 'Metadata': (self.get_Metadata, None), + 'Volume': (self.get_Volume, self.set_Volume), + 'Position': (self.get_Position, None), + 'MinimumRate': (1.0, None), + 'MaximumRate': (1.0, None), + 'CanGoNext': (self.get_CanGoNext, None), + 'CanGoPrevious': (self.get_CanGoPrevious, None), + 'CanPlay': (self.get_CanPlay, None), + 'CanPause': (self.get_CanPause, None), + 'CanSeek': (self.get_CanSeek, None), + 'CanControl': (self.get_CanControl, None), + } + + def _connect_to_dbus(self): + logger.debug(u'Connecting to D-Bus...') + mainloop = dbus.mainloop.glib.DBusGMainLoop() + bus_name = dbus.service.BusName(BUS_NAME, + dbus.SessionBus(mainloop=mainloop)) + logger.info(u'Connected to D-Bus') + return bus_name + + @property + def backend(self): + if self._backend is None: + backend_refs = ActorRegistry.get_by_class(Backend) + assert len(backend_refs) == 1, \ + 'Expected exactly one running backend.' + self._backend = backend_refs[0].proxy() + return self._backend + + @property + def mixer(self): + 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 + + def _get_track_id(self, cp_track): + return '/com/mopidy/track/%d' % cp_track.cpid + + def _get_cpid(self, track_id): + assert track_id.startswith('/com/mopidy/track/') + return track_id.split('/')[-1] + + ### Properties interface + + @dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE, + in_signature='ss', out_signature='v') + def Get(self, interface, prop): + logger.debug(u'%s.Get(%s, %s) called', + dbus.PROPERTIES_IFACE, repr(interface), repr(prop)) + (getter, setter) = self.properties[interface][prop] + if callable(getter): + return getter() + else: + return getter + + @dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE, + in_signature='s', out_signature='a{sv}') + def GetAll(self, interface): + logger.debug(u'%s.GetAll(%s) called', + dbus.PROPERTIES_IFACE, repr(interface)) + getters = {} + for key, (getter, setter) in self.properties[interface].iteritems(): + getters[key] = getter() if callable(getter) else getter + return getters + + @dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE, + in_signature='ssv', out_signature='') + def Set(self, interface, prop, value): + logger.debug(u'%s.Set(%s, %s, %s) called', + dbus.PROPERTIES_IFACE, repr(interface), repr(prop), repr(value)) + getter, setter = self.properties[interface][prop] + if setter is not None: + setter(value) + self.PropertiesChanged(interface, + {prop: self.Get(interface, prop)}, []) + + @dbus.service.signal(dbus_interface=dbus.PROPERTIES_IFACE, + signature='sa{sv}as') + def PropertiesChanged(self, interface, changed_properties, + invalidated_properties): + logger.debug(u'%s.PropertiesChanged(%s, %s, %s) signaled', + dbus.PROPERTIES_IFACE, interface, changed_properties, + invalidated_properties) + + + ### Root interface methods + + @dbus.service.method(dbus_interface=ROOT_IFACE) + def Raise(self): + logger.debug(u'%s.Raise called', ROOT_IFACE) + # Do nothing, as we do not have a GUI + + @dbus.service.method(dbus_interface=ROOT_IFACE) + def Quit(self): + logger.debug(u'%s.Quit called', ROOT_IFACE) + exit_process() + + + ### Root interface properties + + def get_DesktopEntry(self): + return os.path.splitext(os.path.basename(settings.DESKTOP_FILE))[0] + + def get_SupportedUriSchemes(self): + return dbus.Array(self.backend.uri_schemes.get(), signature='s') + + + ### Player interface methods + + @dbus.service.method(dbus_interface=PLAYER_IFACE) + def Next(self): + logger.debug(u'%s.Next called', PLAYER_IFACE) + if not self.get_CanGoNext(): + logger.debug(u'%s.Next not allowed', PLAYER_IFACE) + return + self.backend.playback.next().get() + + @dbus.service.method(dbus_interface=PLAYER_IFACE) + def Previous(self): + logger.debug(u'%s.Previous called', PLAYER_IFACE) + if not self.get_CanGoPrevious(): + logger.debug(u'%s.Previous not allowed', PLAYER_IFACE) + return + self.backend.playback.previous().get() + + @dbus.service.method(dbus_interface=PLAYER_IFACE) + def Pause(self): + logger.debug(u'%s.Pause called', PLAYER_IFACE) + if not self.get_CanPause(): + logger.debug(u'%s.Pause not allowed', PLAYER_IFACE) + return + self.backend.playback.pause().get() + + @dbus.service.method(dbus_interface=PLAYER_IFACE) + def PlayPause(self): + logger.debug(u'%s.PlayPause called', PLAYER_IFACE) + if not self.get_CanPause(): + logger.debug(u'%s.PlayPause not allowed', PLAYER_IFACE) + return + state = self.backend.playback.state.get() + if state == PlaybackController.PLAYING: + self.backend.playback.pause().get() + elif state == PlaybackController.PAUSED: + self.backend.playback.resume().get() + elif state == PlaybackController.STOPPED: + self.backend.playback.play().get() + + @dbus.service.method(dbus_interface=PLAYER_IFACE) + def Stop(self): + logger.debug(u'%s.Stop called', PLAYER_IFACE) + if not self.get_CanControl(): + logger.debug(u'%s.Stop not allowed', PLAYER_IFACE) + return + self.backend.playback.stop().get() + + @dbus.service.method(dbus_interface=PLAYER_IFACE) + def Play(self): + logger.debug(u'%s.Play called', PLAYER_IFACE) + if not self.get_CanPlay(): + logger.debug(u'%s.Play not allowed', PLAYER_IFACE) + return + state = self.backend.playback.state.get() + if state == PlaybackController.PAUSED: + self.backend.playback.resume().get() + else: + self.backend.playback.play().get() + + @dbus.service.method(dbus_interface=PLAYER_IFACE) + def Seek(self, offset): + logger.debug(u'%s.Seek called', PLAYER_IFACE) + if not self.get_CanSeek(): + logger.debug(u'%s.Seek not allowed', PLAYER_IFACE) + return + offset_in_milliseconds = offset // 1000 + current_position = self.backend.playback.time_position.get() + new_position = current_position + offset_in_milliseconds + self.backend.playback.seek(new_position) + + @dbus.service.method(dbus_interface=PLAYER_IFACE) + def SetPosition(self, track_id, position): + logger.debug(u'%s.SetPosition called', PLAYER_IFACE) + if not self.get_CanSeek(): + logger.debug(u'%s.SetPosition not allowed', PLAYER_IFACE) + return + position = position // 1000 + current_cp_track = self.backend.playback.current_cp_track.get() + if current_cp_track is None: + return + if track_id != self._get_track_id(current_cp_track): + return + if position < 0: + return + if current_cp_track.track.length < position: + return + self.backend.playback.seek(position) + + @dbus.service.method(dbus_interface=PLAYER_IFACE) + def OpenUri(self, uri): + logger.debug(u'%s.OpenUri called', PLAYER_IFACE) + if not self.get_CanPlay(): + # NOTE The spec does not explictly require this check, but guarding + # the other methods doesn't help much if OpenUri is open for use. + logger.debug(u'%s.Play not allowed', PLAYER_IFACE) + return + # NOTE Check if URI has MIME type known to the backend, if MIME support + # is added to the backend. + uri_schemes = self.backend.uri_schemes.get() + if not any([uri.startswith(uri_scheme) for uri_scheme in uri_schemes]): + return + track = self.backend.library.lookup(uri).get() + if track is not None: + cp_track = self.backend.current_playlist.add(track).get() + self.backend.playback.play(cp_track) + else: + logger.debug(u'Track with URI "%s" not found in library.', uri) + + + ### Player interface signals + + @dbus.service.signal(dbus_interface=PLAYER_IFACE, signature='x') + def Seeked(self, position): + logger.debug(u'%s.Seeked signaled', PLAYER_IFACE) + # Do nothing, as just calling the method is enough to emit the signal. + + + ### Player interface properties + + def get_PlaybackStatus(self): + state = self.backend.playback.state.get() + if state == PlaybackController.PLAYING: + return 'Playing' + elif state == PlaybackController.PAUSED: + return 'Paused' + elif state == PlaybackController.STOPPED: + return 'Stopped' + + def get_LoopStatus(self): + repeat = self.backend.playback.repeat.get() + single = self.backend.playback.single.get() + if not repeat: + return 'None' + else: + if single: + return 'Track' + else: + return 'Playlist' + + def set_LoopStatus(self, value): + if not self.get_CanControl(): + logger.debug(u'Setting %s.LoopStatus not allowed', PLAYER_IFACE) + return + if value == 'None': + self.backend.playback.repeat = False + self.backend.playback.single = False + elif value == 'Track': + self.backend.playback.repeat = True + self.backend.playback.single = True + elif value == 'Playlist': + self.backend.playback.repeat = True + self.backend.playback.single = False + + def set_Rate(self, value): + if not self.get_CanControl(): + # NOTE The spec does not explictly require this check, but it was + # added to be consistent with all the other property setters. + logger.debug(u'Setting %s.Rate not allowed', PLAYER_IFACE) + return + if value == 0: + self.Pause() + + def get_Shuffle(self): + return self.backend.playback.random.get() + + def set_Shuffle(self, value): + if not self.get_CanControl(): + logger.debug(u'Setting %s.Shuffle not allowed', PLAYER_IFACE) + return + if value: + self.backend.playback.random = True + else: + self.backend.playback.random = False + + def get_Metadata(self): + current_cp_track = self.backend.playback.current_cp_track.get() + if current_cp_track is None: + return {'mpris:trackid': ''} + else: + (cpid, track) = current_cp_track + metadata = {'mpris:trackid': self._get_track_id(current_cp_track)} + if track.length: + metadata['mpris:length'] = track.length * 1000 + if track.uri: + metadata['xesam:url'] = track.uri + if track.name: + metadata['xesam:title'] = track.name + if track.artists: + artists = list(track.artists) + artists.sort(key=lambda a: a.name) + metadata['xesam:artist'] = dbus.Array( + [a.name for a in artists if a.name], signature='s') + if track.album and track.album.name: + metadata['xesam:album'] = track.album.name + if track.album and track.album.artists: + artists = list(track.album.artists) + artists.sort(key=lambda a: a.name) + metadata['xesam:albumArtist'] = dbus.Array( + [a.name for a in artists if a.name], signature='s') + if track.track_no: + metadata['xesam:trackNumber'] = track.track_no + return dbus.Dictionary(metadata, signature='sv') + + def get_Volume(self): + volume = self.mixer.volume.get() + if volume is not None: + return volume / 100.0 + + def set_Volume(self, value): + if not self.get_CanControl(): + logger.debug(u'Setting %s.Volume not allowed', PLAYER_IFACE) + return + if value is None: + return + elif value < 0: + self.mixer.volume = 0 + elif value > 1: + self.mixer.volume = 100 + elif 0 <= value <= 1: + self.mixer.volume = int(value * 100) + + def get_Position(self): + return self.backend.playback.time_position.get() * 1000 + + def get_CanGoNext(self): + if not self.get_CanControl(): + return False + return (self.backend.playback.cp_track_at_next.get() != + self.backend.playback.current_cp_track.get()) + + def get_CanGoPrevious(self): + if not self.get_CanControl(): + return False + return (self.backend.playback.cp_track_at_previous.get() != + self.backend.playback.current_cp_track.get()) + + def get_CanPlay(self): + if not self.get_CanControl(): + return False + return (self.backend.playback.current_track.get() is not None + or self.backend.playback.track_at_next.get() is not None) + + def get_CanPause(self): + if not self.get_CanControl(): + return False + # NOTE Should be changed to vary based on capabilities of the current + # track if Mopidy starts supporting non-seekable media, like streams. + return True + + def get_CanSeek(self): + if not self.get_CanControl(): + return False + # NOTE Should be changed to vary based on capabilities of the current + # track if Mopidy starts supporting non-seekable media, like streams. + return True + + def get_CanControl(self): + # NOTE This could be a setting for the end user to change. + return True diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index b43089e0..8781a4b2 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -13,15 +13,6 @@ 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): """ @@ -34,6 +25,15 @@ class GStreamer(ThreadingActor): """ 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._uridecodebin = None @@ -42,9 +42,6 @@ class GStreamer(ThreadingActor): self._handlers = {} def on_start(self): - # **Warning:** :class:`GStreamer` requires - # :class:`mopidy.utils.process.GObjectEventThread` to be running. This - # is not enforced by :class:`GStreamer` itself. self._setup_pipeline() self._setup_outputs() self._setup_message_processor() @@ -78,12 +75,14 @@ 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): @@ -300,5 +299,3 @@ class GStreamer(ThreadingActor): output.sync_state_with_parent() # Required to add to running pipe gst.element_link_many(self._volume, output) logger.debug('Output set to %s', output.get_name()) - - # FIXME re-add disconnect / swap output code? diff --git a/mopidy/listeners.py b/mopidy/listeners.py index dfc5c60b..ee360bf3 100644 --- a/mopidy/listeners.py +++ b/mopidy/listeners.py @@ -1,3 +1,5 @@ +from pykka import registry + class BackendListener(object): """ Marker interface for recipients of events sent by the backend. @@ -9,7 +11,46 @@ class BackendListener(object): interested in all events. """ - def started_playing(self, track): + @staticmethod + def send(event, **kwargs): + """Helper to allow calling of backend listener events""" + # FIXME this should be updated once Pykka supports non-blocking calls + # on proxies or some similar solution. + registry.ActorRegistry.broadcast({ + 'command': 'pykka_call', + 'attr_path': (event,), + 'args': [], + 'kwargs': kwargs, + }, target_class=BackendListener) + + def track_playback_paused(self, track, time_position): + """ + Called whenever track playback is paused. + + *MAY* be implemented by actor. + + :param track: the track that was playing when playback paused + :type track: :class:`mopidy.models.Track` + :param time_position: the time position in milliseconds + :type time_position: int + """ + pass + + def track_playback_resumed(self, track, time_position): + """ + Called whenever track playback is resumed. + + *MAY* be implemented by actor. + + :param track: the track that was playing when playback resumed + :type track: :class:`mopidy.models.Track` + :param time_position: the time position in milliseconds + :type time_position: int + """ + pass + + + def track_playback_started(self, track): """ Called whenever a new track starts playing. @@ -20,9 +61,9 @@ class BackendListener(object): """ pass - def stopped_playing(self, track, time_position): + def track_playback_ended(self, track, time_position): """ - Called whenever playback is stopped. + Called whenever playback of a track ends. *MAY* be implemented by actor. @@ -32,3 +73,44 @@ class BackendListener(object): :type time_position: int """ pass + + def playback_state_changed(self): + """ + Called whenever playback state is changed. + + *MAY* be implemented by actor. + """ + pass + + def playlist_changed(self): + """ + Called whenever a playlist is changed. + + *MAY* be implemented by actor. + """ + pass + + def options_changed(self): + """ + Called whenever an option is changed. + + *MAY* be implemented by actor. + """ + pass + + def volume_changed(self): + """ + Called whenever the volume is changed. + + *MAY* be implemented by actor. + """ + pass + + def seeked(self): + """ + Called whenever the time position changes by an unexpected amount, e.g. + at seek to a new time position. + + *MAY* be implemented by actor. + """ + pass 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 ec3d8ae5..82783be1 100644 --- a/mopidy/mixers/base.py +++ b/mopidy/mixers/base.py @@ -1,4 +1,8 @@ -from mopidy import settings +import logging + +from mopidy import listeners, settings + +logger = logging.getLogger('mopidy.mixers') class BaseMixer(object): """ @@ -17,19 +21,31 @@ 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): """ @@ -46,3 +62,7 @@ class BaseMixer(object): *MUST be implemented by subclass.* """ raise NotImplementedError + + def _trigger_volume_changed(self): + logger.debug(u'Triggering volume changed event') + listeners.BackendListener.send('volume_changed') 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/settings.py b/mopidy/settings.py index 392c9ad7..a47b389d 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -4,7 +4,7 @@ Available settings and their default values. .. warning:: Do *not* change settings directly in :mod:`mopidy.settings`. Instead, add a - file called ``~/.mopidy/settings.py`` and redefine settings there. + file called ``~/.config/mopidy/settings.py`` and redefine settings there. """ #: List of playback backends to use. See :mod:`mopidy.backends` for all @@ -49,6 +49,15 @@ DEBUG_LOG_FORMAT = u'%(levelname)-8s %(asctime)s' + \ #: DEBUG_LOG_FILENAME = u'mopidy.log' DEBUG_LOG_FILENAME = u'mopidy.log' +#: Location of the Mopidy .desktop file. +#: +#: Used by :mod:`mopidy.frontends.mpris`. +#: +#: Default:: +#: +#: DESKTOP_FILE = u'/usr/share/applications/mopidy.desktop' +DESKTOP_FILE = u'/usr/share/applications/mopidy.desktop' + #: List of server frontends to use. #: #: Default:: @@ -56,10 +65,12 @@ DEBUG_LOG_FILENAME = u'mopidy.log' #: FRONTENDS = ( #: u'mopidy.frontends.mpd.MpdFrontend', #: u'mopidy.frontends.lastfm.LastfmFrontend', +#: u'mopidy.frontends.mpris.MprisFrontend', #: ) FRONTENDS = ( u'mopidy.frontends.mpd.MpdFrontend', u'mopidy.frontends.lastfm.LastfmFrontend', + u'mopidy.frontends.mpris.MprisFrontend', ) #: Your `Last.fm `_ username. @@ -78,8 +89,9 @@ LASTFM_PASSWORD = u'' #: #: Default:: #: -#: LOCAL_MUSIC_PATH = u'~/music' -LOCAL_MUSIC_PATH = u'~/music' +#: # Defaults to asking glib where music is stored, fallback is ~/music +#: LOCAL_MUSIC_PATH = None +LOCAL_MUSIC_PATH = None #: Path to playlist folder with m3u files for local music. #: @@ -87,8 +99,8 @@ LOCAL_MUSIC_PATH = u'~/music' #: #: Default:: #: -#: LOCAL_PLAYLIST_PATH = u'~/.mopidy/playlists' -LOCAL_PLAYLIST_PATH = u'~/.mopidy/playlists' +#: LOCAL_PLAYLIST_PATH = None # Implies $XDG_DATA_DIR/mopidy/playlists +LOCAL_PLAYLIST_PATH = None #: Path to tag cache for local music. #: @@ -96,8 +108,8 @@ LOCAL_PLAYLIST_PATH = u'~/.mopidy/playlists' #: #: Default:: #: -#: LOCAL_TAG_CACHE_FILE = u'~/.mopidy/tag_cache' -LOCAL_TAG_CACHE_FILE = u'~/.mopidy/tag_cache' +#: LOCAL_TAG_CACHE_FILE = None # Implies $XDG_DATA_DIR/mopidy/tag_cache +LOCAL_TAG_CACHE_FILE = None #: Sound mixer to use. See :mod:`mopidy.mixers` for all available mixers. #: @@ -168,6 +180,11 @@ MPD_SERVER_PORT = 6600 #: Default: :class:`None`, which means no password required. MPD_SERVER_PASSWORD = None +#: The maximum number of concurrent connections the MPD server will accept. +#: +#: Default: 20 +MPD_SERVER_MAX_CONNECTIONS = 20 + #: List of outputs to use. See :mod:`mopidy.outputs` for all available #: backends #: @@ -233,7 +250,7 @@ SHOUTCAST_OUTPUT_ENCODER = u'lame mode=stereo bitrate=320' #: Path to the Spotify cache. #: #: Used by :mod:`mopidy.backends.spotify`. -SPOTIFY_CACHE_PATH = u'~/.mopidy/spotify_cache' +SPOTIFY_CACHE_PATH = None #: Your Spotify Premium username. #: diff --git a/mopidy/utils/__init__.py b/mopidy/utils/__init__.py index 9d7532a0..00129cdd 100644 --- a/mopidy/utils/__init__.py +++ b/mopidy/utils/__init__.py @@ -1,3 +1,4 @@ +import locale import logging import os import sys @@ -29,3 +30,9 @@ def get_class(name): except (ImportError, AttributeError): raise ImportError("Couldn't load: %s" % name) return class_object + +def locale_decode(bytestr): + try: + return unicode(bytestr) + except UnicodeError: + return str(bytestr).decode(locale.getpreferredencoding()) diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 03b85b48..0e5dfc29 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -20,7 +20,7 @@ def setup_console_logging(verbosity_level): if verbosity_level == 0: log_level = logging.WARNING log_format = settings.CONSOLE_LOG_FORMAT - elif verbosity_level == 2: + elif verbosity_level >= 2: log_level = logging.DEBUG log_format = settings.DEBUG_LOG_FORMAT else: @@ -33,6 +33,9 @@ def setup_console_logging(verbosity_level): root = logging.getLogger('') root.addHandler(handler) + if verbosity_level < 3: + logging.getLogger('pykka').setLevel(logging.INFO) + def setup_debug_logging_to_file(): formatter = logging.Formatter(settings.DEBUG_LOG_FORMAT) handler = logging.handlers.RotatingFileHandler( diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 1dedf7d7..4b8a9ac9 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -1,23 +1,35 @@ +import errno +import gobject import logging import re import socket +import threading + +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') -def _try_ipv6_socket(): +class ShouldRetrySocketCall(Exception): + """Indicate that attempted socket call should be retried""" + +def try_ipv6_socket(): """Determine if system really supports IPv6""" if not socket.has_ipv6: return False 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. -has_ipv6 = _try_ipv6_socket() +has_ipv6 = try_ipv6_socket() def create_socket(): """Create a TCP socket with or without IPv6 depending on system support""" @@ -27,6 +39,7 @@ def create_socket(): sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) else: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) return sock def format_hostname(hostname): @@ -34,3 +47,351 @@ def format_hostname(hostname): if (has_ipv6 and re.match('\d+.\d+.\d+.\d+', hostname) is not None): hostname = '::ffff:%s' % hostname return hostname + +class Server(object): + """Setup listener and register it with gobject's event loop.""" + + def __init__(self, host, port, protocol, max_connections=5, timeout=30): + self.protocol = protocol + self.max_connections = max_connections + self.timeout = timeout + self.server_socket = self.create_server_socket(host, port) + + self.register_server_socket(self.server_socket.fileno()) + + def create_server_socket(self, host, port): + sock = create_socket() + sock.setblocking(False) + sock.bind((host, port)) + sock.listen(1) + return sock + + def register_server_socket(self, fileno): + gobject.io_add_watch(fileno, gobject.IO_IN, self.handle_connection) + + def handle_connection(self, fd, flags): + try: + sock, addr = self.accept_connection() + except ShouldRetrySocketCall: + return True + + if self.maximum_connections_exceeded(): + self.reject_connection(sock, addr) + else: + self.init_connection(sock, addr) + return True + + def accept_connection(self): + try: + return self.server_socket.accept() + except socket.error as e: + if e.errno in (errno.EAGAIN, errno.EINTR): + raise ShouldRetrySocketCall + raise + + def maximum_connections_exceeded(self): + return (self.max_connections is not None and + self.number_of_connections() >= self.max_connections) + + def number_of_connections(self): + return len(ActorRegistry.get_by_class(self.protocol)) + + def reject_connection(self, sock, addr): + # FIXME provide more context in logging? + logger.warning(u'Rejected connection from [%s]:%s', addr[0], addr[1]) + try: + sock.close() + except socket.error: + pass + + def init_connection(self, sock, addr): + Connection(self.protocol, sock, addr, self.timeout) + + +class Connection(object): + # NOTE: the callback code is _not_ run in the actor's thread, but in the + # same one as the event loop. If code in the callbacks blocks, the rest of + # gobject code will likely be blocked as well... + # + # Also note that source_remove() return values are ignored on purpose, a + # false return value would only tell us that what we thought was registered + # is already gone, there is really nothing more we can do. + + def __init__(self, protocol, sock, addr, timeout): + sock.setblocking(False) + + self.host, self.port = addr[:2] # IPv6 has larger addr + + self.sock = sock + self.protocol = protocol + self.timeout = timeout + + self.send_lock = threading.Lock() + self.send_buffer = '' + + self.stopping = False + + self.recv_id = None + self.send_id = None + self.timeout_id = None + + self.actor_ref = self.protocol.start(self) + + self.enable_recv() + self.enable_timeout() + + def stop(self, reason, level=logging.DEBUG): + if self.stopping: + logger.log(level, 'Already stopping: %s' % reason) + return + else: + self.stopping = True + + logger.log(level, reason) + + try: + self.actor_ref.stop() + except ActorDeadError: + pass + + self.disable_timeout() + self.disable_recv() + self.disable_send() + + try: + self.sock.close() + except socket.error: + pass + + def queue_send(self, data): + """Try to send data to client exactly as is and queue rest.""" + self.send_lock.acquire(True) + self.send_buffer = self.send(self.send_buffer + data) + self.send_lock.release() + if self.send_buffer: + self.enable_send() + + def send(self, data): + """Send data to client, return any unsent data.""" + try: + sent = self.sock.send(data) + return data[sent:] + except socket.error as e: + if e.errno in (errno.EWOULDBLOCK, errno.EINTR): + return data + self.stop(u'Unexpected client error: %s' % e) + return '' + + def enable_timeout(self): + """Reactivate timeout mechanism.""" + if self.timeout <= 0: + return + + self.disable_timeout() + self.timeout_id = gobject.timeout_add_seconds( + self.timeout, self.timeout_callback) + + def disable_timeout(self): + """Deactivate timeout mechanism.""" + if self.timeout_id is None: + return + gobject.source_remove(self.timeout_id) + self.timeout_id = None + + def enable_recv(self): + if self.recv_id is not None: + return + + try: + self.recv_id = gobject.io_add_watch(self.sock.fileno(), + gobject.IO_IN | gobject.IO_ERR | gobject.IO_HUP, + self.recv_callback) + except socket.error as e: + self.stop(u'Problem with connection: %s' % e) + + def disable_recv(self): + if self.recv_id is None: + return + gobject.source_remove(self.recv_id) + self.recv_id = None + + def enable_send(self): + if self.send_id is not None: + return + + try: + self.send_id = gobject.io_add_watch(self.sock.fileno(), + gobject.IO_OUT | gobject.IO_ERR | gobject.IO_HUP, + self.send_callback) + except socket.error as e: + self.stop(u'Problem with connection: %s' % e) + + def disable_send(self): + if self.send_id is None: + return + + gobject.source_remove(self.send_id) + self.send_id = None + + def recv_callback(self, fd, flags): + if flags & (gobject.IO_ERR | gobject.IO_HUP): + self.stop(u'Bad client flags: %s' % flags) + return True + + try: + data = self.sock.recv(4096) + except socket.error as e: + if e.errno not in (errno.EWOULDBLOCK, errno.EINTR): + self.stop(u'Unexpected client error: %s' % e) + return True + + if not data: + self.stop(u'Client most likely disconnected.') + return True + + try: + self.actor_ref.send_one_way({'received': data}) + except ActorDeadError: + self.stop(u'Actor is dead.') + + return True + + def send_callback(self, fd, flags): + if flags & (gobject.IO_ERR | gobject.IO_HUP): + self.stop(u'Bad client flags: %s' % flags) + return True + + # If with can't get the lock, simply try again next time socket is + # ready for sending. + if not self.send_lock.acquire(False): + return True + + try: + self.send_buffer = self.send(self.send_buffer) + if not self.send_buffer: + self.disable_send() + finally: + self.send_lock.release() + + return True + + def timeout_callback(self): + self.stop(u'Client timeout out after %s seconds' % self.timeout) + return False + + +class LineProtocol(ThreadingActor): + """ + Base class for handling line based protocols. + + Takes care of receiving new data from server's client code, decoding and + then splitting data along line boundaries. + """ + + #: Line terminator to use for outputed lines. + terminator = '\n' + + #: Regex to use for spliting lines, will be set compiled version of its + #: own value, or to ``terminator``s value if it is not set itself. + delimeter = None + + #: What encoding to expect incomming data to be in, can be :class:`None`. + encoding = 'utf-8' + + def __init__(self, connection): + super(LineProtocol, self).__init__() + self.connection = connection + self.prevent_timeout = False + self.recv_buffer = '' + + if self.delimeter: + self.delimeter = re.compile(self.delimeter) + else: + self.delimeter = re.compile(self.terminator) + + @property + def host(self): + return self.connection.host + + @property + def port(self): + return self.connection.port + + def on_line_received(self, line): + """ + Called whenever a new line is found. + + Should be implemented by subclasses. + """ + raise NotImplementedError + + def on_receive(self, message): + """Handle messages with new data from server.""" + if 'received' not in message: + return + + self.connection.disable_timeout() + self.recv_buffer += message['received'] + + for line in self.parse_lines(): + line = self.decode(line) + if line is not None: + self.on_line_received(line) + + if not self.prevent_timeout: + self.connection.enable_timeout() + + def on_stop(self): + """Ensure that cleanup when actor stops.""" + self.connection.stop(u'Actor is shutting down.') + + def parse_lines(self): + """Consume new data and yield any lines found.""" + while re.search(self.terminator, self.recv_buffer): + line, self.recv_buffer = self.delimeter.split( + self.recv_buffer, 1) + yield line + + def encode(self, line): + """ + Handle encoding of line. + + Can be overridden by subclasses to change encoding behaviour. + """ + try: + return line.encode(self.encoding) + except UnicodeError: + logger.warning(u'Stopping actor due to encode problem, data ' + 'supplied by client was not valid %s', self.encoding) + self.stop() + + def decode(self, line): + """ + Handle decoding of line. + + Can be overridden by subclasses to change decoding behaviour. + """ + try: + return line.decode(self.encoding) + except UnicodeError: + logger.warning(u'Stopping actor due to decode problem, data ' + 'supplied by client was not valid %s', self.encoding) + self.stop() + + def join_lines(self, lines): + if not lines: + return u'' + return self.terminator.join(lines) + self.terminator + + def send_lines(self, lines): + """ + Send array of lines to client via connection. + + Join lines using the terminator that is set for this class, encode it + and send it to the client. + """ + if not lines: + return + + data = self.join_lines(lines) + self.connection.queue_send(self.encode(data)) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 540cb4fa..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): @@ -60,6 +63,7 @@ def find_files(path): yield filename # pylint: enable = W0612 +# FIXME replace with mock usage in tests. class Mtime(object): def __init__(self): self.fake = None diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 758f8943..80d850fe 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -3,9 +3,6 @@ import signal import thread import threading -import gobject -gobject.threads_init() - from pykka import ActorDeadError from pykka.registry import ActorRegistry @@ -68,25 +65,3 @@ class BaseThread(threading.Thread): def run_inside_try(self): raise NotImplementedError - - -class GObjectEventThread(BaseThread): - """ - A GObject event loop which is shared by all Mopidy components that uses - libraries that need a GObject event loop, like GStreamer and D-Bus. - - Should be started by Mopidy's core and used by - :mod:`mopidy.output.gstreamer`, :mod:`mopidy.frontend.mpris`, etc. - """ - - def __init__(self): - super(GObjectEventThread, self).__init__() - self.name = u'GObjectEventThread' - self.loop = None - - def run_inside_try(self): - self.loop = gobject.MainLoop().run() - - def destroy(self): - self.loop.quit() - super(GObjectEventThread, self).destroy() diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 3f7593af..ff449a61 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -7,7 +7,7 @@ import os from pprint import pformat import sys -from mopidy import SettingsError +from mopidy import SettingsError, SETTINGS_PATH, SETTINGS_FILE from mopidy.utils.log import indent logger = logging.getLogger('mopidy.utils.settings') @@ -20,11 +20,9 @@ class SettingsProxy(object): self.runtime = {} def _get_local_settings(self): - dotdir = os.path.expanduser(u'~/.mopidy/') - settings_file = os.path.join(dotdir, u'settings.py') - if not os.path.isfile(settings_file): + if not os.path.isfile(SETTINGS_FILE): return {} - sys.path.insert(0, dotdir) + sys.path.insert(0, SETTINGS_PATH) # pylint: disable = F0401 import settings as local_settings_module # pylint: enable = F0401 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 f8cf2eb3..e24edd3c 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -1,4 +1,6 @@ coverage -mock +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/__init__.py b/tests/__init__.py index 1d4d2e3d..833ff239 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,24 +1,41 @@ import os +import sys -try: # 2.7 - # pylint: disable = E0611,F0401 - from unittest.case import SkipTest - # pylint: enable = E0611,F0401 -except ImportError: - try: # Nose - from nose.plugins.skip import SkipTest - except ImportError: # Failsafe - class SkipTest(Exception): - pass +if sys.version_info < (2, 7): + import unittest2 as unittest +else: + import unittest from mopidy import settings # Nuke any local settings to ensure same test env all over settings.local.clear() + def path_to_data_dir(name): path = os.path.dirname(__file__) path = os.path.join(path, 'data') path = os.path.abspath(path) return os.path.join(path, name) + +class IsA(object): + def __init__(self, klass): + self.klass = klass + + def __eq__(self, rhs): + try: + return isinstance(rhs, self.klass) + except TypeError: + return type(rhs) == type(self.klass) + + def __ne__(self, rhs): + return not self.__eq__(rhs) + + def __repr__(self): + return str(self.klass) + + +any_int = IsA(int) +any_str = IsA(str) +any_unicode = IsA(unicode) 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 b84391af..e99cd56c 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -1,12 +1,12 @@ import mock -import multiprocessing 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 + class CurrentPlaylistControllerTest(object): tracks = [] @@ -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/backends/base/library.py b/tests/backends/base/library.py index 2a3de730..4b3ef5c0 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -1,6 +1,7 @@ from mopidy.models import Playlist, Track, Album, Artist -from tests import SkipTest, path_to_data_dir +from tests import unittest, path_to_data_dir + class LibraryControllerTest(object): artists = [Artist(name='artist1'), Artist(name='artist2'), Artist()] @@ -20,11 +21,13 @@ class LibraryControllerTest(object): def test_refresh(self): self.library.refresh() + @unittest.SkipTest def test_refresh_uri(self): - raise SkipTest + pass + @unittest.SkipTest def test_refresh_missing_uri(self): - raise SkipTest + pass def test_lookup(self): track = self.library.lookup(self.tracks[0].uri) diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 2d455225..40c49709 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -1,16 +1,16 @@ import mock -import multiprocessing import random import time from mopidy.models import Track from mopidy.gstreamer import GStreamer -from tests import SkipTest +from tests import unittest from tests.backends.base import populate_playlist # TODO Test 'playlist repeat', e.g. repeat=1,single=0 + class PlaybackControllerTest(object): tracks = [] @@ -520,7 +520,7 @@ class PlaybackControllerTest(object): self.assert_(wrapper.called) - @SkipTest # Blocks for 10ms + @unittest.SkipTest # Blocks for 10ms @populate_playlist def test_end_of_track_callback_gets_called(self): self.playback.play() @@ -555,7 +555,7 @@ class PlaybackControllerTest(object): @populate_playlist def test_pause_when_stopped(self): self.playback.pause() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, self.playback.PAUSED) @populate_playlist def test_pause_when_playing(self): @@ -599,7 +599,7 @@ class PlaybackControllerTest(object): self.playback.pause() self.assertEqual(self.playback.resume(), None) - @SkipTest # Uses sleep and might not work with LocalBackend + @unittest.SkipTest # Uses sleep and might not work with LocalBackend @populate_playlist def test_resume_continues_from_right_position(self): self.playback.play() @@ -668,7 +668,7 @@ class PlaybackControllerTest(object): self.playback.seek(0) self.assertEqual(self.playback.state, self.playback.PLAYING) - @SkipTest + @unittest.SkipTest @populate_playlist def test_seek_beyond_end_of_song(self): # FIXME need to decide return value @@ -688,7 +688,7 @@ class PlaybackControllerTest(object): self.playback.seek(self.current_playlist.tracks[-1].length * 100) self.assertEqual(self.playback.state, self.playback.STOPPED) - @SkipTest + @unittest.SkipTest @populate_playlist def test_seek_beyond_start_of_song(self): # FIXME need to decide return value @@ -741,7 +741,7 @@ class PlaybackControllerTest(object): self.assertEqual(self.playback.time_position, 0) - @SkipTest # Uses sleep and does might not work with LocalBackend + @unittest.SkipTest # Uses sleep and does might not work with LocalBackend @populate_playlist def test_time_position_when_playing(self): self.playback.play() @@ -750,7 +750,7 @@ class PlaybackControllerTest(object): second = self.playback.time_position self.assert_(second > first, '%s - %s' % (first, second)) - @SkipTest # Uses sleep + @unittest.SkipTest # Uses sleep @populate_playlist def test_time_position_when_paused(self): self.playback.play() diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 839d5bed..54315e62 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -5,7 +5,8 @@ import tempfile from mopidy import settings from mopidy.models import Playlist -from tests import SkipTest, path_to_data_dir +from tests import unittest, path_to_data_dir + class StoredPlaylistsControllerTest(object): def setUp(self): @@ -78,11 +79,13 @@ class StoredPlaylistsControllerTest(object): except LookupError as e: self.assertEqual(u'"name=c" match no playlists', e[0]) + @unittest.SkipTest def test_lookup(self): - raise SkipTest + pass + @unittest.SkipTest def test_refresh(self): - raise SkipTest + pass def test_rename(self): playlist = self.stored.create('test') @@ -100,5 +103,6 @@ class StoredPlaylistsControllerTest(object): self.stored.save(playlist) self.assert_(playlist in self.stored.playlists) + @unittest.SkipTest def test_playlist_with_unknown_track(self): - raise SkipTest + pass diff --git a/tests/backends/events_test.py b/tests/backends/events_test.py index 44529e90..d761676d 100644 --- a/tests/backends/events_test.py +++ b/tests/backends/events_test.py @@ -1,45 +1,53 @@ -import threading -import unittest +import mock -from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry from mopidy.backends.dummy import DummyBackend from mopidy.listeners import BackendListener from mopidy.models import Track +from tests import unittest + + +@mock.patch.object(BackendListener, 'send') class BackendEventsTest(unittest.TestCase): def setUp(self): - self.events = { - 'started_playing': threading.Event(), - 'stopped_playing': threading.Event(), - } self.backend = DummyBackend.start().proxy() - self.listener = DummyBackendListener.start(self.events).proxy() def tearDown(self): ActorRegistry.stop_all() - def test_play_sends_started_playing_event(self): - self.backend.current_playlist.add([Track(uri='a')]) + def test_pause_sends_track_playback_paused_event(self, send): + self.backend.current_playlist.add(Track(uri='a')) + self.backend.playback.play().get() + send.reset_mock() + self.backend.playback.pause().get() + self.assertEqual(send.call_args[0][0], 'track_playback_paused') + + def test_resume_sends_track_playback_resumed(self, send): + self.backend.current_playlist.add(Track(uri='a')) self.backend.playback.play() - self.events['started_playing'].wait(timeout=1) - self.assertTrue(self.events['started_playing'].is_set()) + self.backend.playback.pause().get() + send.reset_mock() + self.backend.playback.resume().get() + self.assertEqual(send.call_args[0][0], 'track_playback_resumed') - def test_stop_sends_stopped_playing_event(self): - self.backend.current_playlist.add([Track(uri='a')]) - self.backend.playback.play() - self.backend.playback.stop() - self.events['stopped_playing'].wait(timeout=1) - self.assertTrue(self.events['stopped_playing'].is_set()) + def test_play_sends_track_playback_started_event(self, send): + self.backend.current_playlist.add(Track(uri='a')) + send.reset_mock() + self.backend.playback.play().get() + self.assertEqual(send.call_args[0][0], 'track_playback_started') + def test_stop_sends_track_playback_ended_event(self, send): + self.backend.current_playlist.add(Track(uri='a')) + self.backend.playback.play().get() + send.reset_mock() + self.backend.playback.stop().get() + self.assertEqual(send.call_args_list[0][0][0], 'track_playback_ended') -class DummyBackendListener(ThreadingActor, BackendListener): - def __init__(self, events): - self.events = events - - def started_playing(self, track): - self.events['started_playing'].set() - - def stopped_playing(self, track, time_position): - self.events['stopped_playing'].set() + def test_seek_sends_seeked_event(self, send): + self.backend.current_playlist.add(Track(uri='a', length=40000)) + self.backend.playback.play().get() + send.reset_mock() + self.backend.playback.seek(1000).get() + self.assertEqual(send.call_args[0][0], 'seeked') diff --git a/tests/backends/local/current_playlist_test.py b/tests/backends/local/current_playlist_test.py index 6f72d7d5..a475a6fd 100644 --- a/tests/backends/local/current_playlist_test.py +++ b/tests/backends/local/current_playlist_test.py @@ -1,18 +1,16 @@ -import unittest - -# FIXME Our Windows build server does not support GStreamer yet import sys -if sys.platform == 'win32': - from tests import SkipTest - raise SkipTest from mopidy import settings from mopidy.backends.local import LocalBackend from mopidy.models import Track +from tests import unittest from tests.backends.base.current_playlist import CurrentPlaylistControllerTest from tests.backends.local import generate_song + +@unittest.skipIf(sys.platform == 'win32', + 'Our Windows build server does not support GStreamer yet') class LocalCurrentPlaylistControllerTest(CurrentPlaylistControllerTest, unittest.TestCase): diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index 68ab22e9..046e747a 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -1,17 +1,14 @@ -import unittest - -# FIXME Our Windows build server does not support GStreamer yet import sys -if sys.platform == 'win32': - from tests import SkipTest - raise SkipTest from mopidy import settings from mopidy.backends.local import LocalBackend -from tests import path_to_data_dir +from tests import unittest, path_to_data_dir from tests.backends.base.library import LibraryControllerTest + +@unittest.skipIf(sys.platform == 'win32', + 'Our Windows build server does not support GStreamer yet') class LocalLibraryControllerTest(LibraryControllerTest, unittest.TestCase): backend_class = LocalBackend diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 6aec680f..788fe33c 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -1,20 +1,17 @@ -import unittest - -# FIXME Our Windows build server does not support GStreamer yet import sys -if sys.platform == 'win32': - from tests import SkipTest - raise SkipTest from mopidy import settings from mopidy.backends.local import LocalBackend from mopidy.models import Track from mopidy.utils.path import path_to_uri -from tests import path_to_data_dir +from tests import unittest, path_to_data_dir from tests.backends.base.playback import PlaybackControllerTest from tests.backends.local import generate_song + +@unittest.skipIf(sys.platform == 'win32', + 'Our Windows build server does not support GStreamer yet') class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): backend_class = LocalBackend tracks = [Track(uri=generate_song(i), length=4464) diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index b426e9ce..56be92c4 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -1,24 +1,19 @@ -import unittest import os - -from tests import SkipTest - -# FIXME Our Windows build server does not support GStreamer yet import sys -if sys.platform == 'win32': - raise SkipTest from mopidy import settings from mopidy.backends.local import LocalBackend -from mopidy.mixers.dummy import DummyMixer from mopidy.models import Playlist, Track from mopidy.utils.path import path_to_uri -from tests import path_to_data_dir -from tests.backends.base.stored_playlists import \ - StoredPlaylistsControllerTest +from tests import unittest, path_to_data_dir +from tests.backends.base.stored_playlists import ( + StoredPlaylistsControllerTest) from tests.backends.local import generate_song + +@unittest.skipIf(sys.platform == 'win32', + 'Our Windows build server does not support GStreamer yet') class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest, unittest.TestCase): @@ -77,14 +72,18 @@ class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest, self.assertEqual('test', self.stored.playlists[0].name) self.assertEqual(track.uri, self.stored.playlists[0].tracks[0].uri) + @unittest.SkipTest def test_santitising_of_playlist_filenames(self): - raise SkipTest + pass + @unittest.SkipTest def test_playlist_folder_is_createad(self): - raise SkipTest + pass + @unittest.SkipTest def test_create_sets_playlist_uri(self): - raise SkipTest + pass + @unittest.SkipTest def test_save_sets_playlist_uri(self): - raise SkipTest + pass diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py index a4e9f317..1dceb737 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/backends/local/translator_test.py @@ -2,13 +2,12 @@ import os import tempfile -import unittest from mopidy.utils.path import path_to_uri from mopidy.backends.local.translator import parse_m3u, parse_mpd_tag_cache from mopidy.models import Track, Artist, Album -from tests import SkipTest, path_to_data_dir +from tests import unittest, path_to_data_dir song1_path = path_to_data_dir('song1.mp3') song2_path = path_to_data_dir('song2.mp3') @@ -17,6 +16,9 @@ song1_uri = path_to_uri(song1_path) song2_uri = path_to_uri(song2_path) encoded_uri = path_to_uri(encoded_path) +# FIXME use mock instead of tempfile.NamedTemporaryFile + + class M3UToUriTest(unittest.TestCase): def test_empty_file(self): uris = parse_m3u(path_to_data_dir('empty.m3u')) @@ -127,9 +129,10 @@ class MPDTagCacheToTracksTest(unittest.TestCase): self.assertEqual(track, list(tracks)[0]) + @unittest.SkipTest def test_misencoded_cache(self): # FIXME not sure if this can happen - raise SkipTest + pass def test_cache_with_blank_track_info(self): tracks = parse_mpd_tag_cache(path_to_data_dir('blank_tag_cache'), diff --git a/tests/frontends/mpd/audio_output_test.py b/tests/frontends/mpd/audio_output_test.py deleted file mode 100644 index 82d9e203..00000000 --- a/tests/frontends/mpd/audio_output_test.py +++ /dev/null @@ -1,30 +0,0 @@ -import unittest - -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.mixers.dummy import DummyMixer - -class AudioOutputHandlerTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test_enableoutput(self): - result = self.dispatcher.handle_request(u'enableoutput "0"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_disableoutput(self): - result = self.dispatcher.handle_request(u'disableoutput "0"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_outputs(self): - result = self.dispatcher.handle_request(u'outputs') - self.assert_(u'outputid: 0' in result) - self.assert_(u'outputname: None' in result) - self.assert_(u'outputenabled: 1' in result) - self.assert_(u'OK' in result) diff --git a/tests/frontends/mpd/command_list_test.py b/tests/frontends/mpd/command_list_test.py deleted file mode 100644 index 8fd4c828..00000000 --- a/tests/frontends/mpd/command_list_test.py +++ /dev/null @@ -1,63 +0,0 @@ -import unittest - -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import dispatcher -from mopidy.mixers.dummy import DummyMixer - -class CommandListsTest(unittest.TestCase): - def setUp(self): - self.b = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = dispatcher.MpdDispatcher() - - def tearDown(self): - self.b.stop().get() - self.mixer.stop().get() - - def test_command_list_begin(self): - result = self.dispatcher.handle_request(u'command_list_begin') - self.assertEquals(result, []) - - def test_command_list_end(self): - self.dispatcher.handle_request(u'command_list_begin') - result = self.dispatcher.handle_request(u'command_list_end') - self.assert_(u'OK' in result) - - def test_command_list_end_without_start_first_is_an_unknown_command(self): - result = self.dispatcher.handle_request(u'command_list_end') - self.assertEquals(result[0], - u'ACK [5@0] {} unknown command "command_list_end"') - - def test_command_list_with_ping(self): - self.dispatcher.handle_request(u'command_list_begin') - self.assertEqual([], self.dispatcher.command_list) - self.assertEqual(False, self.dispatcher.command_list_ok) - self.dispatcher.handle_request(u'ping') - self.assert_(u'ping' in self.dispatcher.command_list) - result = self.dispatcher.handle_request(u'command_list_end') - self.assert_(u'OK' in result) - self.assertEqual(False, self.dispatcher.command_list) - - def test_command_list_with_error_returns_ack_with_correct_index(self): - self.dispatcher.handle_request(u'command_list_begin') - self.dispatcher.handle_request(u'play') # Known command - self.dispatcher.handle_request(u'paly') # Unknown command - result = self.dispatcher.handle_request(u'command_list_end') - self.assertEqual(len(result), 1, result) - self.assertEqual(result[0], u'ACK [5@1] {} unknown command "paly"') - - def test_command_list_ok_begin(self): - result = self.dispatcher.handle_request(u'command_list_ok_begin') - self.assertEquals(result, []) - - def test_command_list_ok_with_ping(self): - self.dispatcher.handle_request(u'command_list_ok_begin') - self.assertEqual([], self.dispatcher.command_list) - self.assertEqual(True, self.dispatcher.command_list_ok) - self.dispatcher.handle_request(u'ping') - self.assert_(u'ping' in self.dispatcher.command_list) - result = self.dispatcher.handle_request(u'command_list_end') - self.assert_(u'list_OK' in result) - self.assert_(u'OK' in result) - self.assertEqual(False, self.dispatcher.command_list) - self.assertEqual(False, self.dispatcher.command_list_ok) diff --git a/tests/frontends/mpd/connection_test.py b/tests/frontends/mpd/connection_test.py deleted file mode 100644 index bc995a5e..00000000 --- a/tests/frontends/mpd/connection_test.py +++ /dev/null @@ -1,53 +0,0 @@ -import mock -import unittest - -from mopidy import settings -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.frontends.mpd.session import MpdSession -from mopidy.mixers.dummy import DummyMixer - -class ConnectionHandlerTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.session = mock.Mock(spec=MpdSession) - self.dispatcher = MpdDispatcher(session=self.session) - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - settings.runtime.clear() - - def test_close_closes_the_client_connection(self): - result = self.dispatcher.handle_request(u'close') - self.assert_(self.session.close.called, - u'Should call close() on MpdSession') - self.assert_(u'OK' in result) - - def test_empty_request(self): - result = self.dispatcher.handle_request(u'') - self.assert_(u'OK' in result) - - def test_kill(self): - result = self.dispatcher.handle_request(u'kill') - self.assert_(u'ACK [4@0] {kill} you don\'t have permission for "kill"' in result) - - def test_valid_password_is_accepted(self): - settings.MPD_SERVER_PASSWORD = u'topsecret' - result = self.dispatcher.handle_request(u'password "topsecret"') - self.assert_(u'OK' in result) - - def test_invalid_password_is_not_accepted(self): - settings.MPD_SERVER_PASSWORD = u'topsecret' - result = self.dispatcher.handle_request(u'password "secret"') - self.assert_(u'ACK [3@0] {password} incorrect password' in result) - - def test_any_password_is_not_accepted_when_password_check_turned_off(self): - settings.MPD_SERVER_PASSWORD = None - result = self.dispatcher.handle_request(u'password "secret"') - self.assert_(u'ACK [3@0] {password} incorrect password' in result) - - def test_ping(self): - result = self.dispatcher.handle_request(u'ping') - self.assert_(u'OK' in result) diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/frontends/mpd/dispatcher_test.py index 7708ce31..bfa7c548 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -1,11 +1,12 @@ -import unittest - from mopidy.backends.dummy import DummyBackend from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.frontends.mpd.exceptions import MpdAckError from mopidy.frontends.mpd.protocol import request_handlers, handle_request from mopidy.mixers.dummy import DummyMixer +from tests import unittest + + class MpdDispatcherTest(unittest.TestCase): def setUp(self): self.backend = DummyBackend.start().proxy() diff --git a/tests/frontends/mpd/exception_test.py b/tests/frontends/mpd/exception_test.py index df2cd65e..2ea3fe62 100644 --- a/tests/frontends/mpd/exception_test.py +++ b/tests/frontends/mpd/exception_test.py @@ -1,8 +1,9 @@ -import unittest - from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdPermissionError, MpdUnknownCommand, MpdSystemError, MpdNotImplemented) +from tests import unittest + + class MpdExceptionsTest(unittest.TestCase): def test_key_error_wrapped_in_mpd_ack_error(self): try: diff --git a/tests/frontends/mpd/music_db_test.py b/tests/frontends/mpd/music_db_test.py deleted file mode 100644 index 3793db9e..00000000 --- a/tests/frontends/mpd/music_db_test.py +++ /dev/null @@ -1,412 +0,0 @@ -import unittest - -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.mixers.dummy import DummyMixer - -class MusicDatabaseHandlerTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test_count(self): - result = self.dispatcher.handle_request(u'count "tag" "needle"') - self.assert_(u'songs: 0' in result) - self.assert_(u'playtime: 0' in result) - self.assert_(u'OK' in result) - - def test_findadd(self): - result = self.dispatcher.handle_request(u'findadd "album" "what"') - self.assert_(u'OK' in result) - - def test_listall(self): - result = self.dispatcher.handle_request( - u'listall "file:///dev/urandom"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_listallinfo(self): - result = self.dispatcher.handle_request( - u'listallinfo "file:///dev/urandom"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_lsinfo_without_path_returns_same_as_listplaylists(self): - lsinfo_result = self.dispatcher.handle_request(u'lsinfo') - listplaylists_result = self.dispatcher.handle_request(u'listplaylists') - self.assertEqual(lsinfo_result, listplaylists_result) - - def test_lsinfo_with_empty_path_returns_same_as_listplaylists(self): - lsinfo_result = self.dispatcher.handle_request(u'lsinfo ""') - listplaylists_result = self.dispatcher.handle_request(u'listplaylists') - self.assertEqual(lsinfo_result, listplaylists_result) - - def test_lsinfo_for_root_returns_same_as_listplaylists(self): - lsinfo_result = self.dispatcher.handle_request(u'lsinfo "/"') - listplaylists_result = self.dispatcher.handle_request(u'listplaylists') - self.assertEqual(lsinfo_result, listplaylists_result) - - def test_update_without_uri(self): - result = self.dispatcher.handle_request(u'update') - self.assert_(u'OK' in result) - self.assert_(u'updating_db: 0' in result) - - def test_update_with_uri(self): - result = self.dispatcher.handle_request(u'update "file:///dev/urandom"') - self.assert_(u'OK' in result) - self.assert_(u'updating_db: 0' in result) - - def test_rescan_without_uri(self): - result = self.dispatcher.handle_request(u'rescan') - self.assert_(u'OK' in result) - self.assert_(u'updating_db: 0' in result) - - def test_rescan_with_uri(self): - result = self.dispatcher.handle_request(u'rescan "file:///dev/urandom"') - self.assert_(u'OK' in result) - self.assert_(u'updating_db: 0' in result) - - -class MusicDatabaseFindTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test_find_album(self): - result = self.dispatcher.handle_request(u'find "album" "what"') - self.assert_(u'OK' in result) - - def test_find_album_without_quotes(self): - result = self.dispatcher.handle_request(u'find album "what"') - self.assert_(u'OK' in result) - - def test_find_artist(self): - result = self.dispatcher.handle_request(u'find "artist" "what"') - self.assert_(u'OK' in result) - - def test_find_artist_without_quotes(self): - result = self.dispatcher.handle_request(u'find artist "what"') - self.assert_(u'OK' in result) - - def test_find_title(self): - result = self.dispatcher.handle_request(u'find "title" "what"') - self.assert_(u'OK' in result) - - def test_find_title_without_quotes(self): - result = self.dispatcher.handle_request(u'find title "what"') - self.assert_(u'OK' in result) - - def test_find_date(self): - result = self.dispatcher.handle_request(u'find "date" "2002-01-01"') - self.assert_(u'OK' in result) - - def test_find_date_without_quotes(self): - result = self.dispatcher.handle_request(u'find date "2002-01-01"') - self.assert_(u'OK' in result) - - def test_find_date_with_capital_d_and_incomplete_date(self): - result = self.dispatcher.handle_request(u'find Date "2005"') - self.assert_(u'OK' in result) - - def test_find_else_should_fail(self): - - result = self.dispatcher.handle_request(u'find "somethingelse" "what"') - self.assertEqual(result[0], u'ACK [2@0] {find} incorrect arguments') - - def test_find_album_and_artist(self): - result = self.dispatcher.handle_request( - u'find album "album_what" artist "artist_what"') - self.assert_(u'OK' in result) - - -class MusicDatabaseListTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test_list_foo_returns_ack(self): - result = self.dispatcher.handle_request(u'list "foo"') - self.assertEqual(result[0], - u'ACK [2@0] {list} incorrect arguments') - - ### Artist - - def test_list_artist_with_quotes(self): - result = self.dispatcher.handle_request(u'list "artist"') - self.assert_(u'OK' in result) - - def test_list_artist_without_quotes(self): - result = self.dispatcher.handle_request(u'list artist') - self.assert_(u'OK' in result) - - def test_list_artist_without_quotes_and_capitalized(self): - result = self.dispatcher.handle_request(u'list Artist') - self.assert_(u'OK' in result) - - def test_list_artist_with_query_of_one_token(self): - result = self.dispatcher.handle_request(u'list "artist" "anartist"') - self.assertEqual(result[0], - u'ACK [2@0] {list} should be "Album" for 3 arguments') - - def test_list_artist_with_unknown_field_in_query_returns_ack(self): - result = self.dispatcher.handle_request(u'list "artist" "foo" "bar"') - self.assertEqual(result[0], - u'ACK [2@0] {list} not able to parse args') - - def test_list_artist_by_artist(self): - result = self.dispatcher.handle_request( - u'list "artist" "artist" "anartist"') - self.assert_(u'OK' in result) - - def test_list_artist_by_album(self): - result = self.dispatcher.handle_request( - u'list "artist" "album" "analbum"') - self.assert_(u'OK' in result) - - def test_list_artist_by_full_date(self): - result = self.dispatcher.handle_request( - u'list "artist" "date" "2001-01-01"') - self.assert_(u'OK' in result) - - def test_list_artist_by_year(self): - result = self.dispatcher.handle_request( - u'list "artist" "date" "2001"') - self.assert_(u'OK' in result) - - def test_list_artist_by_genre(self): - result = self.dispatcher.handle_request( - u'list "artist" "genre" "agenre"') - self.assert_(u'OK' in result) - - def test_list_artist_by_artist_and_album(self): - result = self.dispatcher.handle_request( - u'list "artist" "artist" "anartist" "album" "analbum"') - self.assert_(u'OK' in result) - - ### Album - - def test_list_album_with_quotes(self): - result = self.dispatcher.handle_request(u'list "album"') - self.assert_(u'OK' in result) - - def test_list_album_without_quotes(self): - result = self.dispatcher.handle_request(u'list album') - self.assert_(u'OK' in result) - - def test_list_album_without_quotes_and_capitalized(self): - result = self.dispatcher.handle_request(u'list Album') - self.assert_(u'OK' in result) - - def test_list_album_with_artist_name(self): - result = self.dispatcher.handle_request(u'list "album" "anartist"') - self.assert_(u'OK' in result) - - def test_list_album_by_artist(self): - result = self.dispatcher.handle_request( - u'list "album" "artist" "anartist"') - self.assert_(u'OK' in result) - - def test_list_album_by_album(self): - result = self.dispatcher.handle_request( - u'list "album" "album" "analbum"') - self.assert_(u'OK' in result) - - def test_list_album_by_full_date(self): - result = self.dispatcher.handle_request( - u'list "album" "date" "2001-01-01"') - self.assert_(u'OK' in result) - - def test_list_album_by_year(self): - result = self.dispatcher.handle_request( - u'list "album" "date" "2001"') - self.assert_(u'OK' in result) - - def test_list_album_by_genre(self): - result = self.dispatcher.handle_request( - u'list "album" "genre" "agenre"') - self.assert_(u'OK' in result) - - def test_list_album_by_artist_and_album(self): - result = self.dispatcher.handle_request( - u'list "album" "artist" "anartist" "album" "analbum"') - self.assert_(u'OK' in result) - - ### Date - - def test_list_date_with_quotes(self): - result = self.dispatcher.handle_request(u'list "date"') - self.assert_(u'OK' in result) - - def test_list_date_without_quotes(self): - result = self.dispatcher.handle_request(u'list date') - self.assert_(u'OK' in result) - - def test_list_date_without_quotes_and_capitalized(self): - result = self.dispatcher.handle_request(u'list Date') - self.assert_(u'OK' in result) - - def test_list_date_with_query_of_one_token(self): - result = self.dispatcher.handle_request(u'list "date" "anartist"') - self.assertEqual(result[0], - u'ACK [2@0] {list} should be "Album" for 3 arguments') - - def test_list_date_by_artist(self): - result = self.dispatcher.handle_request( - u'list "date" "artist" "anartist"') - self.assert_(u'OK' in result) - - def test_list_date_by_album(self): - result = self.dispatcher.handle_request( - u'list "date" "album" "analbum"') - self.assert_(u'OK' in result) - - def test_list_date_by_full_date(self): - result = self.dispatcher.handle_request( - u'list "date" "date" "2001-01-01"') - self.assert_(u'OK' in result) - - def test_list_date_by_year(self): - result = self.dispatcher.handle_request(u'list "date" "date" "2001"') - self.assert_(u'OK' in result) - - def test_list_date_by_genre(self): - result = self.dispatcher.handle_request(u'list "date" "genre" "agenre"') - self.assert_(u'OK' in result) - - def test_list_date_by_artist_and_album(self): - result = self.dispatcher.handle_request( - u'list "date" "artist" "anartist" "album" "analbum"') - self.assert_(u'OK' in result) - - ### Genre - - def test_list_genre_with_quotes(self): - result = self.dispatcher.handle_request(u'list "genre"') - self.assert_(u'OK' in result) - - def test_list_genre_without_quotes(self): - result = self.dispatcher.handle_request(u'list genre') - self.assert_(u'OK' in result) - - def test_list_genre_without_quotes_and_capitalized(self): - result = self.dispatcher.handle_request(u'list Genre') - self.assert_(u'OK' in result) - - def test_list_genre_with_query_of_one_token(self): - result = self.dispatcher.handle_request(u'list "genre" "anartist"') - self.assertEqual(result[0], - u'ACK [2@0] {list} should be "Album" for 3 arguments') - - def test_list_genre_by_artist(self): - result = self.dispatcher.handle_request( - u'list "genre" "artist" "anartist"') - self.assert_(u'OK' in result) - - def test_list_genre_by_album(self): - result = self.dispatcher.handle_request( - u'list "genre" "album" "analbum"') - self.assert_(u'OK' in result) - - def test_list_genre_by_full_date(self): - result = self.dispatcher.handle_request( - u'list "genre" "date" "2001-01-01"') - self.assert_(u'OK' in result) - - def test_list_genre_by_year(self): - result = self.dispatcher.handle_request( - u'list "genre" "date" "2001"') - self.assert_(u'OK' in result) - - def test_list_genre_by_genre(self): - result = self.dispatcher.handle_request( - u'list "genre" "genre" "agenre"') - self.assert_(u'OK' in result) - - def test_list_genre_by_artist_and_album(self): - result = self.dispatcher.handle_request( - u'list "genre" "artist" "anartist" "album" "analbum"') - self.assert_(u'OK' in result) - - -class MusicDatabaseSearchTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test_search_album(self): - result = self.dispatcher.handle_request(u'search "album" "analbum"') - self.assert_(u'OK' in result) - - def test_search_album_without_quotes(self): - result = self.dispatcher.handle_request(u'search album "analbum"') - self.assert_(u'OK' in result) - - def test_search_artist(self): - result = self.dispatcher.handle_request(u'search "artist" "anartist"') - self.assert_(u'OK' in result) - - def test_search_artist_without_quotes(self): - result = self.dispatcher.handle_request(u'search artist "anartist"') - self.assert_(u'OK' in result) - - def test_search_filename(self): - result = self.dispatcher.handle_request( - u'search "filename" "afilename"') - self.assert_(u'OK' in result) - - def test_search_filename_without_quotes(self): - result = self.dispatcher.handle_request(u'search filename "afilename"') - self.assert_(u'OK' in result) - - def test_search_title(self): - result = self.dispatcher.handle_request(u'search "title" "atitle"') - self.assert_(u'OK' in result) - - def test_search_title_without_quotes(self): - result = self.dispatcher.handle_request(u'search title "atitle"') - self.assert_(u'OK' in result) - - def test_search_any(self): - result = self.dispatcher.handle_request(u'search "any" "anything"') - self.assert_(u'OK' in result) - - def test_search_any_without_quotes(self): - result = self.dispatcher.handle_request(u'search any "anything"') - self.assert_(u'OK' in result) - - def test_search_date(self): - result = self.dispatcher.handle_request(u'search "date" "2002-01-01"') - self.assert_(u'OK' in result) - - def test_search_date_without_quotes(self): - result = self.dispatcher.handle_request(u'search date "2002-01-01"') - self.assert_(u'OK' in result) - - def test_search_date_with_capital_d_and_incomplete_date(self): - result = self.dispatcher.handle_request(u'search Date "2005"') - self.assert_(u'OK' in result) - - def test_search_else_should_fail(self): - result = self.dispatcher.handle_request( - u'search "sometype" "something"') - self.assertEqual(result[0], u'ACK [2@0] {search} incorrect arguments') - - diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py new file mode 100644 index 00000000..b54906be --- /dev/null +++ b/tests/frontends/mpd/protocol/__init__.py @@ -0,0 +1,62 @@ +import mock + +from mopidy import settings +from mopidy.backends import dummy as backend +from mopidy.frontends import mpd +from mopidy.mixers import dummy as mixer + +from tests import unittest + + +class MockConnection(mock.Mock): + def __init__(self, *args, **kwargs): + super(MockConnection, self).__init__(*args, **kwargs) + self.host = mock.sentinel.host + self.port = mock.sentinel.port + self.response = [] + + def queue_send(self, data): + lines = (line for line in data.split('\n') if line) + self.response.extend(lines) + + +class BaseTestCase(unittest.TestCase): + def setUp(self): + self.backend = backend.DummyBackend.start().proxy() + self.mixer = mixer.DummyMixer.start().proxy() + + self.connection = MockConnection() + self.session = mpd.MpdSession(self.connection) + self.dispatcher = self.session.dispatcher + self.context = self.dispatcher.context + + def tearDown(self): + self.backend.stop().get() + self.mixer.stop().get() + settings.runtime.clear() + + def sendRequest(self, request): + self.connection.response = [] + request = '%s\n' % request.encode('utf-8') + self.session.on_receive({'received': request}) + return self.connection.response + + def assertNoResponse(self): + self.assertEqual([], self.connection.response) + + def assertInResponse(self, value): + self.assert_(value in self.connection.response, u'Did not find %s ' + 'in %s' % (repr(value), repr(self.connection.response))) + + def assertOnceInResponse(self, value): + matched = len([r for r in self.connection.response if r == value]) + self.assertEqual(1, matched, 'Expected to find %s once in %s' % + (repr(value), repr(self.connection.response))) + + def assertNotInResponse(self, value): + self.assert_(value not in self.connection.response, u'Found %s in %s' % + (repr(value), repr(self.connection.response))) + + def assertEqualResponse(self, value): + self.assertEqual(1, len(self.connection.response)) + self.assertEqual(value, self.connection.response[0]) diff --git a/tests/frontends/mpd/protocol/audio_output_test.py b/tests/frontends/mpd/protocol/audio_output_test.py new file mode 100644 index 00000000..3bb8dce8 --- /dev/null +++ b/tests/frontends/mpd/protocol/audio_output_test.py @@ -0,0 +1,18 @@ +from tests.frontends.mpd import protocol + + +class AudioOutputHandlerTest(protocol.BaseTestCase): + def test_enableoutput(self): + self.sendRequest(u'enableoutput "0"') + self.assertInResponse(u'ACK [0@0] {} Not implemented') + + def test_disableoutput(self): + self.sendRequest(u'disableoutput "0"') + self.assertInResponse(u'ACK [0@0] {} Not implemented') + + def test_outputs(self): + self.sendRequest(u'outputs') + self.assertInResponse(u'outputid: 0') + self.assertInResponse(u'outputname: None') + self.assertInResponse(u'outputenabled: 1') + self.assertInResponse(u'OK') diff --git a/tests/frontends/mpd/authentication_test.py b/tests/frontends/mpd/protocol/authentication_test.py similarity index 53% rename from tests/frontends/mpd/authentication_test.py rename to tests/frontends/mpd/protocol/authentication_test.py index d795d726..20422f5b 100644 --- a/tests/frontends/mpd/authentication_test.py +++ b/tests/frontends/mpd/protocol/authentication_test.py @@ -1,63 +1,62 @@ -import mock -import unittest - from mopidy import settings -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.frontends.mpd.session import MpdSession -class AuthenticationTest(unittest.TestCase): - def setUp(self): - self.session = mock.Mock(spec=MpdSession) - self.dispatcher = MpdDispatcher(session=self.session) +from tests.frontends.mpd import protocol - def tearDown(self): - settings.runtime.clear() +class AuthenticationTest(protocol.BaseTestCase): def test_authentication_with_valid_password_is_accepted(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - response = self.dispatcher.handle_request(u'password "topsecret"') + + self.sendRequest(u'password "topsecret"') self.assertTrue(self.dispatcher.authenticated) - self.assert_(u'OK' in response) + self.assertInResponse(u'OK') def test_authentication_with_invalid_password_is_not_accepted(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - response = self.dispatcher.handle_request(u'password "secret"') + + self.sendRequest(u'password "secret"') self.assertFalse(self.dispatcher.authenticated) - self.assert_(u'ACK [3@0] {password} incorrect password' in response) + self.assertEqualResponse(u'ACK [3@0] {password} incorrect password') def test_authentication_with_anything_when_password_check_turned_off(self): settings.MPD_SERVER_PASSWORD = None - response = self.dispatcher.handle_request(u'any request at all') + + self.sendRequest(u'any request at all') self.assertTrue(self.dispatcher.authenticated) - self.assert_('ACK [5@0] {} unknown command "any"' in response) + self.assertEqualResponse('ACK [5@0] {} unknown command "any"') def test_anything_when_not_authenticated_should_fail(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - response = self.dispatcher.handle_request(u'any request at all') + + self.sendRequest(u'any request at all') self.assertFalse(self.dispatcher.authenticated) - self.assert_( - u'ACK [4@0] {any} you don\'t have permission for "any"' in response) + self.assertEqualResponse( + u'ACK [4@0] {any} you don\'t have permission for "any"') def test_close_is_allowed_without_authentication(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - response = self.dispatcher.handle_request(u'close') + + self.sendRequest(u'close') self.assertFalse(self.dispatcher.authenticated) - self.assert_(u'OK' in response) + self.assertInResponse(u'OK') def test_commands_is_allowed_without_authentication(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - response = self.dispatcher.handle_request(u'commands') + + self.sendRequest(u'commands') self.assertFalse(self.dispatcher.authenticated) - self.assert_(u'OK' in response) + self.assertInResponse(u'OK') def test_notcommands_is_allowed_without_authentication(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - response = self.dispatcher.handle_request(u'notcommands') + + self.sendRequest(u'notcommands') self.assertFalse(self.dispatcher.authenticated) - self.assert_(u'OK' in response) + self.assertInResponse(u'OK') def test_ping_is_allowed_without_authentication(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - response = self.dispatcher.handle_request(u'ping') + + self.sendRequest(u'ping') self.assertFalse(self.dispatcher.authenticated) - self.assert_(u'OK' in response) + self.assertInResponse(u'OK') diff --git a/tests/frontends/mpd/protocol/command_list_test.py b/tests/frontends/mpd/protocol/command_list_test.py new file mode 100644 index 00000000..a81725ad --- /dev/null +++ b/tests/frontends/mpd/protocol/command_list_test.py @@ -0,0 +1,54 @@ +from tests.frontends.mpd import protocol + + +class CommandListsTest(protocol.BaseTestCase): + def test_command_list_begin(self): + response = self.sendRequest(u'command_list_begin') + self.assertEquals([], response) + + def test_command_list_end(self): + self.sendRequest(u'command_list_begin') + self.sendRequest(u'command_list_end') + self.assertInResponse(u'OK') + + def test_command_list_end_without_start_first_is_an_unknown_command(self): + self.sendRequest(u'command_list_end') + self.assertEqualResponse( + u'ACK [5@0] {} unknown command "command_list_end"') + + def test_command_list_with_ping(self): + self.sendRequest(u'command_list_begin') + self.assertEqual([], self.dispatcher.command_list) + self.assertEqual(False, self.dispatcher.command_list_ok) + self.sendRequest(u'ping') + self.assert_(u'ping' in self.dispatcher.command_list) + self.sendRequest(u'command_list_end') + self.assertInResponse(u'OK') + self.assertEqual(False, self.dispatcher.command_list) + + def test_command_list_with_error_returns_ack_with_correct_index(self): + self.sendRequest(u'command_list_begin') + self.sendRequest(u'play') # Known command + self.sendRequest(u'paly') # Unknown command + self.sendRequest(u'command_list_end') + self.assertEqualResponse(u'ACK [5@1] {} unknown command "paly"') + + def test_command_list_ok_begin(self): + response = self.sendRequest(u'command_list_ok_begin') + self.assertEquals([], response) + + def test_command_list_ok_with_ping(self): + self.sendRequest(u'command_list_ok_begin') + self.assertEqual([], self.dispatcher.command_list) + self.assertEqual(True, self.dispatcher.command_list_ok) + self.sendRequest(u'ping') + self.assert_(u'ping' in self.dispatcher.command_list) + self.sendRequest(u'command_list_end') + self.assertInResponse(u'list_OK') + self.assertInResponse(u'OK') + self.assertEqual(False, self.dispatcher.command_list) + self.assertEqual(False, self.dispatcher.command_list_ok) + + # FIXME this should also include the special handling of idle within a + # command list. That is that once a idle/noidle command is found inside a + # commad list, the rest of the list seems to be ignored. diff --git a/tests/frontends/mpd/protocol/connection_test.py b/tests/frontends/mpd/protocol/connection_test.py new file mode 100644 index 00000000..cd08313f --- /dev/null +++ b/tests/frontends/mpd/protocol/connection_test.py @@ -0,0 +1,44 @@ +from mock import patch + +from mopidy import settings + +from tests.frontends.mpd import protocol + + +class ConnectionHandlerTest(protocol.BaseTestCase): + def test_close_closes_the_client_connection(self): + with patch.object(self.session, 'close') as close_mock: + response = self.sendRequest(u'close') + close_mock.assertEqualResponsecalled_once_with() + self.assertEqualResponse(u'OK') + + def test_empty_request(self): + self.sendRequest(u'') + self.assertEqualResponse(u'OK') + + self.sendRequest(u' ') + self.assertEqualResponse(u'OK') + + def test_kill(self): + self.sendRequest(u'kill') + self.assertEqualResponse( + u'ACK [4@0] {kill} you don\'t have permission for "kill"') + + def test_valid_password_is_accepted(self): + settings.MPD_SERVER_PASSWORD = u'topsecret' + self.sendRequest(u'password "topsecret"') + self.assertEqualResponse(u'OK') + + def test_invalid_password_is_not_accepted(self): + settings.MPD_SERVER_PASSWORD = u'topsecret' + self.sendRequest(u'password "secret"') + self.assertEqualResponse(u'ACK [3@0] {password} incorrect password') + + def test_any_password_is_not_accepted_when_password_check_turned_off(self): + settings.MPD_SERVER_PASSWORD = None + self.sendRequest(u'password "secret"') + self.assertEqualResponse(u'ACK [3@0] {password} incorrect password') + + def test_ping(self): + self.sendRequest(u'ping') + self.assertEqualResponse(u'OK') diff --git a/tests/frontends/mpd/current_playlist_test.py b/tests/frontends/mpd/protocol/current_playlist_test.py similarity index 59% rename from tests/frontends/mpd/current_playlist_test.py rename to tests/frontends/mpd/protocol/current_playlist_test.py index c7f47429..321fc6ee 100644 --- a/tests/frontends/mpd/current_playlist_test.py +++ b/tests/frontends/mpd/protocol/current_playlist_test.py @@ -1,20 +1,9 @@ -import unittest - -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.mixers.dummy import DummyMixer from mopidy.models import Track -class CurrentPlaylistHandlerTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() +from tests.frontends.mpd import protocol - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() +class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_add(self): needle = Track(uri='dummy://foo') self.backend.library.provider.dummy_library = [ @@ -22,21 +11,21 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - result = self.dispatcher.handle_request(u'add "dummy://foo"') - self.assertEqual(len(result), 1) - self.assertEqual(result[0], u'OK') + + self.sendRequest(u'add "dummy://foo"') self.assertEqual(len(self.backend.current_playlist.tracks.get()), 6) self.assertEqual(self.backend.current_playlist.tracks.get()[5], needle) + self.assertEqualResponse(u'OK') def test_add_with_uri_not_found_in_library_should_ack(self): - result = self.dispatcher.handle_request(u'add "dummy://foo"') - self.assertEqual(result[0], + self.sendRequest(u'add "dummy://foo"') + self.assertEqualResponse( u'ACK [50@0] {add} directory or file not found') def test_add_with_empty_uri_should_add_all_known_tracks_and_ok(self): - result = self.dispatcher.handle_request(u'add ""') + self.sendRequest(u'add ""') # TODO check that we add all tracks (we currently don't) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_addid_without_songpos(self): needle = Track(uri='dummy://foo') @@ -45,16 +34,17 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - result = self.dispatcher.handle_request(u'addid "dummy://foo"') + + self.sendRequest(u'addid "dummy://foo"') self.assertEqual(len(self.backend.current_playlist.tracks.get()), 6) self.assertEqual(self.backend.current_playlist.tracks.get()[5], needle) - self.assert_(u'Id: %d' % - self.backend.current_playlist.cp_tracks.get()[5][0] in result) - self.assert_(u'OK' in result) + self.assertInResponse(u'Id: %d' % + self.backend.current_playlist.cp_tracks.get()[5][0]) + self.assertInResponse(u'OK') def test_addid_with_empty_uri_acks(self): - result = self.dispatcher.handle_request(u'addid ""') - self.assertEqual(result[0], u'ACK [50@0] {addid} No such song') + self.sendRequest(u'addid ""') + self.assertEqualResponse(u'ACK [50@0] {addid} No such song') def test_addid_with_songpos(self): needle = Track(uri='dummy://foo') @@ -63,12 +53,13 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - result = self.dispatcher.handle_request(u'addid "dummy://foo" "3"') + + self.sendRequest(u'addid "dummy://foo" "3"') self.assertEqual(len(self.backend.current_playlist.tracks.get()), 6) self.assertEqual(self.backend.current_playlist.tracks.get()[3], needle) - self.assert_(u'Id: %d' % - self.backend.current_playlist.cp_tracks.get()[3][0] in result) - self.assert_(u'OK' in result) + self.assertInResponse(u'Id: %d' % + self.backend.current_playlist.cp_tracks.get()[3][0]) + self.assertInResponse(u'OK') def test_addid_with_songpos_out_of_bounds_should_ack(self): needle = Track(uri='dummy://foo') @@ -77,83 +68,93 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - result = self.dispatcher.handle_request(u'addid "dummy://foo" "6"') - self.assertEqual(result[0], u'ACK [2@0] {addid} Bad song index') + + self.sendRequest(u'addid "dummy://foo" "6"') + self.assertEqualResponse(u'ACK [2@0] {addid} Bad song index') def test_addid_with_uri_not_found_in_library_should_ack(self): - result = self.dispatcher.handle_request(u'addid "dummy://foo"') - self.assertEqual(result[0], u'ACK [50@0] {addid} No such song') + self.sendRequest(u'addid "dummy://foo"') + self.assertEqualResponse(u'ACK [50@0] {addid} No such song') def test_clear(self): self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - result = self.dispatcher.handle_request(u'clear') + + self.sendRequest(u'clear') self.assertEqual(len(self.backend.current_playlist.tracks.get()), 0) self.assertEqual(self.backend.playback.current_track.get(), None) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_delete_songpos(self): self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - result = self.dispatcher.handle_request(u'delete "%d"' % + + self.sendRequest(u'delete "%d"' % self.backend.current_playlist.cp_tracks.get()[2][0]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 4) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_delete_songpos_out_of_bounds(self): self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - result = self.dispatcher.handle_request(u'delete "5"') + + self.sendRequest(u'delete "5"') self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - self.assertEqual(result[0], u'ACK [2@0] {delete} Bad song index') + self.assertEqualResponse(u'ACK [2@0] {delete} Bad song index') def test_delete_open_range(self): self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - result = self.dispatcher.handle_request(u'delete "1:"') + + self.sendRequest(u'delete "1:"') self.assertEqual(len(self.backend.current_playlist.tracks.get()), 1) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_delete_closed_range(self): self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - result = self.dispatcher.handle_request(u'delete "1:3"') + + self.sendRequest(u'delete "1:3"') self.assertEqual(len(self.backend.current_playlist.tracks.get()), 3) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_delete_range_out_of_bounds(self): self.backend.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - result = self.dispatcher.handle_request(u'delete "5:7"') + + self.sendRequest(u'delete "5:7"') self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) - self.assertEqual(result[0], u'ACK [2@0] {delete} Bad song index') + self.assertEqualResponse(u'ACK [2@0] {delete} Bad song index') def test_deleteid(self): self.backend.current_playlist.append([Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) - result = self.dispatcher.handle_request(u'deleteid "1"') + + self.sendRequest(u'deleteid "1"') self.assertEqual(len(self.backend.current_playlist.tracks.get()), 1) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_deleteid_does_not_exist(self): self.backend.current_playlist.append([Track(), Track()]) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) - result = self.dispatcher.handle_request(u'deleteid "12345"') + + self.sendRequest(u'deleteid "12345"') self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) - self.assertEqual(result[0], u'ACK [50@0] {deleteid} No such song') + self.assertEqualResponse(u'ACK [50@0] {deleteid} No such song') def test_move_songpos(self): self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.dispatcher.handle_request(u'move "1" "0"') + + self.sendRequest(u'move "1" "0"') tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[1].name, 'a') @@ -161,14 +162,15 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[4].name, 'e') self.assertEqual(tracks[5].name, 'f') - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_move_open_range(self): self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.dispatcher.handle_request(u'move "2:" "0"') + + self.sendRequest(u'move "2:" "0"') tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'c') self.assertEqual(tracks[1].name, 'd') @@ -176,14 +178,15 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEqual(tracks[3].name, 'f') self.assertEqual(tracks[4].name, 'a') self.assertEqual(tracks[5].name, 'b') - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_move_closed_range(self): self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.dispatcher.handle_request(u'move "1:3" "0"') + + self.sendRequest(u'move "1:3" "0"') tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[1].name, 'c') @@ -191,14 +194,15 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[4].name, 'e') self.assertEqual(tracks[5].name, 'f') - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_moveid(self): self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.dispatcher.handle_request(u'moveid "4" "2"') + + self.sendRequest(u'moveid "4" "2"') tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'b') @@ -206,179 +210,200 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEqual(tracks[3].name, 'c') self.assertEqual(tracks[4].name, 'd') self.assertEqual(tracks[5].name, 'f') - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_playlist_returns_same_as_playlistinfo(self): - playlist_result = self.dispatcher.handle_request(u'playlist') - playlistinfo_result = self.dispatcher.handle_request(u'playlistinfo') - self.assertEqual(playlist_result, playlistinfo_result) + playlist_response = self.sendRequest(u'playlist') + playlistinfo_response = self.sendRequest(u'playlistinfo') + self.assertEqual(playlist_response, playlistinfo_response) def test_playlistfind(self): - result = self.dispatcher.handle_request(u'playlistfind "tag" "needle"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) + self.sendRequest(u'playlistfind "tag" "needle"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') def test_playlistfind_by_filename_not_in_current_playlist(self): - result = self.dispatcher.handle_request( - u'playlistfind "filename" "file:///dev/null"') - self.assertEqual(len(result), 1) - self.assert_(u'OK' in result) + self.sendRequest(u'playlistfind "filename" "file:///dev/null"') + self.assertEqualResponse(u'OK') def test_playlistfind_by_filename_without_quotes(self): - result = self.dispatcher.handle_request( - u'playlistfind filename "file:///dev/null"') - self.assertEqual(len(result), 1) - self.assert_(u'OK' in result) + self.sendRequest(u'playlistfind filename "file:///dev/null"') + self.assertEqualResponse(u'OK') def test_playlistfind_by_filename_in_current_playlist(self): self.backend.current_playlist.append([ Track(uri='file:///exists')]) - result = self.dispatcher.handle_request( - u'playlistfind filename "file:///exists"') - self.assert_(u'file: file:///exists' in result) - self.assert_(u'Id: 0' in result) - self.assert_(u'Pos: 0' in result) - self.assert_(u'OK' in result) + + self.sendRequest( u'playlistfind filename "file:///exists"') + self.assertInResponse(u'file: file:///exists') + self.assertInResponse(u'Id: 0') + self.assertInResponse(u'Pos: 0') + self.assertInResponse(u'OK') def test_playlistid_without_songid(self): self.backend.current_playlist.append([Track(name='a'), Track(name='b')]) - result = self.dispatcher.handle_request(u'playlistid') - self.assert_(u'Title: a' in result) - self.assert_(u'Title: b' in result) - self.assert_(u'OK' in result) + + self.sendRequest(u'playlistid') + self.assertInResponse(u'Title: a') + self.assertInResponse(u'Title: b') + self.assertInResponse(u'OK') def test_playlistid_with_songid(self): self.backend.current_playlist.append([Track(name='a'), Track(name='b')]) - result = self.dispatcher.handle_request(u'playlistid "1"') - self.assert_(u'Title: a' not in result) - self.assert_(u'Id: 0' not in result) - self.assert_(u'Title: b' in result) - self.assert_(u'Id: 1' in result) - self.assert_(u'OK' in result) + + self.sendRequest(u'playlistid "1"') + self.assertNotInResponse(u'Title: a') + self.assertNotInResponse(u'Id: 0') + self.assertInResponse(u'Title: b') + self.assertInResponse(u'Id: 1') + self.assertInResponse(u'OK') def test_playlistid_with_not_existing_songid_fails(self): self.backend.current_playlist.append([Track(name='a'), Track(name='b')]) - result = self.dispatcher.handle_request(u'playlistid "25"') - self.assertEqual(result[0], u'ACK [50@0] {playlistid} No such song') + + self.sendRequest(u'playlistid "25"') + self.assertEqualResponse(u'ACK [50@0] {playlistid} No such song') def test_playlistinfo_without_songpos_or_range(self): self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.dispatcher.handle_request(u'playlistinfo') - self.assert_(u'Title: a' in result) - self.assert_(u'Title: b' in result) - self.assert_(u'Title: c' in result) - self.assert_(u'Title: d' in result) - self.assert_(u'Title: e' in result) - self.assert_(u'Title: f' in result) - self.assert_(u'OK' in result) + + 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): self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.dispatcher.handle_request(u'playlistinfo "4"') - self.assert_(u'Title: a' not in result) - self.assert_(u'Title: b' not in result) - self.assert_(u'Title: c' not in result) - self.assert_(u'Title: d' not in result) - self.assert_(u'Title: e' in result) - self.assert_(u'Title: f' not in result) - self.assert_(u'OK' in result) + + 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): - result1 = self.dispatcher.handle_request(u'playlistinfo "-1"') - result2 = self.dispatcher.handle_request(u'playlistinfo') - self.assertEqual(result1, result2) + response1 = self.sendRequest(u'playlistinfo "-1"') + response2 = self.sendRequest(u'playlistinfo') + self.assertEqual(response1, response2) def test_playlistinfo_with_open_range(self): self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.dispatcher.handle_request(u'playlistinfo "2:"') - self.assert_(u'Title: a' not in result) - self.assert_(u'Title: b' not in result) - self.assert_(u'Title: c' in result) - self.assert_(u'Title: d' in result) - self.assert_(u'Title: e' in result) - self.assert_(u'Title: f' in result) - self.assert_(u'OK' in result) + + 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): self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.dispatcher.handle_request(u'playlistinfo "2:4"') - self.assert_(u'Title: a' not in result) - self.assert_(u'Title: b' not in result) - self.assert_(u'Title: c' in result) - self.assert_(u'Title: d' in result) - self.assert_(u'Title: e' not in result) - self.assert_(u'Title: f' not in result) - self.assert_(u'OK' in result) + + self.sendRequest(u'playlistinfo "2:4"') + self.assertNotInResponse(u'Title: a') + self.assertNotInResponse(u'Title: b') + self.assertInResponse(u'Title: c') + self.assertInResponse(u'Title: d') + self.assertNotInResponse(u'Title: e') + self.assertNotInResponse(u'Title: f') + self.assertInResponse(u'OK') def test_playlistinfo_with_too_high_start_of_range_returns_arg_error(self): - result = self.dispatcher.handle_request(u'playlistinfo "10:20"') - self.assert_(u'ACK [2@0] {playlistinfo} Bad song index' in result) + self.sendRequest(u'playlistinfo "10:20"') + self.assertEqualResponse(u'ACK [2@0] {playlistinfo} Bad song index') def test_playlistinfo_with_too_high_end_of_range_returns_ok(self): - result = self.dispatcher.handle_request(u'playlistinfo "0:20"') - self.assert_(u'OK' in result) + self.sendRequest(u'playlistinfo "0:20"') + self.assertInResponse(u'OK') def test_playlistsearch(self): - result = self.dispatcher.handle_request( - u'playlistsearch "any" "needle"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) + self.sendRequest( u'playlistsearch "any" "needle"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') def test_playlistsearch_without_quotes(self): - result = self.dispatcher.handle_request(u'playlistsearch any "needle"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) + self.sendRequest(u'playlistsearch any "needle"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') def test_plchanges(self): self.backend.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) - result = self.dispatcher.handle_request(u'plchanges "0"') - self.assert_(u'Title: a' in result) - self.assert_(u'Title: b' in result) - self.assert_(u'Title: c' in result) - self.assert_(u'OK' in result) + + self.sendRequest(u'plchanges "0"') + self.assertInResponse(u'Title: a') + self.assertInResponse(u'Title: b') + self.assertInResponse(u'Title: c') + self.assertInResponse(u'OK') def test_plchanges_with_minus_one_returns_entire_playlist(self): self.backend.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) - result = self.dispatcher.handle_request(u'plchanges "-1"') - self.assert_(u'Title: a' in result) - self.assert_(u'Title: b' in result) - self.assert_(u'Title: c' in result) - self.assert_(u'OK' in result) + + self.sendRequest(u'plchanges "-1"') + self.assertInResponse(u'Title: a') + self.assertInResponse(u'Title: b') + self.assertInResponse(u'Title: c') + self.assertInResponse(u'OK') def test_plchanges_without_quotes_works(self): self.backend.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) - result = self.dispatcher.handle_request(u'plchanges 0') - self.assert_(u'Title: a' in result) - self.assert_(u'Title: b' in result) - self.assert_(u'Title: c' in result) - self.assert_(u'OK' in result) + + self.sendRequest(u'plchanges 0') + self.assertInResponse(u'Title: a') + self.assertInResponse(u'Title: b') + self.assertInResponse(u'Title: c') + self.assertInResponse(u'OK') def test_plchangesposid(self): self.backend.current_playlist.append([Track(), Track(), Track()]) - result = self.dispatcher.handle_request(u'plchangesposid "0"') + + self.sendRequest(u'plchangesposid "0"') cp_tracks = self.backend.current_playlist.cp_tracks.get() - self.assert_(u'cpos: 0' in result) - self.assert_(u'Id: %d' % cp_tracks[0][0] - in result) - self.assert_(u'cpos: 2' in result) - self.assert_(u'Id: %d' % cp_tracks[1][0] - in result) - self.assert_(u'cpos: 2' in result) - self.assert_(u'Id: %d' % cp_tracks[2][0] - in result) - self.assert_(u'OK' in result) + self.assertInResponse(u'cpos: 0') + self.assertInResponse(u'Id: %d' % cp_tracks[0][0]) + self.assertInResponse(u'cpos: 2') + self.assertInResponse(u'Id: %d' % cp_tracks[1][0]) + self.assertInResponse(u'cpos: 2') + self.assertInResponse(u'Id: %d' % cp_tracks[2][0]) + self.assertInResponse(u'OK') def test_shuffle_without_range(self): self.backend.current_playlist.append([ @@ -386,9 +411,10 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) version = self.backend.current_playlist.version.get() - result = self.dispatcher.handle_request(u'shuffle') + + self.sendRequest(u'shuffle') self.assert_(version < self.backend.current_playlist.version.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_shuffle_with_open_range(self): self.backend.current_playlist.append([ @@ -396,14 +422,15 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) version = self.backend.current_playlist.version.get() - result = self.dispatcher.handle_request(u'shuffle "4:"') + + self.sendRequest(u'shuffle "4:"') self.assert_(version < self.backend.current_playlist.version.get()) tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'b') self.assertEqual(tracks[2].name, 'c') self.assertEqual(tracks[3].name, 'd') - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_shuffle_with_closed_range(self): self.backend.current_playlist.append([ @@ -411,21 +438,23 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) version = self.backend.current_playlist.version.get() - result = self.dispatcher.handle_request(u'shuffle "1:3"') + + self.sendRequest(u'shuffle "1:3"') self.assert_(version < self.backend.current_playlist.version.get()) tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[4].name, 'e') self.assertEqual(tracks[5].name, 'f') - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_swap(self): self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.dispatcher.handle_request(u'swap "1" "4"') + + self.sendRequest(u'swap "1" "4"') tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'e') @@ -433,14 +462,15 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[4].name, 'b') self.assertEqual(tracks[5].name, 'f') - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_swapid(self): self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.dispatcher.handle_request(u'swapid "1" "4"') + + self.sendRequest(u'swapid "1" "4"') tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'e') @@ -448,4 +478,4 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[4].name, 'b') self.assertEqual(tracks[5].name, 'f') - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') diff --git a/tests/frontends/mpd/protocol/idle_test.py b/tests/frontends/mpd/protocol/idle_test.py new file mode 100644 index 00000000..ae23c88e --- /dev/null +++ b/tests/frontends/mpd/protocol/idle_test.py @@ -0,0 +1,206 @@ +from mock import patch + +from mopidy.frontends.mpd.protocol.status import SUBSYSTEMS + +from tests.frontends.mpd import protocol + + +class IdleHandlerTest(protocol.BaseTestCase): + def idleEvent(self, subsystem): + self.session.on_idle(subsystem) + + def assertEqualEvents(self, events): + self.assertEqual(set(events), self.context.events) + + def assertEqualSubscriptions(self, events): + self.assertEqual(set(events), self.context.subscriptions) + + def assertNoEvents(self): + self.assertEqualEvents([]) + + def assertNoSubscriptions(self): + self.assertEqualSubscriptions([]) + + def test_base_state(self): + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertNoResponse() + + def test_idle(self): + self.sendRequest(u'idle') + self.assertEqualSubscriptions(SUBSYSTEMS) + self.assertNoEvents() + self.assertNoResponse() + + def test_idle_disables_timeout(self): + self.sendRequest(u'idle') + self.connection.disable_timeout.assert_called_once_with() + + def test_noidle(self): + self.sendRequest(u'noidle') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertNoResponse() + + def test_idle_player(self): + self.sendRequest(u'idle player') + self.assertEqualSubscriptions(['player']) + self.assertNoEvents() + self.assertNoResponse() + + def test_idle_player_playlist(self): + self.sendRequest(u'idle player playlist') + self.assertEqualSubscriptions(['player', 'playlist']) + self.assertNoEvents() + self.assertNoResponse() + + def test_idle_then_noidle(self): + self.sendRequest(u'idle') + self.sendRequest(u'noidle') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse(u'OK') + + def test_idle_then_noidle_enables_timeout(self): + self.sendRequest(u'idle') + self.sendRequest(u'noidle') + self.connection.enable_timeout.assert_called_once_with() + + def test_idle_then_play(self): + with patch.object(self.session, 'stop') as stop_mock: + self.sendRequest(u'idle') + self.sendRequest(u'play') + stop_mock.assert_called_once_with() + + def test_idle_then_idle(self): + with patch.object(self.session, 'stop') as stop_mock: + self.sendRequest(u'idle') + self.sendRequest(u'idle') + stop_mock.assert_called_once_with() + + def test_idle_player_then_play(self): + with patch.object(self.session, 'stop') as stop_mock: + self.sendRequest(u'idle player') + self.sendRequest(u'play') + stop_mock.assert_called_once_with() + + def test_idle_then_player(self): + self.sendRequest(u'idle') + self.idleEvent(u'player') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse(u'changed: player') + self.assertOnceInResponse(u'OK') + + def test_idle_player_then_event_player(self): + self.sendRequest(u'idle player') + self.idleEvent(u'player') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse(u'changed: player') + self.assertOnceInResponse(u'OK') + + def test_idle_player_then_noidle(self): + self.sendRequest(u'idle player') + self.sendRequest(u'noidle') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse(u'OK') + + def test_idle_player_playlist_then_noidle(self): + self.sendRequest(u'idle player playlist') + self.sendRequest(u'noidle') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse(u'OK') + + def test_idle_player_playlist_then_player(self): + self.sendRequest(u'idle player playlist') + self.idleEvent(u'player') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse(u'changed: player') + self.assertNotInResponse(u'changed: playlist') + self.assertOnceInResponse(u'OK') + + def test_idle_playlist_then_player(self): + self.sendRequest(u'idle playlist') + self.idleEvent(u'player') + self.assertEqualEvents(['player']) + self.assertEqualSubscriptions(['playlist']) + self.assertNoResponse() + + def test_idle_playlist_then_player_then_playlist(self): + self.sendRequest(u'idle playlist') + self.idleEvent(u'player') + self.idleEvent(u'playlist') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertNotInResponse(u'changed: player') + self.assertOnceInResponse(u'changed: playlist') + self.assertOnceInResponse(u'OK') + + def test_player(self): + self.idleEvent(u'player') + self.assertEqualEvents(['player']) + self.assertNoSubscriptions() + self.assertNoResponse() + + def test_player_then_idle_player(self): + self.idleEvent(u'player') + self.sendRequest(u'idle player') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse(u'changed: player') + self.assertNotInResponse(u'changed: playlist') + self.assertOnceInResponse(u'OK') + + def test_player_then_playlist(self): + self.idleEvent(u'player') + self.idleEvent(u'playlist') + self.assertEqualEvents(['player', 'playlist']) + self.assertNoSubscriptions() + self.assertNoResponse() + + def test_player_then_idle(self): + self.idleEvent(u'player') + self.sendRequest(u'idle') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse(u'changed: player') + self.assertOnceInResponse(u'OK') + + def test_player_then_playlist_then_idle(self): + self.idleEvent(u'player') + self.idleEvent(u'playlist') + self.sendRequest(u'idle') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse(u'changed: player') + self.assertOnceInResponse(u'changed: playlist') + self.assertOnceInResponse(u'OK') + + def test_player_then_idle_playlist(self): + self.idleEvent(u'player') + self.sendRequest(u'idle playlist') + self.assertEqualEvents(['player']) + self.assertEqualSubscriptions(['playlist']) + self.assertNoResponse() + + def test_player_then_idle_playlist_then_noidle(self): + self.idleEvent(u'player') + self.sendRequest(u'idle playlist') + self.sendRequest(u'noidle') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse(u'OK') + + def test_player_then_playlist_then_idle_playlist(self): + self.idleEvent(u'player') + self.idleEvent(u'playlist') + self.sendRequest(u'idle playlist') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertNotInResponse(u'changed: player') + self.assertOnceInResponse(u'changed: playlist') + self.assertOnceInResponse(u'OK') diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py new file mode 100644 index 00000000..088502c4 --- /dev/null +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -0,0 +1,344 @@ +from tests.frontends.mpd import protocol + + +class MusicDatabaseHandlerTest(protocol.BaseTestCase): + def test_count(self): + self.sendRequest(u'count "tag" "needle"') + self.assertInResponse(u'songs: 0') + self.assertInResponse(u'playtime: 0') + self.assertInResponse(u'OK') + + def test_findadd(self): + self.sendRequest(u'findadd "album" "what"') + self.assertInResponse(u'OK') + + def test_listall(self): + self.sendRequest(u'listall "file:///dev/urandom"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_listallinfo(self): + self.sendRequest(u'listallinfo "file:///dev/urandom"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_lsinfo_without_path_returns_same_as_listplaylists(self): + lsinfo_response = self.sendRequest(u'lsinfo') + listplaylists_response = self.sendRequest(u'listplaylists') + self.assertEqual(lsinfo_response, listplaylists_response) + + def test_lsinfo_with_empty_path_returns_same_as_listplaylists(self): + lsinfo_response = self.sendRequest(u'lsinfo ""') + listplaylists_response = self.sendRequest(u'listplaylists') + self.assertEqual(lsinfo_response, listplaylists_response) + + def test_lsinfo_for_root_returns_same_as_listplaylists(self): + lsinfo_response = self.sendRequest(u'lsinfo "/"') + listplaylists_response = self.sendRequest(u'listplaylists') + self.assertEqual(lsinfo_response, listplaylists_response) + + def test_update_without_uri(self): + self.sendRequest(u'update') + self.assertInResponse(u'updating_db: 0') + self.assertInResponse(u'OK') + + def test_update_with_uri(self): + self.sendRequest(u'update "file:///dev/urandom"') + self.assertInResponse(u'updating_db: 0') + self.assertInResponse(u'OK') + + def test_rescan_without_uri(self): + self.sendRequest(u'rescan') + self.assertInResponse(u'updating_db: 0') + self.assertInResponse(u'OK') + + def test_rescan_with_uri(self): + self.sendRequest(u'rescan "file:///dev/urandom"') + self.assertInResponse(u'updating_db: 0') + self.assertInResponse(u'OK') + + +class MusicDatabaseFindTest(protocol.BaseTestCase): + def test_find_album(self): + self.sendRequest(u'find "album" "what"') + self.assertInResponse(u'OK') + + def test_find_album_without_quotes(self): + self.sendRequest(u'find album "what"') + self.assertInResponse(u'OK') + + def test_find_artist(self): + self.sendRequest(u'find "artist" "what"') + self.assertInResponse(u'OK') + + def test_find_artist_without_quotes(self): + self.sendRequest(u'find artist "what"') + self.assertInResponse(u'OK') + + def test_find_title(self): + self.sendRequest(u'find "title" "what"') + self.assertInResponse(u'OK') + + def test_find_title_without_quotes(self): + self.sendRequest(u'find title "what"') + self.assertInResponse(u'OK') + + def test_find_date(self): + self.sendRequest(u'find "date" "2002-01-01"') + self.assertInResponse(u'OK') + + def test_find_date_without_quotes(self): + self.sendRequest(u'find date "2002-01-01"') + self.assertInResponse(u'OK') + + def test_find_date_with_capital_d_and_incomplete_date(self): + self.sendRequest(u'find Date "2005"') + self.assertInResponse(u'OK') + + def test_find_else_should_fail(self): + self.sendRequest(u'find "somethingelse" "what"') + self.assertEqualResponse(u'ACK [2@0] {find} incorrect arguments') + + def test_find_album_and_artist(self): + self.sendRequest(u'find album "album_what" artist "artist_what"') + self.assertInResponse(u'OK') + + +class MusicDatabaseListTest(protocol.BaseTestCase): + def test_list_foo_returns_ack(self): + self.sendRequest(u'list "foo"') + self.assertEqualResponse(u'ACK [2@0] {list} incorrect arguments') + + ### Artist + + def test_list_artist_with_quotes(self): + self.sendRequest(u'list "artist"') + self.assertInResponse(u'OK') + + def test_list_artist_without_quotes(self): + self.sendRequest(u'list artist') + self.assertInResponse(u'OK') + + def test_list_artist_without_quotes_and_capitalized(self): + self.sendRequest(u'list Artist') + self.assertInResponse(u'OK') + + def test_list_artist_with_query_of_one_token(self): + self.sendRequest(u'list "artist" "anartist"') + self.assertEqualResponse( + u'ACK [2@0] {list} should be "Album" for 3 arguments') + + def test_list_artist_with_unknown_field_in_query_returns_ack(self): + self.sendRequest(u'list "artist" "foo" "bar"') + self.assertEqualResponse(u'ACK [2@0] {list} not able to parse args') + + def test_list_artist_by_artist(self): + self.sendRequest(u'list "artist" "artist" "anartist"') + self.assertInResponse(u'OK') + + def test_list_artist_by_album(self): + self.sendRequest(u'list "artist" "album" "analbum"') + self.assertInResponse(u'OK') + + def test_list_artist_by_full_date(self): + self.sendRequest(u'list "artist" "date" "2001-01-01"') + self.assertInResponse(u'OK') + + def test_list_artist_by_year(self): + self.sendRequest(u'list "artist" "date" "2001"') + self.assertInResponse(u'OK') + + def test_list_artist_by_genre(self): + self.sendRequest(u'list "artist" "genre" "agenre"') + self.assertInResponse(u'OK') + + def test_list_artist_by_artist_and_album(self): + self.sendRequest( + u'list "artist" "artist" "anartist" "album" "analbum"') + self.assertInResponse(u'OK') + + ### Album + + def test_list_album_with_quotes(self): + self.sendRequest(u'list "album"') + self.assertInResponse(u'OK') + + def test_list_album_without_quotes(self): + self.sendRequest(u'list album') + self.assertInResponse(u'OK') + + def test_list_album_without_quotes_and_capitalized(self): + self.sendRequest(u'list Album') + self.assertInResponse(u'OK') + + def test_list_album_with_artist_name(self): + self.sendRequest(u'list "album" "anartist"') + self.assertInResponse(u'OK') + + def test_list_album_by_artist(self): + self.sendRequest(u'list "album" "artist" "anartist"') + self.assertInResponse(u'OK') + + def test_list_album_by_album(self): + self.sendRequest(u'list "album" "album" "analbum"') + self.assertInResponse(u'OK') + + def test_list_album_by_full_date(self): + self.sendRequest(u'list "album" "date" "2001-01-01"') + self.assertInResponse(u'OK') + + def test_list_album_by_year(self): + self.sendRequest(u'list "album" "date" "2001"') + self.assertInResponse(u'OK') + + def test_list_album_by_genre(self): + self.sendRequest(u'list "album" "genre" "agenre"') + self.assertInResponse(u'OK') + + def test_list_album_by_artist_and_album(self): + self.sendRequest( + u'list "album" "artist" "anartist" "album" "analbum"') + self.assertInResponse(u'OK') + + ### Date + + def test_list_date_with_quotes(self): + self.sendRequest(u'list "date"') + self.assertInResponse(u'OK') + + def test_list_date_without_quotes(self): + self.sendRequest(u'list date') + self.assertInResponse(u'OK') + + def test_list_date_without_quotes_and_capitalized(self): + self.sendRequest(u'list Date') + self.assertInResponse(u'OK') + + def test_list_date_with_query_of_one_token(self): + self.sendRequest(u'list "date" "anartist"') + self.assertEqualResponse( + u'ACK [2@0] {list} should be "Album" for 3 arguments') + + def test_list_date_by_artist(self): + self.sendRequest(u'list "date" "artist" "anartist"') + self.assertInResponse(u'OK') + + def test_list_date_by_album(self): + self.sendRequest(u'list "date" "album" "analbum"') + self.assertInResponse(u'OK') + + def test_list_date_by_full_date(self): + self.sendRequest(u'list "date" "date" "2001-01-01"') + self.assertInResponse(u'OK') + + def test_list_date_by_year(self): + self.sendRequest(u'list "date" "date" "2001"') + self.assertInResponse(u'OK') + + def test_list_date_by_genre(self): + self.sendRequest(u'list "date" "genre" "agenre"') + self.assertInResponse(u'OK') + + def test_list_date_by_artist_and_album(self): + self.sendRequest(u'list "date" "artist" "anartist" "album" "analbum"') + self.assertInResponse(u'OK') + + ### Genre + + def test_list_genre_with_quotes(self): + self.sendRequest(u'list "genre"') + self.assertInResponse(u'OK') + + def test_list_genre_without_quotes(self): + self.sendRequest(u'list genre') + self.assertInResponse(u'OK') + + def test_list_genre_without_quotes_and_capitalized(self): + self.sendRequest(u'list Genre') + self.assertInResponse(u'OK') + + def test_list_genre_with_query_of_one_token(self): + self.sendRequest(u'list "genre" "anartist"') + self.assertEqualResponse( + u'ACK [2@0] {list} should be "Album" for 3 arguments') + + def test_list_genre_by_artist(self): + self.sendRequest(u'list "genre" "artist" "anartist"') + self.assertInResponse(u'OK') + + def test_list_genre_by_album(self): + self.sendRequest(u'list "genre" "album" "analbum"') + self.assertInResponse(u'OK') + + def test_list_genre_by_full_date(self): + self.sendRequest(u'list "genre" "date" "2001-01-01"') + self.assertInResponse(u'OK') + + def test_list_genre_by_year(self): + self.sendRequest(u'list "genre" "date" "2001"') + self.assertInResponse(u'OK') + + def test_list_genre_by_genre(self): + self.sendRequest(u'list "genre" "genre" "agenre"') + self.assertInResponse(u'OK') + + def test_list_genre_by_artist_and_album(self): + self.sendRequest( + u'list "genre" "artist" "anartist" "album" "analbum"') + self.assertInResponse(u'OK') + + +class MusicDatabaseSearchTest(protocol.BaseTestCase): + def test_search_album(self): + self.sendRequest(u'search "album" "analbum"') + self.assertInResponse(u'OK') + + def test_search_album_without_quotes(self): + self.sendRequest(u'search album "analbum"') + self.assertInResponse(u'OK') + + def test_search_artist(self): + self.sendRequest(u'search "artist" "anartist"') + self.assertInResponse(u'OK') + + def test_search_artist_without_quotes(self): + self.sendRequest(u'search artist "anartist"') + self.assertInResponse(u'OK') + + def test_search_filename(self): + self.sendRequest(u'search "filename" "afilename"') + self.assertInResponse(u'OK') + + def test_search_filename_without_quotes(self): + self.sendRequest(u'search filename "afilename"') + self.assertInResponse(u'OK') + + def test_search_title(self): + self.sendRequest(u'search "title" "atitle"') + self.assertInResponse(u'OK') + + def test_search_title_without_quotes(self): + self.sendRequest(u'search title "atitle"') + self.assertInResponse(u'OK') + + def test_search_any(self): + self.sendRequest(u'search "any" "anything"') + self.assertInResponse(u'OK') + + def test_search_any_without_quotes(self): + self.sendRequest(u'search any "anything"') + self.assertInResponse(u'OK') + + def test_search_date(self): + self.sendRequest(u'search "date" "2002-01-01"') + self.assertInResponse(u'OK') + + def test_search_date_without_quotes(self): + self.sendRequest(u'search date "2002-01-01"') + self.assertInResponse(u'OK') + + def test_search_date_with_capital_d_and_incomplete_date(self): + self.sendRequest(u'search Date "2005"') + self.assertInResponse(u'OK') + + def test_search_else_should_fail(self): + self.sendRequest(u'search "sometype" "something"') + self.assertEqualResponse(u'ACK [2@0] {search} incorrect arguments') diff --git a/tests/frontends/mpd/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py similarity index 53% rename from tests/frontends/mpd/playback_test.py rename to tests/frontends/mpd/protocol/playback_test.py index e80943d6..01658f6d 100644 --- a/tests/frontends/mpd/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -1,247 +1,238 @@ -import unittest - -from mopidy.backends.base import PlaybackController -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.mixers.dummy import DummyMixer +from mopidy.backends import base as backend from mopidy.models import Track -from tests import SkipTest +from tests import unittest +from tests.frontends.mpd import protocol -PAUSED = PlaybackController.PAUSED -PLAYING = PlaybackController.PLAYING -STOPPED = PlaybackController.STOPPED +PAUSED = backend.PlaybackController.PAUSED +PLAYING = backend.PlaybackController.PLAYING +STOPPED = backend.PlaybackController.STOPPED -class PlaybackOptionsHandlerTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() +class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_consume_off(self): - result = self.dispatcher.handle_request(u'consume "0"') + self.sendRequest(u'consume "0"') self.assertFalse(self.backend.playback.consume.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_consume_off_without_quotes(self): - result = self.dispatcher.handle_request(u'consume 0') + self.sendRequest(u'consume 0') self.assertFalse(self.backend.playback.consume.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_consume_on(self): - result = self.dispatcher.handle_request(u'consume "1"') + self.sendRequest(u'consume "1"') self.assertTrue(self.backend.playback.consume.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_consume_on_without_quotes(self): - result = self.dispatcher.handle_request(u'consume 1') + self.sendRequest(u'consume 1') self.assertTrue(self.backend.playback.consume.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_crossfade(self): - result = self.dispatcher.handle_request(u'crossfade "10"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) + self.sendRequest(u'crossfade "10"') + self.assertInResponse(u'ACK [0@0] {} Not implemented') def test_random_off(self): - result = self.dispatcher.handle_request(u'random "0"') + self.sendRequest(u'random "0"') self.assertFalse(self.backend.playback.random.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_random_off_without_quotes(self): - result = self.dispatcher.handle_request(u'random 0') + self.sendRequest(u'random 0') self.assertFalse(self.backend.playback.random.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_random_on(self): - result = self.dispatcher.handle_request(u'random "1"') + self.sendRequest(u'random "1"') self.assertTrue(self.backend.playback.random.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_random_on_without_quotes(self): - result = self.dispatcher.handle_request(u'random 1') + self.sendRequest(u'random 1') self.assertTrue(self.backend.playback.random.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_repeat_off(self): - result = self.dispatcher.handle_request(u'repeat "0"') + self.sendRequest(u'repeat "0"') self.assertFalse(self.backend.playback.repeat.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_repeat_off_without_quotes(self): - result = self.dispatcher.handle_request(u'repeat 0') + self.sendRequest(u'repeat 0') self.assertFalse(self.backend.playback.repeat.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_repeat_on(self): - result = self.dispatcher.handle_request(u'repeat "1"') + self.sendRequest(u'repeat "1"') self.assertTrue(self.backend.playback.repeat.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_repeat_on_without_quotes(self): - result = self.dispatcher.handle_request(u'repeat 1') + self.sendRequest(u'repeat 1') self.assertTrue(self.backend.playback.repeat.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_setvol_below_min(self): - result = self.dispatcher.handle_request(u'setvol "-10"') - self.assert_(u'OK' in result) + self.sendRequest(u'setvol "-10"') self.assertEqual(0, self.mixer.volume.get()) + self.assertInResponse(u'OK') def test_setvol_min(self): - result = self.dispatcher.handle_request(u'setvol "0"') - self.assert_(u'OK' in result) + self.sendRequest(u'setvol "0"') self.assertEqual(0, self.mixer.volume.get()) + self.assertInResponse(u'OK') def test_setvol_middle(self): - result = self.dispatcher.handle_request(u'setvol "50"') - self.assert_(u'OK' in result) + self.sendRequest(u'setvol "50"') self.assertEqual(50, self.mixer.volume.get()) + self.assertInResponse(u'OK') def test_setvol_max(self): - result = self.dispatcher.handle_request(u'setvol "100"') - self.assert_(u'OK' in result) + self.sendRequest(u'setvol "100"') self.assertEqual(100, self.mixer.volume.get()) + self.assertInResponse(u'OK') def test_setvol_above_max(self): - result = self.dispatcher.handle_request(u'setvol "110"') - self.assert_(u'OK' in result) + self.sendRequest(u'setvol "110"') self.assertEqual(100, self.mixer.volume.get()) + self.assertInResponse(u'OK') def test_setvol_plus_is_ignored(self): - result = self.dispatcher.handle_request(u'setvol "+10"') - self.assert_(u'OK' in result) + self.sendRequest(u'setvol "+10"') self.assertEqual(10, self.mixer.volume.get()) + self.assertInResponse(u'OK') def test_setvol_without_quotes(self): - result = self.dispatcher.handle_request(u'setvol 50') - self.assert_(u'OK' in result) + self.sendRequest(u'setvol 50') self.assertEqual(50, self.mixer.volume.get()) + self.assertInResponse(u'OK') def test_single_off(self): - result = self.dispatcher.handle_request(u'single "0"') + self.sendRequest(u'single "0"') self.assertFalse(self.backend.playback.single.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_single_off_without_quotes(self): - result = self.dispatcher.handle_request(u'single 0') + self.sendRequest(u'single 0') self.assertFalse(self.backend.playback.single.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_single_on(self): - result = self.dispatcher.handle_request(u'single "1"') + self.sendRequest(u'single "1"') self.assertTrue(self.backend.playback.single.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_single_on_without_quotes(self): - result = self.dispatcher.handle_request(u'single 1') + self.sendRequest(u'single 1') self.assertTrue(self.backend.playback.single.get()) - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') def test_replay_gain_mode_off(self): - result = self.dispatcher.handle_request(u'replay_gain_mode "off"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) + self.sendRequest(u'replay_gain_mode "off"') + self.assertInResponse(u'ACK [0@0] {} Not implemented') def test_replay_gain_mode_track(self): - result = self.dispatcher.handle_request(u'replay_gain_mode "track"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) + self.sendRequest(u'replay_gain_mode "track"') + self.assertInResponse(u'ACK [0@0] {} Not implemented') def test_replay_gain_mode_album(self): - result = self.dispatcher.handle_request(u'replay_gain_mode "album"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) + self.sendRequest(u'replay_gain_mode "album"') + self.assertInResponse(u'ACK [0@0] {} Not implemented') def test_replay_gain_status_default(self): - expected = u'off' - result = self.dispatcher.handle_request(u'replay_gain_status') - self.assert_(u'OK' in result) - self.assert_(expected in result) + self.sendRequest(u'replay_gain_status') + self.assertInResponse(u'OK') + self.assertInResponse(u'off') + @unittest.SkipTest def test_replay_gain_status_off(self): - raise SkipTest # TODO + pass + @unittest.SkipTest def test_replay_gain_status_track(self): - raise SkipTest # TODO + pass + @unittest.SkipTest def test_replay_gain_status_album(self): - raise SkipTest # TODO + pass -class PlaybackControlHandlerTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - +class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_next(self): - result = self.dispatcher.handle_request(u'next') - self.assert_(u'OK' in result) + self.sendRequest(u'next') + self.assertInResponse(u'OK') def test_pause_off(self): self.backend.current_playlist.append([Track()]) - self.dispatcher.handle_request(u'play "0"') - self.dispatcher.handle_request(u'pause "1"') - result = self.dispatcher.handle_request(u'pause "0"') - self.assert_(u'OK' in result) + + self.sendRequest(u'play "0"') + self.sendRequest(u'pause "1"') + self.sendRequest(u'pause "0"') self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertInResponse(u'OK') def test_pause_on(self): self.backend.current_playlist.append([Track()]) - self.dispatcher.handle_request(u'play "0"') - result = self.dispatcher.handle_request(u'pause "1"') - self.assert_(u'OK' in result) + + self.sendRequest(u'play "0"') + self.sendRequest(u'pause "1"') self.assertEqual(PAUSED, self.backend.playback.state.get()) + self.assertInResponse(u'OK') def test_pause_toggle(self): self.backend.current_playlist.append([Track()]) - result = self.dispatcher.handle_request(u'play "0"') - self.assert_(u'OK' in result) + + self.sendRequest(u'play "0"') self.assertEqual(PLAYING, self.backend.playback.state.get()) - result = self.dispatcher.handle_request(u'pause') - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') + + self.sendRequest(u'pause') self.assertEqual(PAUSED, self.backend.playback.state.get()) - result = self.dispatcher.handle_request(u'pause') - self.assert_(u'OK' in result) + self.assertInResponse(u'OK') + + self.sendRequest(u'pause') self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertInResponse(u'OK') def test_play_without_pos(self): self.backend.current_playlist.append([Track()]) self.backend.playback.state = PAUSED - result = self.dispatcher.handle_request(u'play') - self.assert_(u'OK' in result) + + self.sendRequest(u'play') self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertInResponse(u'OK') def test_play_with_pos(self): self.backend.current_playlist.append([Track()]) - result = self.dispatcher.handle_request(u'play "0"') - self.assert_(u'OK' in result) + + self.sendRequest(u'play "0"') self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertInResponse(u'OK') def test_play_with_pos_without_quotes(self): self.backend.current_playlist.append([Track()]) - result = self.dispatcher.handle_request(u'play 0') - self.assert_(u'OK' in result) + + self.sendRequest(u'play 0') self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertInResponse(u'OK') def test_play_with_pos_out_of_bounds(self): self.backend.current_playlist.append([]) - result = self.dispatcher.handle_request(u'play "0"') - self.assertEqual(result[0], u'ACK [2@0] {play} Bad song index') + + self.sendRequest(u'play "0"') self.assertEqual(STOPPED, self.backend.playback.state.get()) + self.assertInResponse(u'ACK [2@0] {play} Bad song index') def test_play_minus_one_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.backend.playback.current_track.get(), None) self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - result = self.dispatcher.handle_request(u'play "-1"') - self.assert_(u'OK' in result) + + self.sendRequest(u'play "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertEqual(self.backend.playback.current_track.get().uri, 'a') + self.assertEqual('a', self.backend.playback.current_track.get().uri) + self.assertInResponse(u'OK') def test_play_minus_one_plays_current_track_if_current_track_is_set(self): self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) @@ -250,27 +241,30 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.backend.playback.next() self.backend.playback.stop() self.assertNotEqual(self.backend.playback.current_track.get(), None) - result = self.dispatcher.handle_request(u'play "-1"') - self.assert_(u'OK' in result) + + self.sendRequest(u'play "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertEqual(self.backend.playback.current_track.get().uri, 'b') + self.assertEqual('b', self.backend.playback.current_track.get().uri) + self.assertInResponse(u'OK') def test_play_minus_one_on_empty_playlist_does_not_ack(self): self.backend.current_playlist.clear() - result = self.dispatcher.handle_request(u'play "-1"') - self.assert_(u'OK' in result) + + self.sendRequest(u'play "-1"') self.assertEqual(STOPPED, self.backend.playback.state.get()) - self.assertEqual(self.backend.playback.current_track.get(), None) + self.assertEqual(None, self.backend.playback.current_track.get()) + self.assertInResponse(u'OK') def test_play_minus_is_ignored_if_playing(self): self.backend.current_playlist.append([Track(length=40000)]) self.backend.playback.seek(30000) self.assert_(self.backend.playback.time_position.get() >= 30000) self.assertEquals(PLAYING, self.backend.playback.state.get()) - result = self.dispatcher.handle_request(u'play "-1"') - self.assert_(u'OK' in result) + + self.sendRequest(u'play "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertInResponse(u'OK') def test_play_minus_one_resumes_if_paused(self): self.backend.current_playlist.append([Track(length=40000)]) @@ -279,24 +273,27 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.assertEquals(PLAYING, self.backend.playback.state.get()) self.backend.playback.pause() self.assertEquals(PAUSED, self.backend.playback.state.get()) - result = self.dispatcher.handle_request(u'play "-1"') - self.assert_(u'OK' in result) + + self.sendRequest(u'play "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertInResponse(u'OK') def test_playid(self): self.backend.current_playlist.append([Track()]) - result = self.dispatcher.handle_request(u'playid "0"') - self.assert_(u'OK' in result) + + self.sendRequest(u'playid "0"') self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertInResponse(u'OK') def test_playid_minus_one_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.backend.playback.current_track.get(), None) self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - result = self.dispatcher.handle_request(u'playid "-1"') - self.assert_(u'OK' in result) + + self.sendRequest(u'playid "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertEqual(self.backend.playback.current_track.get().uri, 'a') + self.assertEqual('a', self.backend.playback.current_track.get().uri) + self.assertInResponse(u'OK') def test_playid_minus_one_plays_current_track_if_current_track_is_set(self): self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) @@ -304,28 +301,31 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.backend.playback.play() self.backend.playback.next() self.backend.playback.stop() - self.assertNotEqual(self.backend.playback.current_track.get(), None) - result = self.dispatcher.handle_request(u'playid "-1"') - self.assert_(u'OK' in result) + self.assertNotEqual(None, self.backend.playback.current_track.get()) + + self.sendRequest(u'playid "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertEqual(self.backend.playback.current_track.get().uri, 'b') + self.assertEqual('b', self.backend.playback.current_track.get().uri) + self.assertInResponse(u'OK') def test_playid_minus_one_on_empty_playlist_does_not_ack(self): self.backend.current_playlist.clear() - result = self.dispatcher.handle_request(u'playid "-1"') - self.assert_(u'OK' in result) + + self.sendRequest(u'playid "-1"') self.assertEqual(STOPPED, self.backend.playback.state.get()) - self.assertEqual(self.backend.playback.current_track.get(), None) + self.assertEqual(None, self.backend.playback.current_track.get()) + self.assertInResponse(u'OK') def test_playid_minus_is_ignored_if_playing(self): self.backend.current_playlist.append([Track(length=40000)]) self.backend.playback.seek(30000) self.assert_(self.backend.playback.time_position.get() >= 30000) self.assertEquals(PLAYING, self.backend.playback.state.get()) - result = self.dispatcher.handle_request(u'playid "-1"') - self.assert_(u'OK' in result) + + self.sendRequest(u'playid "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertInResponse(u'OK') def test_playid_minus_one_resumes_if_paused(self): self.backend.current_playlist.append([Track(length=40000)]) @@ -334,58 +334,64 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.assertEquals(PLAYING, self.backend.playback.state.get()) self.backend.playback.pause() self.assertEquals(PAUSED, self.backend.playback.state.get()) - result = self.dispatcher.handle_request(u'playid "-1"') - self.assert_(u'OK' in result) + + self.sendRequest(u'playid "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertInResponse(u'OK') def test_playid_which_does_not_exist(self): self.backend.current_playlist.append([Track()]) - result = self.dispatcher.handle_request(u'playid "12345"') - self.assertEqual(result[0], u'ACK [50@0] {playid} No such song') + + self.sendRequest(u'playid "12345"') + self.assertInResponse(u'ACK [50@0] {playid} No such song') def test_previous(self): - result = self.dispatcher.handle_request(u'previous') - self.assert_(u'OK' in result) + self.sendRequest(u'previous') + self.assertInResponse(u'OK') def test_seek(self): self.backend.current_playlist.append([Track(length=40000)]) - self.dispatcher.handle_request(u'seek "0"') - result = self.dispatcher.handle_request(u'seek "0" "30"') - self.assert_(u'OK' in result) + + self.sendRequest(u'seek "0"') + self.sendRequest(u'seek "0" "30"') self.assert_(self.backend.playback.time_position >= 30000) + self.assertInResponse(u'OK') def test_seek_with_songpos(self): seek_track = Track(uri='2', length=40000) self.backend.current_playlist.append( [Track(uri='1', length=40000), seek_track]) - result = self.dispatcher.handle_request(u'seek "1" "30"') - self.assert_(u'OK' in result) + + self.sendRequest(u'seek "1" "30"') self.assertEqual(self.backend.playback.current_track.get(), seek_track) + self.assertInResponse(u'OK') def test_seek_without_quotes(self): self.backend.current_playlist.append([Track(length=40000)]) - self.dispatcher.handle_request(u'seek 0') - result = self.dispatcher.handle_request(u'seek 0 30') - self.assert_(u'OK' in result) + + self.sendRequest(u'seek 0') + self.sendRequest(u'seek 0 30') self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertInResponse(u'OK') def test_seekid(self): self.backend.current_playlist.append([Track(length=40000)]) - result = self.dispatcher.handle_request(u'seekid "0" "30"') - self.assert_(u'OK' in result) + self.sendRequest(u'seekid "0" "30"') self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertInResponse(u'OK') def test_seekid_with_cpid(self): seek_track = Track(uri='2', length=40000) self.backend.current_playlist.append( [Track(length=40000), seek_track]) - result = self.dispatcher.handle_request(u'seekid "1" "30"') - self.assert_(u'OK' in result) - self.assertEqual(self.backend.playback.current_cpid.get(), 1) - self.assertEqual(self.backend.playback.current_track.get(), seek_track) + + self.sendRequest(u'seekid "1" "30"') + self.assertEqual(1, self.backend.playback.current_cpid.get()) + self.assertEqual(seek_track, self.backend.playback.current_track.get()) + self.assertInResponse(u'OK') def test_stop(self): - result = self.dispatcher.handle_request(u'stop') - self.assert_(u'OK' in result) + self.sendRequest(u'stop') self.assertEqual(STOPPED, self.backend.playback.state.get()) + self.assertInResponse(u'OK') diff --git a/tests/frontends/mpd/protocol/reflection_test.py b/tests/frontends/mpd/protocol/reflection_test.py new file mode 100644 index 00000000..8bd9b7e0 --- /dev/null +++ b/tests/frontends/mpd/protocol/reflection_test.py @@ -0,0 +1,67 @@ +from mopidy import settings + +from tests.frontends.mpd import protocol + + +class ReflectionHandlerTest(protocol.BaseTestCase): + def test_commands_returns_list_of_all_commands(self): + self.sendRequest(u'commands') + # Check if some random commands are included + self.assertInResponse(u'command: commands') + self.assertInResponse(u'command: play') + self.assertInResponse(u'command: status') + # Check if commands you do not have access to are not present + self.assertNotInResponse(u'command: kill') + # Check if the blacklisted commands are not present + self.assertNotInResponse(u'command: command_list_begin') + self.assertNotInResponse(u'command: command_list_ok_begin') + self.assertNotInResponse(u'command: command_list_end') + self.assertNotInResponse(u'command: idle') + self.assertNotInResponse(u'command: noidle') + self.assertNotInResponse(u'command: sticker') + self.assertInResponse(u'OK') + + def test_commands_show_less_if_auth_required_and_not_authed(self): + settings.MPD_SERVER_PASSWORD = u'secret' + self.sendRequest(u'commands') + # Not requiring auth + self.assertInResponse(u'command: close') + self.assertInResponse(u'command: commands') + self.assertInResponse(u'command: notcommands') + self.assertInResponse(u'command: password') + self.assertInResponse(u'command: ping') + # Requiring auth + self.assertNotInResponse(u'command: play') + self.assertNotInResponse(u'command: status') + + def test_decoders(self): + self.sendRequest(u'decoders') + self.assertInResponse(u'ACK [0@0] {} Not implemented') + + def test_notcommands_returns_only_kill_and_ok(self): + response = self.sendRequest(u'notcommands') + self.assertEqual(2, len(response)) + self.assertInResponse(u'command: kill') + self.assertInResponse(u'OK') + + def test_notcommands_returns_more_if_auth_required_and_not_authed(self): + settings.MPD_SERVER_PASSWORD = u'secret' + self.sendRequest(u'notcommands') + # Not requiring auth + self.assertNotInResponse(u'command: close') + self.assertNotInResponse(u'command: commands') + self.assertNotInResponse(u'command: notcommands') + self.assertNotInResponse(u'command: password') + self.assertNotInResponse(u'command: ping') + # Requiring auth + self.assertInResponse(u'command: play') + self.assertInResponse(u'command: status') + + def test_tagtypes(self): + self.sendRequest(u'tagtypes') + self.assertInResponse(u'OK') + + def test_urlhandlers(self): + self.sendRequest(u'urlhandlers') + self.assertInResponse(u'OK') + self.assertInResponse(u'handler: dummy') diff --git a/tests/frontends/mpd/protocol/regression_test.py b/tests/frontends/mpd/protocol/regression_test.py new file mode 100644 index 00000000..7f214efa --- /dev/null +++ b/tests/frontends/mpd/protocol/regression_test.py @@ -0,0 +1,164 @@ +import random + +from mopidy.models import Track + +from tests.frontends.mpd import protocol + + +class IssueGH17RegressionTest(protocol.BaseTestCase): + """ + The issue: http://github.com/mopidy/mopidy/issues/17 + + How to reproduce: + + - Play a playlist where one track cannot be played + - Turn on random mode + - Press next until you get to the unplayable track + """ + def test(self): + self.backend.current_playlist.append([ + Track(uri='a'), Track(uri='b'), None, + Track(uri='d'), Track(uri='e'), Track(uri='f')]) + random.seed(1) # Playlist order: abcfde + + self.sendRequest(u'play') + self.assertEquals('a', self.backend.playback.current_track.get().uri) + self.sendRequest(u'random "1"') + self.sendRequest(u'next') + self.assertEquals('b', self.backend.playback.current_track.get().uri) + self.sendRequest(u'next') + # Should now be at track 'c', but playback fails and it skips ahead + self.assertEquals('f', self.backend.playback.current_track.get().uri) + self.sendRequest(u'next') + self.assertEquals('d', self.backend.playback.current_track.get().uri) + self.sendRequest(u'next') + self.assertEquals('e', self.backend.playback.current_track.get().uri) + + +class IssueGH18RegressionTest(protocol.BaseTestCase): + """ + The issue: http://github.com/mopidy/mopidy/issues/18 + + How to reproduce: + + Play, random on, next, random off, next, next. + + At this point it gives the same song over and over. + """ + + def test(self): + self.backend.current_playlist.append([ + Track(uri='a'), Track(uri='b'), Track(uri='c'), + Track(uri='d'), Track(uri='e'), Track(uri='f')]) + random.seed(1) + + self.sendRequest(u'play') + self.sendRequest(u'random "1"') + self.sendRequest(u'next') + self.sendRequest(u'random "0"') + self.sendRequest(u'next') + + self.sendRequest(u'next') + cp_track_1 = self.backend.playback.current_cp_track.get() + self.sendRequest(u'next') + cp_track_2 = self.backend.playback.current_cp_track.get() + self.sendRequest(u'next') + cp_track_3 = self.backend.playback.current_cp_track.get() + + self.assertNotEqual(cp_track_1, cp_track_2) + self.assertNotEqual(cp_track_2, cp_track_3) + + +class IssueGH22RegressionTest(protocol.BaseTestCase): + """ + The issue: http://github.com/mopidy/mopidy/issues/22 + + How to reproduce: + + Play, random on, remove all tracks from the current playlist (as in + "delete" each one, not "clear"). + + Alternatively: Play, random on, remove a random track from the current + playlist, press next until it crashes. + """ + + def test(self): + self.backend.current_playlist.append([ + Track(uri='a'), Track(uri='b'), Track(uri='c'), + Track(uri='d'), Track(uri='e'), Track(uri='f')]) + random.seed(1) + + self.sendRequest(u'play') + self.sendRequest(u'random "1"') + self.sendRequest(u'deleteid "1"') + self.sendRequest(u'deleteid "2"') + self.sendRequest(u'deleteid "3"') + self.sendRequest(u'deleteid "4"') + self.sendRequest(u'deleteid "5"') + self.sendRequest(u'deleteid "6"') + self.sendRequest(u'status') + + +class IssueGH69RegressionTest(protocol.BaseTestCase): + """ + The issue: https://github.com/mopidy/mopidy/issues/69 + + How to reproduce: + + Play track, stop, clear current playlist, load a new playlist, status. + + The status response now contains "song: None". + """ + + def test(self): + self.backend.stored_playlists.create('foo') + self.backend.current_playlist.append([ + Track(uri='a'), Track(uri='b'), Track(uri='c'), + Track(uri='d'), Track(uri='e'), Track(uri='f')]) + + self.sendRequest(u'play') + self.sendRequest(u'stop') + self.sendRequest(u'clear') + self.sendRequest(u'load "foo"') + self.assertNotInResponse('song: None') + + +class IssueGH113RegressionTest(protocol.BaseTestCase): + """ + The issue: https://github.com/mopidy/mopidy/issues/113 + + How to reproduce: + + - Have a playlist with a name contining backslashes, like + "all lart spotify:track:\w\{22\} pastes". + - Try to load the playlist with the backslashes in the playlist name + escaped. + """ + + def test(self): + self.backend.stored_playlists.create( + u'all lart spotify:track:\w\{22\} pastes') + + self.sendRequest(u'lsinfo "/"') + self.assertInResponse( + u'playlist: all lart spotify:track:\w\{22\} pastes') + + 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/protocol/status_test.py b/tests/frontends/mpd/protocol/status_test.py new file mode 100644 index 00000000..e6572eab --- /dev/null +++ b/tests/frontends/mpd/protocol/status_test.py @@ -0,0 +1,37 @@ +from mopidy.models import Track + +from tests.frontends.mpd import protocol + + +class StatusHandlerTest(protocol.BaseTestCase): + def test_clearerror(self): + self.sendRequest(u'clearerror') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_currentsong(self): + track = Track() + self.backend.current_playlist.append([track]) + self.backend.playback.play() + self.sendRequest(u'currentsong') + self.assertInResponse(u'file: ') + self.assertInResponse(u'Time: 0') + self.assertInResponse(u'Artist: ') + self.assertInResponse(u'Title: ') + self.assertInResponse(u'Album: ') + self.assertInResponse(u'Track: 0') + self.assertInResponse(u'Date: ') + self.assertInResponse(u'Pos: 0') + self.assertInResponse(u'Id: 0') + self.assertInResponse(u'OK') + + def test_currentsong_without_song(self): + self.sendRequest(u'currentsong') + self.assertInResponse(u'OK') + + def test_stats_command(self): + self.sendRequest(u'stats') + self.assertInResponse(u'OK') + + def test_status_command(self): + self.sendRequest(u'status') + self.assertInResponse(u'OK') diff --git a/tests/frontends/mpd/protocol/stickers_test.py b/tests/frontends/mpd/protocol/stickers_test.py new file mode 100644 index 00000000..3e8b687f --- /dev/null +++ b/tests/frontends/mpd/protocol/stickers_test.py @@ -0,0 +1,33 @@ +from tests.frontends.mpd import protocol + + +class StickersHandlerTest(protocol.BaseTestCase): + def test_sticker_get(self): + self.sendRequest( + u'sticker get "song" "file:///dev/urandom" "a_name"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_sticker_set(self): + self.sendRequest( + u'sticker set "song" "file:///dev/urandom" "a_name" "a_value"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_sticker_delete_with_name(self): + self.sendRequest( + u'sticker delete "song" "file:///dev/urandom" "a_name"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_sticker_delete_without_name(self): + self.sendRequest( + u'sticker delete "song" "file:///dev/urandom"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_sticker_list(self): + self.sendRequest( + u'sticker list "song" "file:///dev/urandom"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_sticker_find(self): + self.sendRequest( + u'sticker find "song" "file:///dev/urandom" "a_name"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py new file mode 100644 index 00000000..45d6a09a --- /dev/null +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -0,0 +1,94 @@ +import datetime + +from mopidy.models import Track, Playlist + +from tests.frontends.mpd import protocol + + +class StoredPlaylistsHandlerTest(protocol.BaseTestCase): + def test_listplaylist(self): + self.backend.stored_playlists.playlists = [ + Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] + + self.sendRequest(u'listplaylist "name"') + self.assertInResponse(u'file: file:///dev/urandom') + self.assertInResponse(u'OK') + + def test_listplaylist_fails_if_no_playlist_is_found(self): + self.sendRequest(u'listplaylist "name"') + self.assertEqualResponse(u'ACK [50@0] {listplaylist} No such playlist') + + def test_listplaylistinfo(self): + self.backend.stored_playlists.playlists = [ + Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] + + self.sendRequest(u'listplaylistinfo "name"') + self.assertInResponse(u'file: file:///dev/urandom') + self.assertInResponse(u'Track: 0') + self.assertNotInResponse(u'Pos: 0') + self.assertInResponse(u'OK') + + def test_listplaylistinfo_fails_if_no_playlist_is_found(self): + self.sendRequest(u'listplaylistinfo "name"') + self.assertEqualResponse( + u'ACK [50@0] {listplaylistinfo} No such playlist') + + def test_listplaylists(self): + last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) + self.backend.stored_playlists.playlists = [Playlist(name='a', + last_modified=last_modified)] + + self.sendRequest(u'listplaylists') + self.assertInResponse(u'playlist: a') + # Date without microseconds and with time zone information + self.assertInResponse(u'Last-Modified: 2001-03-17T13:41:17Z') + self.assertInResponse(u'OK') + + def test_load_known_playlist_appends_to_current_playlist(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) + self.backend.stored_playlists.playlists = [Playlist(name='A-list', + tracks=[Track(uri='c'), Track(uri='d'), Track(uri='e')])] + + self.sendRequest(u'load "A-list"') + tracks = self.backend.current_playlist.tracks.get() + self.assertEqual(5, len(tracks)) + self.assertEqual('a', tracks[0].uri) + self.assertEqual('b', tracks[1].uri) + self.assertEqual('c', tracks[2].uri) + self.assertEqual('d', tracks[3].uri) + self.assertEqual('e', tracks[4].uri) + self.assertInResponse(u'OK') + + def test_load_unknown_playlist_acks(self): + self.sendRequest(u'load "unknown playlist"') + self.assertEqual(0, len(self.backend.current_playlist.tracks.get())) + self.assertEqualResponse(u'ACK [50@0] {load} No such playlist') + + def test_playlistadd(self): + self.sendRequest(u'playlistadd "name" "file:///dev/urandom"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_playlistclear(self): + self.sendRequest(u'playlistclear "name"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_playlistdelete(self): + self.sendRequest(u'playlistdelete "name" "5"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_playlistmove(self): + self.sendRequest(u'playlistmove "name" "5" "10"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_rename(self): + self.sendRequest(u'rename "old_name" "new_name"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_rm(self): + self.sendRequest(u'rm "name"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + + def test_save(self): + self.sendRequest(u'save "name"') + self.assertEqualResponse(u'ACK [0@0] {} Not implemented') diff --git a/tests/frontends/mpd/reflection_test.py b/tests/frontends/mpd/reflection_test.py deleted file mode 100644 index c4fd632a..00000000 --- a/tests/frontends/mpd/reflection_test.py +++ /dev/null @@ -1,79 +0,0 @@ -import unittest - -from mopidy import settings -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.mixers.dummy import DummyMixer - -class ReflectionHandlerTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() - - def tearDown(self): - settings.runtime.clear() - self.backend.stop().get() - self.mixer.stop().get() - - def test_commands_returns_list_of_all_commands(self): - result = self.dispatcher.handle_request(u'commands') - # Check if some random commands are included - self.assert_(u'command: commands' in result) - self.assert_(u'command: play' in result) - self.assert_(u'command: status' in result) - # Check if commands you do not have access to are not present - self.assert_(u'command: kill' not in result) - # Check if the blacklisted commands are not present - self.assert_(u'command: command_list_begin' not in result) - self.assert_(u'command: command_list_ok_begin' not in result) - self.assert_(u'command: command_list_end' not in result) - self.assert_(u'command: idle' not in result) - self.assert_(u'command: noidle' not in result) - self.assert_(u'command: sticker' not in result) - self.assert_(u'OK' in result) - - def test_commands_show_less_if_auth_required_and_not_authed(self): - settings.MPD_SERVER_PASSWORD = u'secret' - result = self.dispatcher.handle_request(u'commands') - # Not requiring auth - self.assert_(u'command: close' in result, result) - self.assert_(u'command: commands' in result, result) - self.assert_(u'command: notcommands' in result, result) - self.assert_(u'command: password' in result, result) - self.assert_(u'command: ping' in result, result) - # Requiring auth - self.assert_(u'command: play' not in result, result) - self.assert_(u'command: status' not in result, result) - - def test_decoders(self): - result = self.dispatcher.handle_request(u'decoders') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_notcommands_returns_only_kill_and_ok(self): - result = self.dispatcher.handle_request(u'notcommands') - self.assertEqual(2, len(result)) - self.assert_(u'command: kill' in result) - self.assert_(u'OK' in result) - - def test_notcommands_returns_more_if_auth_required_and_not_authed(self): - settings.MPD_SERVER_PASSWORD = u'secret' - result = self.dispatcher.handle_request(u'notcommands') - # Not requiring auth - self.assert_(u'command: close' not in result, result) - self.assert_(u'command: commands' not in result, result) - self.assert_(u'command: notcommands' not in result, result) - self.assert_(u'command: password' not in result, result) - self.assert_(u'command: ping' not in result, result) - # Requiring auth - self.assert_(u'command: play' in result, result) - self.assert_(u'command: status' in result, result) - - def test_tagtypes(self): - result = self.dispatcher.handle_request(u'tagtypes') - self.assert_(u'OK' in result) - - def test_urlhandlers(self): - result = self.dispatcher.handle_request(u'urlhandlers') - self.assert_(u'OK' in result) - self.assert_(u'handler: dummy' in result) diff --git a/tests/frontends/mpd/regression_test.py b/tests/frontends/mpd/regression_test.py deleted file mode 100644 index f786cf0a..00000000 --- a/tests/frontends/mpd/regression_test.py +++ /dev/null @@ -1,158 +0,0 @@ -import random -import unittest - -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import dispatcher -from mopidy.mixers.dummy import DummyMixer -from mopidy.models import Track - -class IssueGH17RegressionTest(unittest.TestCase): - """ - The issue: http://github.com/mopidy/mopidy/issues#issue/17 - - How to reproduce: - - - Play a playlist where one track cannot be played - - Turn on random mode - - Press next until you get to the unplayable track - """ - - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.backend.current_playlist.append([ - Track(uri='a'), Track(uri='b'), None, - Track(uri='d'), Track(uri='e'), Track(uri='f')]) - self.mixer = DummyMixer.start().proxy() - self.mpd = dispatcher.MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test(self): - random.seed(1) # Playlist order: abcfde - self.mpd.handle_request(u'play') - self.assertEquals('a', self.backend.playback.current_track.get().uri) - self.mpd.handle_request(u'random "1"') - self.mpd.handle_request(u'next') - self.assertEquals('b', self.backend.playback.current_track.get().uri) - self.mpd.handle_request(u'next') - # Should now be at track 'c', but playback fails and it skips ahead - self.assertEquals('f', self.backend.playback.current_track.get().uri) - self.mpd.handle_request(u'next') - self.assertEquals('d', self.backend.playback.current_track.get().uri) - self.mpd.handle_request(u'next') - self.assertEquals('e', self.backend.playback.current_track.get().uri) - - -class IssueGH18RegressionTest(unittest.TestCase): - """ - The issue: http://github.com/mopidy/mopidy/issues#issue/18 - - How to reproduce: - - Play, random on, next, random off, next, next. - - At this point it gives the same song over and over. - """ - - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.backend.current_playlist.append([ - Track(uri='a'), Track(uri='b'), Track(uri='c'), - Track(uri='d'), Track(uri='e'), Track(uri='f')]) - self.mixer = DummyMixer.start().proxy() - self.mpd = dispatcher.MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test(self): - random.seed(1) - self.mpd.handle_request(u'play') - self.mpd.handle_request(u'random "1"') - self.mpd.handle_request(u'next') - self.mpd.handle_request(u'random "0"') - self.mpd.handle_request(u'next') - - self.mpd.handle_request(u'next') - cp_track_1 = self.backend.playback.current_cp_track.get() - self.mpd.handle_request(u'next') - cp_track_2 = self.backend.playback.current_cp_track.get() - self.mpd.handle_request(u'next') - cp_track_3 = self.backend.playback.current_cp_track.get() - - self.assertNotEqual(cp_track_1, cp_track_2) - self.assertNotEqual(cp_track_2, cp_track_3) - - -class IssueGH22RegressionTest(unittest.TestCase): - """ - The issue: http://github.com/mopidy/mopidy/issues/#issue/22 - - How to reproduce: - - Play, random on, remove all tracks from the current playlist (as in - "delete" each one, not "clear"). - - Alternatively: Play, random on, remove a random track from the current - playlist, press next until it crashes. - """ - - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.backend.current_playlist.append([ - Track(uri='a'), Track(uri='b'), Track(uri='c'), - Track(uri='d'), Track(uri='e'), Track(uri='f')]) - self.mixer = DummyMixer.start().proxy() - self.mpd = dispatcher.MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test(self): - random.seed(1) - self.mpd.handle_request(u'play') - self.mpd.handle_request(u'random "1"') - self.mpd.handle_request(u'deleteid "1"') - self.mpd.handle_request(u'deleteid "2"') - self.mpd.handle_request(u'deleteid "3"') - self.mpd.handle_request(u'deleteid "4"') - self.mpd.handle_request(u'deleteid "5"') - self.mpd.handle_request(u'deleteid "6"') - self.mpd.handle_request(u'status') - - -class IssueGH69RegressionTest(unittest.TestCase): - """ - The issue: https://github.com/mopidy/mopidy/issues#issue/69 - - How to reproduce: - - Play track, stop, clear current playlist, load a new playlist, status. - - The status response now contains "song: None". - """ - - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.backend.current_playlist.append([ - Track(uri='a'), Track(uri='b'), Track(uri='c'), - Track(uri='d'), Track(uri='e'), Track(uri='f')]) - self.backend.stored_playlists.create('foo') - self.mixer = DummyMixer.start().proxy() - self.mpd = dispatcher.MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test(self): - self.mpd.handle_request(u'play') - self.mpd.handle_request(u'stop') - self.mpd.handle_request(u'clear') - self.mpd.handle_request(u'load "foo"') - response = self.mpd.handle_request(u'status') - self.assert_('song: None' not in response) diff --git a/tests/frontends/mpd/serializer_test.py b/tests/frontends/mpd/serializer_test.py index b0c57588..a20abaed 100644 --- a/tests/frontends/mpd/serializer_test.py +++ b/tests/frontends/mpd/serializer_test.py @@ -1,11 +1,13 @@ -import datetime as dt +import datetime import os -import unittest 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 + class TrackMpdFormatTest(unittest.TestCase): track = Track( @@ -15,7 +17,7 @@ class TrackMpdFormatTest(unittest.TestCase): album=Album(name=u'an album', num_tracks=13, artists=[Artist(name=u'an other artist')]), track_no=7, - date=dt.date(1977, 1, 1), + date=datetime.date(1977, 1, 1), length=137000, ) @@ -43,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) @@ -61,7 +63,7 @@ class TrackMpdFormatTest(unittest.TestCase): self.assert_(('Album', 'an album') in result) self.assert_(('AlbumArtist', 'an other artist') in result) self.assert_(('Track', '7/13') in result) - self.assert_(('Date', dt.date(1977, 1, 1)) in result) + self.assert_(('Date', datetime.date(1977, 1, 1)) in result) self.assert_(('Pos', 9) in result) self.assert_(('Id', 122) in result) self.assertEqual(len(result), 10) diff --git a/tests/frontends/mpd/server_test.py b/tests/frontends/mpd/server_test.py deleted file mode 100644 index b2e27559..00000000 --- a/tests/frontends/mpd/server_test.py +++ /dev/null @@ -1,23 +0,0 @@ -import unittest - -from mopidy import settings -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd import server -from mopidy.mixers.dummy import DummyMixer - -class MpdSessionTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.session = server.MpdSession(None, None, (None, None)) - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - settings.runtime.clear() - - def test_found_terminator_catches_decode_error(self): - # Pressing Ctrl+C in a telnet session sends a 0xff byte to the server. - self.session.input_buffer = ['\xff'] - self.session.found_terminator() - self.assertEqual(len(self.session.input_buffer), 0) diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index a7ed921f..bdd2dab8 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -1,67 +1,30 @@ -import unittest - -from mopidy.backends.base import PlaybackController -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd.dispatcher import MpdDispatcher +from mopidy.backends import dummy as backend +from mopidy.frontends.mpd import dispatcher from mopidy.frontends.mpd.protocol import status -from mopidy.mixers.dummy import DummyMixer +from mopidy.mixers import dummy as mixer from mopidy.models import Track -PAUSED = PlaybackController.PAUSED -PLAYING = PlaybackController.PLAYING -STOPPED = PlaybackController.STOPPED +from tests import unittest + +PAUSED = backend.PlaybackController.PAUSED +PLAYING = backend.PlaybackController.PLAYING +STOPPED = backend.PlaybackController.STOPPED + +# FIXME migrate to using protocol.BaseTestCase instead of status.stats +# directly? + class StatusHandlerTest(unittest.TestCase): def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() + self.backend = backend.DummyBackend.start().proxy() + self.mixer = mixer.DummyMixer.start().proxy() + self.dispatcher = dispatcher.MpdDispatcher() self.context = self.dispatcher.context def tearDown(self): self.backend.stop().get() self.mixer.stop().get() - def test_clearerror(self): - result = self.dispatcher.handle_request(u'clearerror') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_currentsong(self): - track = Track() - self.backend.current_playlist.append([track]) - self.backend.playback.play() - result = self.dispatcher.handle_request(u'currentsong') - self.assert_(u'file: ' in result) - self.assert_(u'Time: 0' in result) - self.assert_(u'Artist: ' in result) - self.assert_(u'Title: ' in result) - self.assert_(u'Album: ' in result) - self.assert_(u'Track: 0' in result) - self.assert_(u'Date: ' in result) - self.assert_(u'Pos: 0' in result) - self.assert_(u'Id: 0' in result) - self.assert_(u'OK' in result) - - def test_currentsong_without_song(self): - result = self.dispatcher.handle_request(u'currentsong') - self.assert_(u'OK' in result) - - def test_idle_without_subsystems(self): - result = self.dispatcher.handle_request(u'idle') - self.assert_(u'OK' in result) - - def test_idle_with_subsystems(self): - result = self.dispatcher.handle_request(u'idle database playlist') - self.assert_(u'OK' in result) - - def test_noidle(self): - result = self.dispatcher.handle_request(u'noidle') - self.assert_(u'OK' in result) - - def test_stats_command(self): - result = self.dispatcher.handle_request(u'stats') - self.assert_(u'OK' in result) - def test_stats_method(self): result = status.stats(self.context) self.assert_('artists' in result) @@ -79,10 +42,6 @@ class StatusHandlerTest(unittest.TestCase): self.assert_('playtime' in result) self.assert_(int(result['playtime']) >= 0) - def test_status_command(self): - result = self.dispatcher.handle_request(u'status') - self.assert_(u'OK' in result) - def test_status_method_contains_volume_which_defaults_to_0(self): result = dict(status.status(self.context)) self.assert_('volume' in result) @@ -205,7 +164,14 @@ class StatusHandlerTest(unittest.TestCase): self.backend.playback.play_time_accumulated = 59123 result = dict(status.status(self.context)) self.assert_('elapsed' in result) - self.assertEqual(int(result['elapsed']), 59123) + self.assertEqual(result['elapsed'], '59.123') + + def test_status_method_when_starting_playing_contains_elapsed_zero(self): + self.backend.playback.state = PAUSED + self.backend.playback.play_time_accumulated = 123 # Less than 1000ms + result = dict(status.status(self.context)) + self.assert_('elapsed' in result) + self.assertEqual(result['elapsed'], '0.123') def test_status_method_when_playing_contains_bitrate(self): self.backend.current_playlist.append([Track(bitrate=320)]) diff --git a/tests/frontends/mpd/stickers_test.py b/tests/frontends/mpd/stickers_test.py deleted file mode 100644 index 86ac8aec..00000000 --- a/tests/frontends/mpd/stickers_test.py +++ /dev/null @@ -1,45 +0,0 @@ -import unittest - -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.mixers.dummy import DummyMixer - -class StickersHandlerTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test_sticker_get(self): - result = self.dispatcher.handle_request( - u'sticker get "song" "file:///dev/urandom" "a_name"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_sticker_set(self): - result = self.dispatcher.handle_request( - u'sticker set "song" "file:///dev/urandom" "a_name" "a_value"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_sticker_delete_with_name(self): - result = self.dispatcher.handle_request( - u'sticker delete "song" "file:///dev/urandom" "a_name"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_sticker_delete_without_name(self): - result = self.dispatcher.handle_request( - u'sticker delete "song" "file:///dev/urandom"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_sticker_list(self): - result = self.dispatcher.handle_request( - u'sticker list "song" "file:///dev/urandom"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_sticker_find(self): - result = self.dispatcher.handle_request( - u'sticker find "song" "file:///dev/urandom" "a_name"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) diff --git a/tests/frontends/mpd/stored_playlists_test.py b/tests/frontends/mpd/stored_playlists_test.py deleted file mode 100644 index 04bab6f1..00000000 --- a/tests/frontends/mpd/stored_playlists_test.py +++ /dev/null @@ -1,102 +0,0 @@ -import datetime as dt -import unittest - -from mopidy.backends.dummy import DummyBackend -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.mixers.dummy import DummyMixer -from mopidy.models import Track, Playlist - -class StoredPlaylistsHandlerTest(unittest.TestCase): - def setUp(self): - self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() - self.dispatcher = MpdDispatcher() - - def tearDown(self): - self.backend.stop().get() - self.mixer.stop().get() - - def test_listplaylist(self): - self.backend.stored_playlists.playlists = [ - Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] - result = self.dispatcher.handle_request(u'listplaylist "name"') - self.assert_(u'file: file:///dev/urandom' in result) - self.assert_(u'OK' in result) - - def test_listplaylist_fails_if_no_playlist_is_found(self): - result = self.dispatcher.handle_request(u'listplaylist "name"') - self.assertEqual(result[0], - u'ACK [50@0] {listplaylist} No such playlist') - - def test_listplaylistinfo(self): - self.backend.stored_playlists.playlists = [ - Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] - result = self.dispatcher.handle_request(u'listplaylistinfo "name"') - self.assert_(u'file: file:///dev/urandom' in result) - self.assert_(u'Track: 0' in result) - self.assert_(u'Pos: 0' not in result) - self.assert_(u'OK' in result) - - def test_listplaylistinfo_fails_if_no_playlist_is_found(self): - result = self.dispatcher.handle_request(u'listplaylistinfo "name"') - self.assertEqual(result[0], - u'ACK [50@0] {listplaylistinfo} No such playlist') - - def test_listplaylists(self): - last_modified = dt.datetime(2001, 3, 17, 13, 41, 17, 12345) - self.backend.stored_playlists.playlists = [Playlist(name='a', - last_modified=last_modified)] - result = self.dispatcher.handle_request(u'listplaylists') - self.assert_(u'playlist: a' in result) - # Date without microseconds and with time zone information - self.assert_(u'Last-Modified: 2001-03-17T13:41:17Z' in result) - self.assert_(u'OK' in result) - - def test_load_known_playlist_appends_to_current_playlist(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) - self.backend.stored_playlists.playlists = [Playlist(name='A-list', - tracks=[Track(uri='c'), Track(uri='d'), Track(uri='e')])] - result = self.dispatcher.handle_request(u'load "A-list"') - self.assert_(u'OK' in result) - tracks = self.backend.current_playlist.tracks.get() - self.assertEqual(len(tracks), 5) - self.assertEqual(tracks[0].uri, 'a') - self.assertEqual(tracks[1].uri, 'b') - self.assertEqual(tracks[2].uri, 'c') - self.assertEqual(tracks[3].uri, 'd') - self.assertEqual(tracks[4].uri, 'e') - - def test_load_unknown_playlist_acks(self): - result = self.dispatcher.handle_request(u'load "unknown playlist"') - self.assert_(u'ACK [50@0] {load} No such playlist' in result) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 0) - - def test_playlistadd(self): - result = self.dispatcher.handle_request( - u'playlistadd "name" "file:///dev/urandom"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_playlistclear(self): - result = self.dispatcher.handle_request(u'playlistclear "name"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_playlistdelete(self): - result = self.dispatcher.handle_request(u'playlistdelete "name" "5"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_playlistmove(self): - result = self.dispatcher.handle_request(u'playlistmove "name" "5" "10"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_rename(self): - result = self.dispatcher.handle_request(u'rename "old_name" "new_name"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_rm(self): - result = self.dispatcher.handle_request(u'rm "name"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) - - def test_save(self): - result = self.dispatcher.handle_request(u'save "name"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) diff --git a/tests/frontends/mpris/__init__.py b/tests/frontends/mpris/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py new file mode 100644 index 00000000..49e56226 --- /dev/null +++ b/tests/frontends/mpris/events_test.py @@ -0,0 +1,78 @@ +import sys + +import mock + +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 + self.mpris_object = mock.Mock(spec=objects.MprisObject) + self.mpris_frontend.mpris_object = self.mpris_object + + def test_track_playback_paused_event_changes_playback_status(self): + self.mpris_object.Get.return_value = 'Paused' + self.mpris_frontend.track_playback_paused(Track(), 0) + self.assertListEqual(self.mpris_object.Get.call_args_list, [ + ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), + ]) + self.mpris_object.PropertiesChanged.assert_called_with( + objects.PLAYER_IFACE, {'PlaybackStatus': 'Paused'}, []) + + def test_track_playback_resumed_event_changes_playback_status(self): + self.mpris_object.Get.return_value = 'Playing' + self.mpris_frontend.track_playback_resumed(Track(), 0) + self.assertListEqual(self.mpris_object.Get.call_args_list, [ + ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), + ]) + self.mpris_object.PropertiesChanged.assert_called_with( + objects.PLAYER_IFACE, {'PlaybackStatus': 'Playing'}, []) + + def test_track_playback_started_event_changes_playback_status_and_metadata(self): + self.mpris_object.Get.return_value = '...' + self.mpris_frontend.track_playback_started(Track()) + self.assertListEqual(self.mpris_object.Get.call_args_list, [ + ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), + ((objects.PLAYER_IFACE, 'Metadata'), {}), + ]) + self.mpris_object.PropertiesChanged.assert_called_with( + objects.PLAYER_IFACE, + {'Metadata': '...', 'PlaybackStatus': '...'}, []) + + def test_track_playback_ended_event_changes_playback_status_and_metadata(self): + self.mpris_object.Get.return_value = '...' + self.mpris_frontend.track_playback_ended(Track(), 0) + self.assertListEqual(self.mpris_object.Get.call_args_list, [ + ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), + ((objects.PLAYER_IFACE, 'Metadata'), {}), + ]) + self.mpris_object.PropertiesChanged.assert_called_with( + objects.PLAYER_IFACE, + {'Metadata': '...', 'PlaybackStatus': '...'}, []) + + def test_volume_changed_event_changes_volume(self): + self.mpris_object.Get.return_value = 1.0 + self.mpris_frontend.volume_changed() + self.assertListEqual(self.mpris_object.Get.call_args_list, [ + ((objects.PLAYER_IFACE, 'Volume'), {}), + ]) + self.mpris_object.PropertiesChanged.assert_called_with( + objects.PLAYER_IFACE, {'Volume': 1.0}, []) + + def test_seeked_event_causes_mpris_seeked_event(self): + self.mpris_object.Get.return_value = 31000000 + self.mpris_frontend.seeked() + self.assertListEqual(self.mpris_object.Get.call_args_list, [ + ((objects.PLAYER_IFACE, 'Position'), {}), + ]) + self.mpris_object.Seeked.assert_called_with(31000000) diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py new file mode 100644 index 00000000..24c426fb --- /dev/null +++ b/tests/frontends/mpris/player_interface_test.py @@ -0,0 +1,834 @@ +import sys + +import mock + +from mopidy import OptionalDependencyError +from mopidy.backends.dummy import DummyBackend +from mopidy.backends.base.playback import PlaybackController +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 +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() + self.mixer = DummyMixer.start().proxy() + self.backend = DummyBackend.start().proxy() + self.mpris = objects.MprisObject() + self.mpris._backend = self.backend + + def tearDown(self): + self.backend.stop() + self.mixer.stop() + + def test_get_playback_status_is_playing_when_playing(self): + self.backend.playback.state = PLAYING + result = self.mpris.Get(objects.PLAYER_IFACE, 'PlaybackStatus') + self.assertEqual('Playing', result) + + def test_get_playback_status_is_paused_when_paused(self): + self.backend.playback.state = PAUSED + result = self.mpris.Get(objects.PLAYER_IFACE, 'PlaybackStatus') + self.assertEqual('Paused', result) + + def test_get_playback_status_is_stopped_when_stopped(self): + self.backend.playback.state = STOPPED + result = self.mpris.Get(objects.PLAYER_IFACE, 'PlaybackStatus') + self.assertEqual('Stopped', result) + + def test_get_loop_status_is_none_when_not_looping(self): + self.backend.playback.repeat = False + self.backend.playback.single = False + result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') + self.assertEqual('None', result) + + def test_get_loop_status_is_track_when_looping_a_single_track(self): + self.backend.playback.repeat = True + self.backend.playback.single = True + result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') + self.assertEqual('Track', result) + + def test_get_loop_status_is_playlist_when_looping_the_current_playlist(self): + self.backend.playback.repeat = True + self.backend.playback.single = False + result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') + self.assertEqual('Playlist', result) + + def test_set_loop_status_is_ignored_if_can_control_is_false(self): + self.mpris.get_CanControl = lambda *_: False + self.backend.playback.repeat = True + self.backend.playback.single = True + self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'None') + self.assertEquals(self.backend.playback.repeat.get(), True) + self.assertEquals(self.backend.playback.single.get(), True) + + def test_set_loop_status_to_none_unsets_repeat_and_single(self): + self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'None') + self.assertEquals(self.backend.playback.repeat.get(), False) + self.assertEquals(self.backend.playback.single.get(), False) + + def test_set_loop_status_to_track_sets_repeat_and_single(self): + self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'Track') + self.assertEquals(self.backend.playback.repeat.get(), True) + self.assertEquals(self.backend.playback.single.get(), True) + + def test_set_loop_status_to_playlists_sets_repeat_and_not_single(self): + self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'Playlist') + self.assertEquals(self.backend.playback.repeat.get(), True) + self.assertEquals(self.backend.playback.single.get(), False) + + def test_get_rate_is_greater_or_equal_than_minimum_rate(self): + rate = self.mpris.Get(objects.PLAYER_IFACE, 'Rate') + minimum_rate = self.mpris.Get(objects.PLAYER_IFACE, 'MinimumRate') + self.assert_(rate >= minimum_rate) + + def test_get_rate_is_less_or_equal_than_maximum_rate(self): + rate = self.mpris.Get(objects.PLAYER_IFACE, 'Rate') + maximum_rate = self.mpris.Get(objects.PLAYER_IFACE, 'MaximumRate') + self.assert_(rate >= maximum_rate) + + def test_set_rate_is_ignored_if_can_control_is_false(self): + self.mpris.get_CanControl = lambda *_: False + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.Set(objects.PLAYER_IFACE, 'Rate', 0) + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + def test_set_rate_to_zero_pauses_playback(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.Set(objects.PLAYER_IFACE, 'Rate', 0) + self.assertEquals(self.backend.playback.state.get(), PAUSED) + + def test_get_shuffle_returns_true_if_random_is_active(self): + self.backend.playback.random = True + result = self.mpris.Get(objects.PLAYER_IFACE, 'Shuffle') + self.assertTrue(result) + + def test_get_shuffle_returns_false_if_random_is_inactive(self): + self.backend.playback.random = False + result = self.mpris.Get(objects.PLAYER_IFACE, 'Shuffle') + self.assertFalse(result) + + def test_set_shuffle_is_ignored_if_can_control_is_false(self): + self.mpris.get_CanControl = lambda *_: False + self.backend.playback.random = False + result = self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) + self.assertFalse(self.backend.playback.random.get()) + + def test_set_shuffle_to_true_activates_random_mode(self): + self.backend.playback.random = False + self.assertFalse(self.backend.playback.random.get()) + result = self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) + self.assertTrue(self.backend.playback.random.get()) + + def test_set_shuffle_to_false_deactivates_random_mode(self): + self.backend.playback.random = True + self.assertTrue(self.backend.playback.random.get()) + result = self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', False) + self.assertFalse(self.backend.playback.random.get()) + + def test_get_metadata_has_trackid_even_when_no_current_track(self): + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assert_('mpris:trackid' in result.keys()) + self.assertEquals(result['mpris:trackid'], '') + + def test_get_metadata_has_trackid_based_on_cpid(self): + self.backend.current_playlist.append([Track(uri='a')]) + self.backend.playback.play() + (cpid, track) = self.backend.playback.current_cp_track.get() + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assertIn('mpris:trackid', result.keys()) + self.assertEquals(result['mpris:trackid'], + '/com/mopidy/track/%d' % cpid) + + def test_get_metadata_has_track_length(self): + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assertIn('mpris:length', result.keys()) + self.assertEquals(result['mpris:length'], 40000000) + + def test_get_metadata_has_track_uri(self): + self.backend.current_playlist.append([Track(uri='a')]) + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assertIn('xesam:url', result.keys()) + self.assertEquals(result['xesam:url'], 'a') + + def test_get_metadata_has_track_title(self): + self.backend.current_playlist.append([Track(name='a')]) + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assertIn('xesam:title', result.keys()) + self.assertEquals(result['xesam:title'], 'a') + + def test_get_metadata_has_track_artists(self): + self.backend.current_playlist.append([Track(artists=[ + Artist(name='a'), Artist(name='b'), Artist(name=None)])]) + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assertIn('xesam:artist', result.keys()) + self.assertEquals(result['xesam:artist'], ['a', 'b']) + + def test_get_metadata_has_track_album(self): + self.backend.current_playlist.append([Track(album=Album(name='a'))]) + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assertIn('xesam:album', result.keys()) + self.assertEquals(result['xesam:album'], 'a') + + def test_get_metadata_has_track_album_artists(self): + self.backend.current_playlist.append([Track(album=Album(artists=[ + Artist(name='a'), Artist(name='b'), Artist(name=None)]))]) + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assertIn('xesam:albumArtist', result.keys()) + self.assertEquals(result['xesam:albumArtist'], ['a', 'b']) + + def test_get_metadata_has_track_number_in_album(self): + self.backend.current_playlist.append([Track(track_no=7)]) + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') + self.assertIn('xesam:trackNumber', result.keys()) + self.assertEquals(result['xesam:trackNumber'], 7) + + def test_get_volume_should_return_volume_between_zero_and_one(self): + self.mixer.volume = 0 + result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') + self.assertEquals(result, 0) + + self.mixer.volume = 50 + result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') + self.assertEquals(result, 0.5) + + self.mixer.volume = 100 + result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') + self.assertEquals(result, 1) + + def test_set_volume_is_ignored_if_can_control_is_false(self): + self.mpris.get_CanControl = lambda *_: False + self.mixer.volume = 0 + self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) + self.assertEquals(self.mixer.volume.get(), 0) + + def test_set_volume_to_one_should_set_mixer_volume_to_100(self): + self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) + self.assertEquals(self.mixer.volume.get(), 100) + + def test_set_volume_to_anything_above_one_should_set_mixer_volume_to_100(self): + self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 2.0) + self.assertEquals(self.mixer.volume.get(), 100) + + def test_set_volume_to_anything_not_a_number_does_not_change_volume(self): + self.mixer.volume = 10 + self.mpris.Set(objects.PLAYER_IFACE, 'Volume', None) + self.assertEquals(self.mixer.volume.get(), 10) + + def test_get_position_returns_time_position_in_microseconds(self): + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + self.backend.playback.seek(10000) + result_in_microseconds = self.mpris.Get(objects.PLAYER_IFACE, 'Position') + result_in_milliseconds = result_in_microseconds // 1000 + self.assert_(result_in_milliseconds >= 10000) + + def test_get_position_when_no_current_track_should_be_zero(self): + result_in_microseconds = self.mpris.Get(objects.PLAYER_IFACE, 'Position') + result_in_milliseconds = result_in_microseconds // 1000 + self.assertEquals(result_in_milliseconds, 0) + + def test_get_minimum_rate_is_one_or_less(self): + result = self.mpris.Get(objects.PLAYER_IFACE, 'MinimumRate') + self.assert_(result <= 1.0) + + def test_get_maximum_rate_is_one_or_more(self): + result = self.mpris.Get(objects.PLAYER_IFACE, 'MaximumRate') + self.assert_(result >= 1.0) + + def test_can_go_next_is_true_if_can_control_and_other_next_track(self): + self.mpris.get_CanControl = lambda *_: True + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') + self.assertTrue(result) + + def test_can_go_next_is_false_if_next_track_is_the_same(self): + self.mpris.get_CanControl = lambda *_: True + self.backend.current_playlist.append([Track(uri='a')]) + self.backend.playback.repeat = True + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') + self.assertFalse(result) + + def test_can_go_next_is_false_if_can_control_is_false(self): + self.mpris.get_CanControl = lambda *_: False + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') + self.assertFalse(result) + + def test_can_go_previous_is_true_if_can_control_and_other_previous_track(self): + self.mpris.get_CanControl = lambda *_: True + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.next() + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') + self.assertTrue(result) + + def test_can_go_previous_is_false_if_previous_track_is_the_same(self): + self.mpris.get_CanControl = lambda *_: True + self.backend.current_playlist.append([Track(uri='a')]) + self.backend.playback.repeat = True + self.backend.playback.play() + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') + self.assertFalse(result) + + def test_can_go_previous_is_false_if_can_control_is_false(self): + self.mpris.get_CanControl = lambda *_: False + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.next() + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') + self.assertFalse(result) + + def test_can_play_is_true_if_can_control_and_current_track(self): + self.mpris.get_CanControl = lambda *_: True + self.backend.current_playlist.append([Track(uri='a')]) + self.backend.playback.play() + self.assertTrue(self.backend.playback.current_track.get()) + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') + self.assertTrue(result) + + def test_can_play_is_false_if_no_current_track(self): + self.mpris.get_CanControl = lambda *_: True + self.assertFalse(self.backend.playback.current_track.get()) + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') + self.assertFalse(result) + + def test_can_play_if_false_if_can_control_is_false(self): + self.mpris.get_CanControl = lambda *_: False + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') + self.assertFalse(result) + + def test_can_pause_is_true_if_can_control_and_track_can_be_paused(self): + self.mpris.get_CanControl = lambda *_: True + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPause') + self.assertTrue(result) + + def test_can_pause_if_false_if_can_control_is_false(self): + self.mpris.get_CanControl = lambda *_: False + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPause') + self.assertFalse(result) + + def test_can_seek_is_true_if_can_control_is_true(self): + self.mpris.get_CanControl = lambda *_: True + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanSeek') + self.assertTrue(result) + + def test_can_seek_is_false_if_can_control_is_false(self): + self.mpris.get_CanControl = lambda *_: False + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanSeek') + self.assertFalse(result) + + def test_can_control_is_true(self): + result = self.mpris.Get(objects.PLAYER_IFACE, 'CanControl') + self.assertTrue(result) + + def test_next_is_ignored_if_can_go_next_is_false(self): + self.mpris.get_CanGoNext = lambda *_: False + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.mpris.Next() + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + + def test_next_when_playing_should_skip_to_next_track_and_keep_playing(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.Next() + self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + def test_next_when_at_end_of_list_should_stop_playback(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.next() + self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.Next() + self.assertEquals(self.backend.playback.state.get(), STOPPED) + + def test_next_when_paused_should_skip_to_next_track_and_stay_paused(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.pause() + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.mpris.Next() + self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.assertEquals(self.backend.playback.state.get(), PAUSED) + + def test_next_when_stopped_should_skip_to_next_track_and_stay_stopped(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.stop() + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.mpris.Next() + self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.assertEquals(self.backend.playback.state.get(), STOPPED) + + def test_previous_is_ignored_if_can_go_previous_is_false(self): + self.mpris.get_CanGoPrevious = lambda *_: False + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.next() + self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.mpris.Previous() + self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + + def test_previous_when_playing_should_skip_to_prev_track_and_keep_playing(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.next() + self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.Previous() + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + def test_previous_when_at_start_of_list_should_stop_playback(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.Previous() + self.assertEquals(self.backend.playback.state.get(), STOPPED) + + def test_previous_when_paused_should_skip_to_previous_track_and_stay_paused(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.next() + self.backend.playback.pause() + self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.mpris.Previous() + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.backend.playback.state.get(), PAUSED) + + def test_previous_when_stopped_should_skip_to_previous_track_and_stay_stopped(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.next() + self.backend.playback.stop() + self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.mpris.Previous() + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.backend.playback.state.get(), STOPPED) + + def test_pause_is_ignored_if_can_pause_is_false(self): + self.mpris.get_CanPause = lambda *_: False + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.Pause() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + def test_pause_when_playing_should_pause_playback(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.Pause() + self.assertEquals(self.backend.playback.state.get(), PAUSED) + + def test_pause_when_paused_has_no_effect(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.pause() + self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.mpris.Pause() + self.assertEquals(self.backend.playback.state.get(), PAUSED) + + def test_playpause_is_ignored_if_can_pause_is_false(self): + self.mpris.get_CanPause = lambda *_: False + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.PlayPause() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + def test_playpause_when_playing_should_pause_playback(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.PlayPause() + self.assertEquals(self.backend.playback.state.get(), PAUSED) + + def test_playpause_when_paused_should_resume_playback(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.pause() + + self.assertEquals(self.backend.playback.state.get(), PAUSED) + at_pause = self.backend.playback.time_position.get() + self.assert_(at_pause >= 0) + + self.mpris.PlayPause() + + self.assertEquals(self.backend.playback.state.get(), PLAYING) + after_pause = self.backend.playback.time_position.get() + self.assert_(after_pause >= at_pause) + + def test_playpause_when_stopped_should_start_playback(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.mpris.PlayPause() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + def test_stop_is_ignored_if_can_control_is_false(self): + self.mpris.get_CanControl = lambda *_: False + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.Stop() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + def test_stop_when_playing_should_stop_playback(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.mpris.Stop() + self.assertEquals(self.backend.playback.state.get(), STOPPED) + + def test_stop_when_paused_should_stop_playback(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.pause() + self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.mpris.Stop() + self.assertEquals(self.backend.playback.state.get(), STOPPED) + + def test_play_is_ignored_if_can_play_is_false(self): + self.mpris.get_CanPlay = lambda *_: False + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.mpris.Play() + self.assertEquals(self.backend.playback.state.get(), STOPPED) + + def test_play_when_stopped_starts_playback(self): + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.mpris.Play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + def test_play_after_pause_resumes_from_same_position(self): + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + + before_pause = self.backend.playback.time_position.get() + self.assert_(before_pause >= 0) + + self.mpris.Pause() + self.assertEquals(self.backend.playback.state.get(), PAUSED) + at_pause = self.backend.playback.time_position.get() + self.assert_(at_pause >= before_pause) + + self.mpris.Play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + after_pause = self.backend.playback.time_position.get() + self.assert_(after_pause >= at_pause) + + def test_play_when_there_is_no_track_has_no_effect(self): + self.backend.current_playlist.clear() + self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.mpris.Play() + self.assertEquals(self.backend.playback.state.get(), STOPPED) + + def test_seek_is_ignored_if_can_seek_is_false(self): + self.mpris.get_CanSeek = lambda *_: False + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + + before_seek = self.backend.playback.time_position.get() + self.assert_(before_seek >= 0) + + milliseconds_to_seek = 10000 + microseconds_to_seek = milliseconds_to_seek * 1000 + + self.mpris.Seek(microseconds_to_seek) + + after_seek = self.backend.playback.time_position.get() + self.assert_(before_seek <= after_seek < ( + before_seek + milliseconds_to_seek)) + + def test_seek_seeks_given_microseconds_forward_in_the_current_track(self): + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + + before_seek = self.backend.playback.time_position.get() + self.assert_(before_seek >= 0) + + milliseconds_to_seek = 10000 + microseconds_to_seek = milliseconds_to_seek * 1000 + + self.mpris.Seek(microseconds_to_seek) + + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + after_seek = self.backend.playback.time_position.get() + self.assert_(after_seek >= (before_seek + milliseconds_to_seek)) + + def test_seek_seeks_given_microseconds_backward_if_negative(self): + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + self.backend.playback.seek(20000) + + before_seek = self.backend.playback.time_position.get() + self.assert_(before_seek >= 20000) + + milliseconds_to_seek = -10000 + microseconds_to_seek = milliseconds_to_seek * 1000 + + self.mpris.Seek(microseconds_to_seek) + + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + after_seek = self.backend.playback.time_position.get() + self.assert_(after_seek >= (before_seek + milliseconds_to_seek)) + self.assert_(after_seek < before_seek) + + def test_seek_seeks_to_start_of_track_if_new_position_is_negative(self): + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + self.backend.playback.seek(20000) + + before_seek = self.backend.playback.time_position.get() + self.assert_(before_seek >= 20000) + + milliseconds_to_seek = -30000 + microseconds_to_seek = milliseconds_to_seek * 1000 + + self.mpris.Seek(microseconds_to_seek) + + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + after_seek = self.backend.playback.time_position.get() + self.assert_(after_seek >= (before_seek + milliseconds_to_seek)) + self.assert_(after_seek < before_seek) + self.assert_(after_seek >= 0) + + def test_seek_skips_to_next_track_if_new_position_larger_than_track_length(self): + self.backend.current_playlist.append([Track(uri='a', length=40000), + Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.seek(20000) + + before_seek = self.backend.playback.time_position.get() + self.assert_(before_seek >= 20000) + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + + milliseconds_to_seek = 50000 + microseconds_to_seek = milliseconds_to_seek * 1000 + + self.mpris.Seek(microseconds_to_seek) + + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + + after_seek = self.backend.playback.time_position.get() + self.assert_(after_seek >= 0) + self.assert_(after_seek < before_seek) + + def test_set_position_is_ignored_if_can_seek_is_false(self): + self.mpris.get_CanSeek = lambda *_: False + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + + before_set_position = self.backend.playback.time_position.get() + self.assert_(before_set_position <= 5000) + + track_id = 'a' + + position_to_set_in_milliseconds = 20000 + position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + + self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + + after_set_position = self.backend.playback.time_position.get() + self.assert_(before_set_position <= after_set_position < + position_to_set_in_milliseconds) + + def test_set_position_sets_the_current_track_position_in_microsecs(self): + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + + before_set_position = self.backend.playback.time_position.get() + self.assert_(before_set_position <= 5000) + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + track_id = '/com/mopidy/track/0' + + position_to_set_in_milliseconds = 20000 + position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + + self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + + self.assertEquals(self.backend.playback.state.get(), PLAYING) + + after_set_position = self.backend.playback.time_position.get() + self.assert_(after_set_position >= position_to_set_in_milliseconds) + + def test_set_position_does_nothing_if_the_position_is_negative(self): + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + self.backend.playback.seek(20000) + + before_set_position = self.backend.playback.time_position.get() + self.assert_(before_set_position >= 20000) + self.assert_(before_set_position <= 25000) + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + + track_id = '/com/mopidy/track/0' + + position_to_set_in_milliseconds = -1000 + position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + + self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + + after_set_position = self.backend.playback.time_position.get() + self.assert_(after_set_position >= before_set_position) + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + + def test_set_position_does_nothing_if_position_is_larger_than_track_length(self): + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + self.backend.playback.seek(20000) + + before_set_position = self.backend.playback.time_position.get() + self.assert_(before_set_position >= 20000) + self.assert_(before_set_position <= 25000) + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + + track_id = 'a' + + position_to_set_in_milliseconds = 50000 + position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + + self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + + after_set_position = self.backend.playback.time_position.get() + self.assert_(after_set_position >= before_set_position) + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + + def test_set_position_does_nothing_if_track_id_does_not_match_current_track(self): + self.backend.current_playlist.append([Track(uri='a', length=40000)]) + self.backend.playback.play() + self.backend.playback.seek(20000) + + before_set_position = self.backend.playback.time_position.get() + self.assert_(before_set_position >= 20000) + self.assert_(before_set_position <= 25000) + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + + track_id = 'b' + + position_to_set_in_milliseconds = 0 + position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + + self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + + after_set_position = self.backend.playback.time_position.get() + self.assert_(after_set_position >= before_set_position) + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + + def test_open_uri_is_ignored_if_can_play_is_false(self): + self.mpris.get_CanPlay = lambda *_: False + self.backend.library.provider.dummy_library = [ + Track(uri='dummy:/test/uri')] + self.mpris.OpenUri('dummy:/test/uri') + self.assertEquals(len(self.backend.current_playlist.tracks.get()), 0) + + def test_open_uri_ignores_uris_with_unknown_uri_scheme(self): + self.assertListEqual(self.backend.uri_schemes.get(), ['dummy']) + self.mpris.get_CanPlay = lambda *_: True + self.backend.library.provider.dummy_library = [ + Track(uri='notdummy:/test/uri')] + self.mpris.OpenUri('notdummy:/test/uri') + self.assertEquals(len(self.backend.current_playlist.tracks.get()), 0) + + def test_open_uri_adds_uri_to_current_playlist(self): + self.mpris.get_CanPlay = lambda *_: True + self.backend.library.provider.dummy_library = [ + Track(uri='dummy:/test/uri')] + self.mpris.OpenUri('dummy:/test/uri') + self.assertEquals(self.backend.current_playlist.tracks.get()[0].uri, + 'dummy:/test/uri') + + def test_open_uri_starts_playback_of_new_track_if_stopped(self): + self.mpris.get_CanPlay = lambda *_: True + self.backend.library.provider.dummy_library = [ + Track(uri='dummy:/test/uri')] + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEquals(self.backend.playback.state.get(), STOPPED) + + self.mpris.OpenUri('dummy:/test/uri') + + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, + 'dummy:/test/uri') + + def test_open_uri_starts_playback_of_new_track_if_paused(self): + self.mpris.get_CanPlay = lambda *_: True + self.backend.library.provider.dummy_library = [ + Track(uri='dummy:/test/uri')] + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.backend.playback.pause() + self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + + self.mpris.OpenUri('dummy:/test/uri') + + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, + 'dummy:/test/uri') + + def test_open_uri_starts_playback_of_new_track_if_playing(self): + self.mpris.get_CanPlay = lambda *_: True + self.backend.library.provider.dummy_library = [ + Track(uri='dummy:/test/uri')] + self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.backend.playback.play() + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + + self.mpris.OpenUri('dummy:/test/uri') + + self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.backend.playback.current_track.get().uri, + 'dummy:/test/uri') diff --git a/tests/frontends/mpris/root_interface_test.py b/tests/frontends/mpris/root_interface_test.py new file mode 100644 index 00000000..1e54fc15 --- /dev/null +++ b/tests/frontends/mpris/root_interface_test.py @@ -0,0 +1,70 @@ +import sys + +import mock + +from mopidy import OptionalDependencyError, settings +from mopidy.backends.dummy import DummyBackend + +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() + objects.MprisObject._connect_to_dbus = mock.Mock() + self.backend = DummyBackend.start().proxy() + self.mpris = objects.MprisObject() + + def tearDown(self): + self.backend.stop() + + def test_constructor_connects_to_dbus(self): + self.assert_(self.mpris._connect_to_dbus.called) + + def test_can_raise_returns_false(self): + result = self.mpris.Get(objects.ROOT_IFACE, 'CanRaise') + self.assertFalse(result) + + def test_raise_does_nothing(self): + self.mpris.Raise() + + def test_can_quit_returns_true(self): + result = self.mpris.Get(objects.ROOT_IFACE, 'CanQuit') + self.assertTrue(result) + + def test_quit_should_stop_all_actors(self): + self.mpris.Quit() + self.assert_(objects.exit_process.called) + + def test_has_track_list_returns_false(self): + result = self.mpris.Get(objects.ROOT_IFACE, 'HasTrackList') + self.assertFalse(result) + + def test_identify_is_mopidy(self): + result = self.mpris.Get(objects.ROOT_IFACE, 'Identity') + self.assertEquals(result, 'Mopidy') + + def test_desktop_entry_is_mopidy(self): + result = self.mpris.Get(objects.ROOT_IFACE, 'DesktopEntry') + self.assertEquals(result, 'mopidy') + + def test_desktop_entry_is_based_on_DESKTOP_FILE_setting(self): + settings.runtime['DESKTOP_FILE'] = '/tmp/foo.desktop' + result = self.mpris.Get(objects.ROOT_IFACE, 'DesktopEntry') + self.assertEquals(result, 'foo') + settings.runtime.clear() + + def test_supported_uri_schemes_is_empty(self): + result = self.mpris.Get(objects.ROOT_IFACE, 'SupportedUriSchemes') + self.assertEquals(len(result), 1) + self.assertEquals(result[0], 'dummy') + + def test_supported_mime_types_is_empty(self): + result = self.mpris.Get(objects.ROOT_IFACE, 'SupportedMimeTypes') + self.assertEquals(len(result), 0) diff --git a/tests/gstreamer_test.py b/tests/gstreamer_test.py index 0b9a559e..012c9002 100644 --- a/tests/gstreamer_test.py +++ b/tests/gstreamer_test.py @@ -1,21 +1,14 @@ -import multiprocessing -import unittest - -from tests import SkipTest - -# FIXME Our Windows build server does not support GStreamer yet import sys -if sys.platform == 'win32': - raise SkipTest from mopidy import settings from mopidy.gstreamer import GStreamer from mopidy.utils.path import path_to_uri -from tests import path_to_data_dir +from tests import unittest, path_to_data_dir -# TODO BaseOutputTest? +@unittest.skipIf(sys.platform == 'win32', + 'Our Windows build server does not support GStreamer yet') class GStreamerTest(unittest.TestCase): def setUp(self): settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) @@ -48,11 +41,11 @@ class GStreamerTest(unittest.TestCase): self.gstreamer.start_playback() self.assertTrue(self.gstreamer.stop_playback()) - @SkipTest + @unittest.SkipTest def test_deliver_data(self): pass # TODO - @SkipTest + @unittest.SkipTest def test_end_of_data_stream(self): pass # TODO @@ -71,10 +64,10 @@ class GStreamerTest(unittest.TestCase): self.assertTrue(self.gstreamer.set_volume(100)) self.assertEqual(100, self.gstreamer.get_volume()) - @SkipTest + @unittest.SkipTest def test_set_state_encapsulation(self): pass # TODO - @SkipTest + @unittest.SkipTest def test_set_position(self): pass # TODO diff --git a/tests/help_test.py b/tests/help_test.py index 25f534c2..1fa22c2f 100644 --- a/tests/help_test.py +++ b/tests/help_test.py @@ -1,10 +1,12 @@ import os import subprocess import sys -import unittest import mopidy +from tests import unittest + + class HelpTest(unittest.TestCase): def test_help_has_mopidy_options(self): mopidy_dir = os.path.dirname(mopidy.__file__) diff --git a/tests/listeners_test.py b/tests/listeners_test.py index 761aff4f..486dcf9c 100644 --- a/tests/listeners_test.py +++ b/tests/listeners_test.py @@ -1,14 +1,36 @@ -import unittest - from mopidy.listeners import BackendListener from mopidy.models import Track +from tests import unittest + + class BackendListenerTest(unittest.TestCase): def setUp(self): self.listener = BackendListener() - def test_listener_has_default_impl_for_the_started_playing_event(self): - self.listener.started_playing(Track()) + def test_listener_has_default_impl_for_track_playback_paused(self): + self.listener.track_playback_paused(Track(), 0) - def test_listener_has_default_impl_for_the_stopped_playing_event(self): - self.listener.stopped_playing(Track(), 0) + def test_listener_has_default_impl_for_track_playback_resumed(self): + self.listener.track_playback_resumed(Track(), 0) + + def test_listener_has_default_impl_for_track_playback_started(self): + self.listener.track_playback_started(Track()) + + def test_listener_has_default_impl_for_track_playback_ended(self): + self.listener.track_playback_ended(Track(), 0) + + def test_listener_has_default_impl_for_playback_state_changed(self): + self.listener.playback_state_changed() + + def test_listener_has_default_impl_for_playlist_changed(self): + self.listener.playlist_changed() + + def test_listener_has_default_impl_for_options_changed(self): + self.listener.options_changed() + + def test_listener_has_default_impl_for_volume_changed(self): + self.listener.volume_changed() + + def test_listener_has_default_impl_for_seeked(self): + self.listener.seeked() diff --git a/tests/mixers/denon_test.py b/tests/mixers/denon_test.py index 5370f155..cdfe0772 100644 --- a/tests/mixers/denon_test.py +++ b/tests/mixers/denon_test.py @@ -1,8 +1,9 @@ -import unittest - from mopidy.mixers.denon import DenonMixer from tests.mixers.base_test import BaseMixerTest +from tests import unittest + + class DenonMixerDeviceMock(object): def __init__(self): self._open = True @@ -24,6 +25,7 @@ class DenonMixerDeviceMock(object): def open(self): self._open = True + class DenonMixerTest(BaseMixerTest, unittest.TestCase): ACTUAL_MAX = 99 INITIAL = 1 @@ -32,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 334dc8a1..f9418d7a 100644 --- a/tests/mixers/dummy_test.py +++ b/tests/mixers/dummy_test.py @@ -1,9 +1,10 @@ -import unittest - from mopidy.mixers.dummy import DummyMixer + +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): @@ -15,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/models_test.py b/tests/models_test.py index 637a8209..978f35b6 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -1,9 +1,9 @@ -import datetime as dt -import unittest +import datetime from mopidy.models import Artist, Album, CpTrack, Track, Playlist -from tests import SkipTest +from tests import unittest + class GenericCopyTets(unittest.TestCase): def compare(self, orig, other): @@ -49,6 +49,7 @@ class GenericCopyTets(unittest.TestCase): test = lambda: Track().copy(invalid_key=True) self.assertRaises(TypeError, test) + class ArtistTest(unittest.TestCase): def test_uri(self): uri = u'an_uri' @@ -321,7 +322,7 @@ class TrackTest(unittest.TestCase): self.assertRaises(AttributeError, setattr, track, 'track_no', None) def test_date(self): - date = dt.date(1977, 1, 1) + date = datetime.date(1977, 1, 1) track = Track(date=date) self.assertEqual(track.date, date) self.assertRaises(AttributeError, setattr, track, 'date', None) @@ -400,7 +401,7 @@ class TrackTest(unittest.TestCase): self.assertEqual(hash(track1), hash(track2)) def test_eq_date(self): - date = dt.date.today() + date = datetime.date.today() track1 = Track(date=date) track2 = Track(date=date) self.assertEqual(track1, track2) @@ -425,7 +426,7 @@ class TrackTest(unittest.TestCase): self.assertEqual(hash(track1), hash(track2)) def test_eq(self): - date = dt.date.today() + date = datetime.date.today() artists = [Artist()] album = Album() track1 = Track(uri=u'uri', name=u'name', artists=artists, album=album, @@ -474,8 +475,8 @@ class TrackTest(unittest.TestCase): self.assertNotEqual(hash(track1), hash(track2)) def test_ne_date(self): - track1 = Track(date=dt.date.today()) - track2 = Track(date=dt.date.today()-dt.timedelta(days=1)) + track1 = Track(date=datetime.date.today()) + track2 = Track(date=datetime.date.today()-datetime.timedelta(days=1)) self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) @@ -500,11 +501,11 @@ class TrackTest(unittest.TestCase): def test_ne(self): track1 = Track(uri=u'uri1', name=u'name1', artists=[Artist(name=u'name1')], album=Album(name=u'name1'), - track_no=1, date=dt.date.today(), length=100, bitrate=100, + track_no=1, date=datetime.date.today(), length=100, bitrate=100, musicbrainz_id='id1') track2 = Track(uri=u'uri2', name=u'name2', artists=[Artist(name=u'name2')], album=Album(name=u'name2'), - track_no=2, date=dt.date.today()-dt.timedelta(days=1), + track_no=2, date=datetime.date.today()-datetime.timedelta(days=1), length=200, bitrate=200, musicbrainz_id='id2') self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) @@ -535,7 +536,7 @@ class PlaylistTest(unittest.TestCase): self.assertEqual(playlist.length, 3) def test_last_modified(self): - last_modified = dt.datetime.now() + last_modified = datetime.datetime.now() playlist = Playlist(last_modified=last_modified) self.assertEqual(playlist.last_modified, last_modified) self.assertRaises(AttributeError, setattr, playlist, 'last_modified', @@ -543,7 +544,7 @@ class PlaylistTest(unittest.TestCase): def test_with_new_uri(self): tracks = [Track()] - last_modified = dt.datetime.now() + last_modified = datetime.datetime.now() playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) new_playlist = playlist.copy(uri=u'another uri') @@ -554,7 +555,7 @@ class PlaylistTest(unittest.TestCase): def test_with_new_name(self): tracks = [Track()] - last_modified = dt.datetime.now() + last_modified = datetime.datetime.now() playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) new_playlist = playlist.copy(name=u'another name') @@ -565,7 +566,7 @@ class PlaylistTest(unittest.TestCase): def test_with_new_tracks(self): tracks = [Track()] - last_modified = dt.datetime.now() + last_modified = datetime.datetime.now() playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) new_tracks = [Track(), Track()] @@ -577,8 +578,8 @@ class PlaylistTest(unittest.TestCase): def test_with_new_last_modified(self): tracks = [Track()] - last_modified = dt.datetime.now() - new_last_modified = last_modified + dt.timedelta(1) + last_modified = datetime.datetime.now() + new_last_modified = last_modified + datetime.timedelta(1) playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) new_playlist = playlist.copy(last_modified=new_last_modified) diff --git a/tests/scanner_test.py b/tests/scanner_test.py index f403a221..91e67e11 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -1,10 +1,10 @@ -import unittest from datetime import date from mopidy.scanner import Scanner, translator from mopidy.models import Track, Artist, Album -from tests import path_to_data_dir, SkipTest +from tests import unittest, path_to_data_dir + class FakeGstDate(object): def __init__(self, year, month, day): @@ -12,6 +12,7 @@ class FakeGstDate(object): self.month = month self.day = day + class TranslatorTest(unittest.TestCase): def setUp(self): self.data = { @@ -126,6 +127,7 @@ class TranslatorTest(unittest.TestCase): del self.track['date'] self.check() + class ScannerTest(unittest.TestCase): def setUp(self): self.errors = {} @@ -185,6 +187,6 @@ class ScannerTest(unittest.TestCase): self.scan('scanner/image') self.assert_(self.errors) - @SkipTest + @unittest.SkipTest def test_song_without_time_is_handeled(self): pass 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/init_test.py b/tests/utils/init_test.py index 70dd7e36..2097e3e6 100644 --- a/tests/utils/init_test.py +++ b/tests/utils/init_test.py @@ -1,7 +1,8 @@ -import unittest - from mopidy.utils import get_class +from tests import unittest + + class GetClassTest(unittest.TestCase): def test_loading_module_that_does_not_exist(self): self.assertRaises(ImportError, get_class, 'foo.bar.Baz') diff --git a/tests/utils/network/__init__.py b/tests/utils/network/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/utils/network/connection_test.py b/tests/utils/network/connection_test.py new file mode 100644 index 00000000..aa1be2b6 --- /dev/null +++ b/tests/utils/network/connection_test.py @@ -0,0 +1,539 @@ +import errno +import gobject +import logging +import pykka +import socket +from mock import patch, sentinel, Mock + +from mopidy.utils import network + +from tests import unittest, any_int, any_unicode + + +class ConnectionTest(unittest.TestCase): + def setUp(self): + self.mock = Mock(spec=network.Connection) + + def test_init_ensure_nonblocking_io(self): + sock = Mock(spec=socket.SocketType) + + network.Connection.__init__(self.mock, Mock(), sock, + (sentinel.host, sentinel.port), sentinel.timeout) + sock.setblocking.assert_called_once_with(False) + + def test_init_starts_actor(self): + protocol = Mock(spec=network.LineProtocol) + + network.Connection.__init__(self.mock, protocol, Mock(), + (sentinel.host, sentinel.port), sentinel.timeout) + protocol.start.assert_called_once_with(self.mock) + + def test_init_enables_recv_and_timeout(self): + network.Connection.__init__(self.mock, Mock(), Mock(), + (sentinel.host, sentinel.port), sentinel.timeout) + self.mock.enable_recv.assert_called_once_with() + self.mock.enable_timeout.assert_called_once_with() + + def test_init_stores_values_in_attributes(self): + addr = (sentinel.host, sentinel.port) + protocol = Mock(spec=network.LineProtocol) + sock = Mock(spec=socket.SocketType) + + network.Connection.__init__( + self.mock, protocol, sock, addr, sentinel.timeout) + self.assertEqual(sock, self.mock.sock) + self.assertEqual(protocol, self.mock.protocol) + self.assertEqual(sentinel.timeout, self.mock.timeout) + self.assertEqual(sentinel.host, self.mock.host) + self.assertEqual(sentinel.port, self.mock.port) + + def test_init_handles_ipv6_addr(self): + addr = (sentinel.host, sentinel.port, + sentinel.flowinfo, sentinel.scopeid) + protocol = Mock(spec=network.LineProtocol) + sock = Mock(spec=socket.SocketType) + + network.Connection.__init__( + self.mock, protocol, sock, addr, sentinel.timeout) + self.assertEqual(sentinel.host, self.mock.host) + self.assertEqual(sentinel.port, self.mock.port) + + def test_stop_disables_recv_send_and_timeout(self): + self.mock.stopping = False + self.mock.actor_ref = Mock() + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.stop(self.mock, sentinel.reason) + self.mock.disable_timeout.assert_called_once_with() + self.mock.disable_recv.assert_called_once_with() + self.mock.disable_send.assert_called_once_with() + + def test_stop_closes_socket(self): + self.mock.stopping = False + self.mock.actor_ref = Mock() + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.stop(self.mock, sentinel.reason) + self.mock.sock.close.assert_called_once_with() + + def test_stop_closes_socket_error(self): + self.mock.stopping = False + self.mock.actor_ref = Mock() + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.close.side_effect = socket.error + + network.Connection.stop(self.mock, sentinel.reason) + self.mock.sock.close.assert_called_once_with() + + def test_stop_stops_actor(self): + self.mock.stopping = False + self.mock.actor_ref = Mock() + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.stop(self.mock, sentinel.reason) + self.mock.actor_ref.stop.assert_called_once_with() + + def test_stop_handles_actor_already_being_stopped(self): + self.mock.stopping = False + self.mock.actor_ref = Mock() + self.mock.actor_ref.stop.side_effect = pykka.ActorDeadError() + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.stop(self.mock, sentinel.reason) + self.mock.actor_ref.stop.assert_called_once_with() + + def test_stop_sets_stopping_to_true(self): + self.mock.stopping = False + self.mock.actor_ref = Mock() + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.stop(self.mock, sentinel.reason) + self.assertEqual(True, self.mock.stopping) + + def test_stop_does_not_proceed_when_already_stopping(self): + self.mock.stopping = True + self.mock.actor_ref = Mock() + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.stop(self.mock, sentinel.reason) + self.assertEqual(0, self.mock.actor_ref.stop.call_count) + self.assertEqual(0, self.mock.sock.close.call_count) + + @patch.object(network.logger, 'log', new=Mock()) + def test_stop_logs_reason(self): + self.mock.stopping = False + self.mock.actor_ref = Mock() + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.stop(self.mock, sentinel.reason) + network.logger.log.assert_called_once_with( + logging.DEBUG, sentinel.reason) + + @patch.object(network.logger, 'log', new=Mock()) + def test_stop_logs_reason_with_level(self): + self.mock.stopping = False + self.mock.actor_ref = Mock() + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.stop(self.mock, sentinel.reason, + level=sentinel.level) + network.logger.log.assert_called_once_with( + sentinel.level, sentinel.reason) + + @patch.object(network.logger, 'log', new=Mock()) + def test_stop_logs_that_it_is_calling_itself(self): + self.mock.stopping = True + self.mock.actor_ref = Mock() + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.stop(self.mock, sentinel.reason) + network.logger.log(any_int, any_unicode) + + @patch.object(gobject, 'io_add_watch', new=Mock()) + def test_enable_recv_registers_with_gobject(self): + self.mock.recv_id = None + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.fileno.return_value = sentinel.fileno + gobject.io_add_watch.return_value = sentinel.tag + + network.Connection.enable_recv(self.mock) + gobject.io_add_watch.assert_called_once_with(sentinel.fileno, + gobject.IO_IN | gobject.IO_ERR | gobject.IO_HUP, + self.mock.recv_callback) + self.assertEqual(sentinel.tag, self.mock.recv_id) + + @patch.object(gobject, 'io_add_watch', new=Mock()) + def test_enable_recv_already_registered(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.recv_id = sentinel.tag + + network.Connection.enable_recv(self.mock) + self.assertEqual(0, gobject.io_add_watch.call_count) + + def test_enable_recv_does_not_change_tag(self): + self.mock.recv_id = sentinel.tag + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.enable_recv(self.mock) + self.assertEqual(sentinel.tag, self.mock.recv_id) + + @patch.object(gobject, 'source_remove', new=Mock()) + def test_disable_recv_deregisters(self): + self.mock.recv_id = sentinel.tag + + network.Connection.disable_recv(self.mock) + gobject.source_remove.assert_called_once_with(sentinel.tag) + self.assertEqual(None, self.mock.recv_id) + + @patch.object(gobject, 'source_remove', new=Mock()) + def test_disable_recv_already_deregistered(self): + self.mock.recv_id = None + + network.Connection.disable_recv(self.mock) + self.assertEqual(0, gobject.source_remove.call_count) + self.assertEqual(None, self.mock.recv_id) + + def test_enable_recv_on_closed_socket(self): + self.mock.recv_id = None + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.fileno.side_effect = socket.error(errno.EBADF, '') + + network.Connection.enable_recv(self.mock) + self.mock.stop.assert_called_once_with(any_unicode) + self.assertEqual(None, self.mock.recv_id) + + @patch.object(gobject, 'io_add_watch', new=Mock()) + def test_enable_send_registers_with_gobject(self): + self.mock.send_id = None + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.fileno.return_value = sentinel.fileno + gobject.io_add_watch.return_value = sentinel.tag + + network.Connection.enable_send(self.mock) + gobject.io_add_watch.assert_called_once_with(sentinel.fileno, + gobject.IO_OUT | gobject.IO_ERR | gobject.IO_HUP, + self.mock.send_callback) + self.assertEqual(sentinel.tag, self.mock.send_id) + + @patch.object(gobject, 'io_add_watch', new=Mock()) + def test_enable_send_already_registered(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.send_id = sentinel.tag + + network.Connection.enable_send(self.mock) + self.assertEqual(0, gobject.io_add_watch.call_count) + + def test_enable_send_does_not_change_tag(self): + self.mock.send_id = sentinel.tag + self.mock.sock = Mock(spec=socket.SocketType) + + network.Connection.enable_send(self.mock) + self.assertEqual(sentinel.tag, self.mock.send_id) + + @patch.object(gobject, 'source_remove', new=Mock()) + def test_disable_send_deregisters(self): + self.mock.send_id = sentinel.tag + + network.Connection.disable_send(self.mock) + gobject.source_remove.assert_called_once_with(sentinel.tag) + self.assertEqual(None, self.mock.send_id) + + @patch.object(gobject, 'source_remove', new=Mock()) + def test_disable_send_already_deregistered(self): + self.mock.send_id = None + + network.Connection.disable_send(self.mock) + self.assertEqual(0, gobject.source_remove.call_count) + self.assertEqual(None, self.mock.send_id) + + def test_enable_send_on_closed_socket(self): + self.mock.send_id = None + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.fileno.side_effect = socket.error(errno.EBADF, '') + + network.Connection.enable_send(self.mock) + self.assertEqual(None, self.mock.send_id) + + @patch.object(gobject, 'timeout_add_seconds', new=Mock()) + def test_enable_timeout_clears_existing_timeouts(self): + self.mock.timeout = 10 + + network.Connection.enable_timeout(self.mock) + self.mock.disable_timeout.assert_called_once_with() + + @patch.object(gobject, 'timeout_add_seconds', new=Mock()) + def test_enable_timeout_add_gobject_timeout(self): + self.mock.timeout = 10 + gobject.timeout_add_seconds.return_value = sentinel.tag + + network.Connection.enable_timeout(self.mock) + gobject.timeout_add_seconds.assert_called_once_with(10, + self.mock.timeout_callback) + self.assertEqual(sentinel.tag, self.mock.timeout_id) + + @patch.object(gobject, 'timeout_add_seconds', new=Mock()) + def test_enable_timeout_does_not_add_timeout(self): + self.mock.timeout = 0 + network.Connection.enable_timeout(self.mock) + self.assertEqual(0, gobject.timeout_add_seconds.call_count) + + self.mock.timeout = -1 + network.Connection.enable_timeout(self.mock) + self.assertEqual(0, gobject.timeout_add_seconds.call_count) + + self.mock.timeout = None + network.Connection.enable_timeout(self.mock) + self.assertEqual(0, gobject.timeout_add_seconds.call_count) + + def test_enable_timeout_does_not_call_disable_for_invalid_timeout(self): + self.mock.timeout = 0 + network.Connection.enable_timeout(self.mock) + self.assertEqual(0, self.mock.disable_timeout.call_count) + + self.mock.timeout = -1 + network.Connection.enable_timeout(self.mock) + self.assertEqual(0, self.mock.disable_timeout.call_count) + + self.mock.timeout = None + network.Connection.enable_timeout(self.mock) + self.assertEqual(0, self.mock.disable_timeout.call_count) + + @patch.object(gobject, 'source_remove', new=Mock()) + def test_disable_timeout_deregisters(self): + self.mock.timeout_id = sentinel.tag + + network.Connection.disable_timeout(self.mock) + gobject.source_remove.assert_called_once_with(sentinel.tag) + self.assertEqual(None, self.mock.timeout_id) + + @patch.object(gobject, 'source_remove', new=Mock()) + def test_disable_timeout_already_deregistered(self): + self.mock.timeout_id = None + + network.Connection.disable_timeout(self.mock) + self.assertEqual(0, gobject.source_remove.call_count) + self.assertEqual(None, self.mock.timeout_id) + + def test_queue_send_acquires_and_releases_lock(self): + self.mock.send_lock = Mock() + self.mock.send_buffer = '' + + network.Connection.queue_send(self.mock, 'data') + self.mock.send_lock.acquire.assert_called_once_with(True) + self.mock.send_lock.release.assert_called_once_with() + + def test_queue_send_calls_send(self): + self.mock.send_buffer = '' + self.mock.send_lock = Mock() + self.mock.send.return_value = '' + + network.Connection.queue_send(self.mock, 'data') + self.mock.send.assert_called_once_with('data') + self.assertEqual(0, self.mock.enable_send.call_count) + self.assertEqual('', self.mock.send_buffer) + + def test_queue_send_calls_enable_send_for_partial_send(self): + self.mock.send_buffer = '' + self.mock.send_lock = Mock() + self.mock.send.return_value = 'ta' + + network.Connection.queue_send(self.mock, 'data') + self.mock.send.assert_called_once_with('data') + self.mock.enable_send.assert_called_once_with() + self.assertEqual('ta', self.mock.send_buffer) + + def test_queue_send_calls_send_with_existing_buffer(self): + self.mock.send_buffer = 'foo' + self.mock.send_lock = Mock() + self.mock.send.return_value = '' + + network.Connection.queue_send(self.mock, 'bar') + self.mock.send.assert_called_once_with('foobar') + self.assertEqual(0, self.mock.enable_send.call_count) + self.assertEqual('', self.mock.send_buffer) + + def test_recv_callback_respects_io_err(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.actor_ref = Mock() + + self.assertTrue(network.Connection.recv_callback(self.mock, + sentinel.fd, gobject.IO_IN | gobject.IO_ERR)) + self.mock.stop.assert_called_once_with(any_unicode) + + def test_recv_callback_respects_io_hup(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.actor_ref = Mock() + + self.assertTrue(network.Connection.recv_callback(self.mock, + sentinel.fd, gobject.IO_IN | gobject.IO_HUP)) + self.mock.stop.assert_called_once_with(any_unicode) + + def test_recv_callback_respects_io_hup_and_io_err(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.actor_ref = Mock() + + self.assertTrue(network.Connection.recv_callback(self.mock, + sentinel.fd, gobject.IO_IN | gobject.IO_HUP | gobject.IO_ERR)) + self.mock.stop.assert_called_once_with(any_unicode) + + def test_recv_callback_sends_data_to_actor(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.recv.return_value = 'data' + self.mock.actor_ref = Mock() + + self.assertTrue(network.Connection.recv_callback( + self.mock, sentinel.fd, gobject.IO_IN)) + self.mock.actor_ref.send_one_way.assert_called_once_with( + {'received': 'data'}) + + def test_recv_callback_handles_dead_actors(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.recv.return_value = 'data' + self.mock.actor_ref = Mock() + self.mock.actor_ref.send_one_way.side_effect = pykka.ActorDeadError() + + self.assertTrue(network.Connection.recv_callback( + self.mock, sentinel.fd, gobject.IO_IN)) + self.mock.stop.assert_called_once_with(any_unicode) + + def test_recv_callback_gets_no_data(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.recv.return_value = '' + self.mock.actor_ref = Mock() + + self.assertTrue(network.Connection.recv_callback( + self.mock, sentinel.fd, gobject.IO_IN)) + self.mock.stop.assert_called_once_with(any_unicode) + + def test_recv_callback_recoverable_error(self): + self.mock.sock = Mock(spec=socket.SocketType) + + for error in (errno.EWOULDBLOCK, errno.EINTR): + self.mock.sock.recv.side_effect = socket.error(error, '') + self.assertTrue(network.Connection.recv_callback( + self.mock, sentinel.fd, gobject.IO_IN)) + self.assertEqual(0, self.mock.stop.call_count) + + def test_recv_callback_unrecoverable_error(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.recv.side_effect = socket.error + + self.assertTrue(network.Connection.recv_callback( + self.mock, sentinel.fd, gobject.IO_IN)) + self.mock.stop.assert_called_once_with(any_unicode) + + def test_send_callback_respects_io_err(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.send.return_value = 1 + self.mock.send_lock = Mock() + self.mock.actor_ref = Mock() + self.mock.send_buffer = '' + + self.assertTrue(network.Connection.send_callback(self.mock, + sentinel.fd, gobject.IO_IN | gobject.IO_ERR)) + self.mock.stop.assert_called_once_with(any_unicode) + + def test_send_callback_respects_io_hup(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.send.return_value = 1 + self.mock.send_lock = Mock() + self.mock.actor_ref = Mock() + self.mock.send_buffer = '' + + self.assertTrue(network.Connection.send_callback(self.mock, + sentinel.fd, gobject.IO_IN | gobject.IO_HUP)) + self.mock.stop.assert_called_once_with(any_unicode) + + def test_send_callback_respects_io_hup_and_io_err(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.send.return_value = 1 + self.mock.send_lock = Mock() + self.mock.actor_ref = Mock() + self.mock.send_buffer = '' + + self.assertTrue(network.Connection.send_callback(self.mock, + sentinel.fd, gobject.IO_IN | gobject.IO_HUP | gobject.IO_ERR)) + self.mock.stop.assert_called_once_with(any_unicode) + + def test_send_callback_acquires_and_releases_lock(self): + self.mock.send_lock = Mock() + self.mock.send_lock.acquire.return_value = True + self.mock.send_buffer = '' + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.send.return_value = 0 + + self.assertTrue(network.Connection.send_callback( + self.mock, sentinel.fd, gobject.IO_IN)) + self.mock.send_lock.acquire.assert_called_once_with(False) + self.mock.send_lock.release.assert_called_once_with() + + def test_send_callback_fails_to_acquire_lock(self): + self.mock.send_lock = Mock() + self.mock.send_lock.acquire.return_value = False + self.mock.send_buffer = '' + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.send.return_value = 0 + + self.assertTrue(network.Connection.send_callback( + self.mock, sentinel.fd, gobject.IO_IN)) + self.mock.send_lock.acquire.assert_called_once_with(False) + self.assertEqual(0, self.mock.sock.send.call_count) + + def test_send_callback_sends_all_data(self): + self.mock.send_lock = Mock() + self.mock.send_lock.acquire.return_value = True + self.mock.send_buffer = 'data' + self.mock.send.return_value = '' + + self.assertTrue(network.Connection.send_callback( + self.mock, sentinel.fd, gobject.IO_IN)) + self.mock.disable_send.assert_called_once_with() + self.mock.send.assert_called_once_with('data') + self.assertEqual('', self.mock.send_buffer) + + def test_send_callback_sends_partial_data(self): + self.mock.send_lock = Mock() + self.mock.send_lock.acquire.return_value = True + self.mock.send_buffer = 'data' + self.mock.send.return_value = 'ta' + + self.assertTrue(network.Connection.send_callback( + self.mock, sentinel.fd, gobject.IO_IN)) + self.mock.send.assert_called_once_with('data') + self.assertEqual('ta', self.mock.send_buffer) + + def test_send_recoverable_error(self): + self.mock.sock = Mock(spec=socket.SocketType) + + for error in (errno.EWOULDBLOCK, errno.EINTR): + self.mock.sock.send.side_effect = socket.error(error, '') + + network.Connection.send(self.mock, 'data') + self.assertEqual(0, self.mock.stop.call_count) + + def test_send_calls_socket_send(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.send.return_value = 4 + + self.assertEqual('', network.Connection.send(self.mock, 'data')) + self.mock.sock.send.assert_called_once_with('data') + + def test_send_calls_socket_send_partial_send(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.send.return_value = 2 + + self.assertEqual('ta', network.Connection.send(self.mock, 'data')) + self.mock.sock.send.assert_called_once_with('data') + + def test_send_unrecoverable_error(self): + self.mock.sock = Mock(spec=socket.SocketType) + self.mock.sock.send.side_effect = socket.error + + self.assertEqual('', network.Connection.send(self.mock, 'data')) + self.mock.stop.assert_called_once_with(any_unicode) + + def test_timeout_callback(self): + self.mock.timeout = 10 + + self.assertFalse(network.Connection.timeout_callback(self.mock)) + self.mock.stop.assert_called_once_with(any_unicode) diff --git a/tests/utils/network/lineprotocol_test.py b/tests/utils/network/lineprotocol_test.py new file mode 100644 index 00000000..b323de09 --- /dev/null +++ b/tests/utils/network/lineprotocol_test.py @@ -0,0 +1,290 @@ +#encoding: utf-8 + +import re +from mock import sentinel, Mock + +from mopidy.utils import network + +from tests import unittest + + +class LineProtocolTest(unittest.TestCase): + def setUp(self): + self.mock = Mock(spec=network.LineProtocol) + + self.mock.terminator = network.LineProtocol.terminator + self.mock.encoding = network.LineProtocol.encoding + self.mock.delimeter = network.LineProtocol.delimeter + self.mock.prevent_timeout = False + + def test_init_stores_values_in_attributes(self): + delimeter = re.compile(network.LineProtocol.terminator) + network.LineProtocol.__init__(self.mock, sentinel.connection) + self.assertEqual(sentinel.connection, self.mock.connection) + self.assertEqual('', self.mock.recv_buffer) + self.assertEqual(delimeter, self.mock.delimeter) + self.assertFalse(self.mock.prevent_timeout) + + def test_init_compiles_delimeter(self): + self.mock.delimeter = '\r?\n' + delimeter = re.compile('\r?\n') + + network.LineProtocol.__init__(self.mock, sentinel.connection) + self.assertEqual(delimeter, self.mock.delimeter) + + def test_on_receive_no_new_lines_adds_to_recv_buffer(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.recv_buffer = '' + self.mock.parse_lines.return_value = [] + + network.LineProtocol.on_receive(self.mock, {'received': 'data'}) + self.assertEqual('data', self.mock.recv_buffer) + self.mock.parse_lines.assert_called_once_with() + self.assertEqual(0, self.mock.on_line_received.call_count) + + def test_on_receive_toggles_timeout(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.recv_buffer = '' + self.mock.parse_lines.return_value = [] + + network.LineProtocol.on_receive(self.mock, {'received': 'data'}) + self.mock.connection.disable_timeout.assert_called_once_with() + self.mock.connection.enable_timeout.assert_called_once_with() + + def test_on_receive_toggles_unless_prevent_timeout_is_set(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.recv_buffer = '' + self.mock.parse_lines.return_value = [] + self.mock.prevent_timeout = True + + network.LineProtocol.on_receive(self.mock, {'received': 'data'}) + self.mock.connection.disable_timeout.assert_called_once_with() + self.assertEqual(0, self.mock.connection.enable_timeout.call_count) + + def test_on_receive_no_new_lines_calls_parse_lines(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.recv_buffer = '' + self.mock.parse_lines.return_value = [] + + network.LineProtocol.on_receive(self.mock, {'received': 'data'}) + self.mock.parse_lines.assert_called_once_with() + self.assertEqual(0, self.mock.on_line_received.call_count) + + def test_on_receive_with_new_line_calls_decode(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.recv_buffer = '' + self.mock.parse_lines.return_value = [sentinel.line] + + network.LineProtocol.on_receive(self.mock, {'received': 'data\n'}) + self.mock.parse_lines.assert_called_once_with() + self.mock.decode.assert_called_once_with(sentinel.line) + + def test_on_receive_with_new_line_calls_on_recieve(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.recv_buffer = '' + self.mock.parse_lines.return_value = [sentinel.line] + self.mock.decode.return_value = sentinel.decoded + + network.LineProtocol.on_receive(self.mock, {'received': 'data\n'}) + self.mock.on_line_received.assert_called_once_with(sentinel.decoded) + + def test_on_receive_with_new_line_with_failed_decode(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.recv_buffer = '' + self.mock.parse_lines.return_value = [sentinel.line] + self.mock.decode.return_value = None + + network.LineProtocol.on_receive(self.mock, {'received': 'data\n'}) + self.assertEqual(0, self.mock.on_line_received.call_count) + + def test_on_receive_with_new_lines_calls_on_recieve(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.recv_buffer = '' + self.mock.parse_lines.return_value = ['line1', 'line2'] + self.mock.decode.return_value = sentinel.decoded + + network.LineProtocol.on_receive(self.mock, + {'received': 'line1\nline2\n'}) + self.assertEqual(2, self.mock.on_line_received.call_count) + + def test_parse_lines_emtpy_buffer(self): + self.mock.delimeter = re.compile(r'\n') + self.mock.recv_buffer = '' + + lines = network.LineProtocol.parse_lines(self.mock) + self.assertRaises(StopIteration, lines.next) + + def test_parse_lines_no_terminator(self): + self.mock.delimeter = re.compile(r'\n') + self.mock.recv_buffer = 'data' + + lines = network.LineProtocol.parse_lines(self.mock) + self.assertRaises(StopIteration, lines.next) + + def test_parse_lines_termintor(self): + self.mock.delimeter = re.compile(r'\n') + self.mock.recv_buffer = 'data\n' + + lines = network.LineProtocol.parse_lines(self.mock) + self.assertEqual('data', lines.next()) + self.assertRaises(StopIteration, lines.next) + self.assertEqual('', self.mock.recv_buffer) + + def test_parse_lines_termintor_with_carriage_return(self): + self.mock.delimeter = re.compile(r'\r?\n') + self.mock.recv_buffer = 'data\r\n' + + lines = network.LineProtocol.parse_lines(self.mock) + self.assertEqual('data', lines.next()) + self.assertRaises(StopIteration, lines.next) + self.assertEqual('', self.mock.recv_buffer) + + def test_parse_lines_no_data_before_terminator(self): + self.mock.delimeter = re.compile(r'\n') + self.mock.recv_buffer = '\n' + + lines = network.LineProtocol.parse_lines(self.mock) + self.assertEqual('', lines.next()) + self.assertRaises(StopIteration, lines.next) + self.assertEqual('', self.mock.recv_buffer) + + def test_parse_lines_extra_data_after_terminator(self): + self.mock.delimeter = re.compile(r'\n') + self.mock.recv_buffer = 'data1\ndata2' + + lines = network.LineProtocol.parse_lines(self.mock) + self.assertEqual('data1', lines.next()) + self.assertRaises(StopIteration, lines.next) + self.assertEqual('data2', self.mock.recv_buffer) + + def test_parse_lines_unicode(self): + self.mock.delimeter = re.compile(r'\n') + self.mock.recv_buffer = u'æøå\n'.encode('utf-8') + + lines = network.LineProtocol.parse_lines(self.mock) + self.assertEqual(u'æøå'.encode('utf-8'), lines.next()) + self.assertRaises(StopIteration, lines.next) + self.assertEqual('', self.mock.recv_buffer) + + def test_parse_lines_multiple_lines(self): + self.mock.delimeter = re.compile(r'\n') + self.mock.recv_buffer = 'abc\ndef\nghi\njkl' + + lines = network.LineProtocol.parse_lines(self.mock) + self.assertEqual('abc', lines.next()) + self.assertEqual('def', lines.next()) + self.assertEqual('ghi', lines.next()) + self.assertRaises(StopIteration, lines.next) + self.assertEqual('jkl', self.mock.recv_buffer) + + def test_parse_lines_multiple_calls(self): + self.mock.delimeter = re.compile(r'\n') + self.mock.recv_buffer = 'data1' + + lines = network.LineProtocol.parse_lines(self.mock) + self.assertRaises(StopIteration, lines.next) + self.assertEqual('data1', self.mock.recv_buffer) + + self.mock.recv_buffer += '\ndata2' + + lines = network.LineProtocol.parse_lines(self.mock) + self.assertEqual('data1', lines.next()) + self.assertRaises(StopIteration, lines.next) + self.assertEqual('data2', self.mock.recv_buffer) + + def test_send_lines_called_with_no_lines(self): + self.mock.connection = Mock(spec=network.Connection) + + network.LineProtocol.send_lines(self.mock, []) + self.assertEqual(0, self.mock.encode.call_count) + self.assertEqual(0, self.mock.connection.queue_send.call_count) + + def test_send_lines_calls_join_lines(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.join_lines.return_value = 'lines' + + network.LineProtocol.send_lines(self.mock, sentinel.lines) + self.mock.join_lines.assert_called_once_with(sentinel.lines) + + def test_send_line_encodes_joined_lines_with_final_terminator(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.join_lines.return_value = u'lines\n' + + network.LineProtocol.send_lines(self.mock, sentinel.lines) + self.mock.encode.assert_called_once_with(u'lines\n') + + def test_send_lines_sends_encoded_string(self): + self.mock.connection = Mock(spec=network.Connection) + self.mock.join_lines.return_value = 'lines' + self.mock.encode.return_value = sentinel.data + + network.LineProtocol.send_lines(self.mock, sentinel.lines) + self.mock.connection.queue_send.assert_called_once_with(sentinel.data) + + def test_join_lines_returns_empty_string_for_no_lines(self): + self.assertEqual(u'', network.LineProtocol.join_lines(self.mock, [])) + + def test_join_lines_returns_joined_lines(self): + self.assertEqual(u'1\n2\n', network.LineProtocol.join_lines( + self.mock, [u'1', u'2'])) + + def test_decode_calls_decode_on_string(self): + string = Mock() + + network.LineProtocol.decode(self.mock, string) + string.decode.assert_called_once_with(self.mock.encoding) + + def test_decode_plain_ascii(self): + result = network.LineProtocol.decode(self.mock, 'abc') + self.assertEqual(u'abc', result) + self.assertEqual(unicode, type(result)) + + def test_decode_utf8(self): + result = network.LineProtocol.decode( + self.mock, u'æøå'.encode('utf-8')) + self.assertEqual(u'æøå', result) + self.assertEqual(unicode, type(result)) + + def test_decode_invalid_data(self): + string = Mock() + string.decode.side_effect = UnicodeError + + network.LineProtocol.decode(self.mock, string) + self.mock.stop.assert_called_once_with() + + def test_encode_calls_encode_on_string(self): + string = Mock() + + network.LineProtocol.encode(self.mock, string) + string.encode.assert_called_once_with(self.mock.encoding) + + def test_encode_plain_ascii(self): + result = network.LineProtocol.encode(self.mock, u'abc') + self.assertEqual('abc', result) + self.assertEqual(str, type(result)) + + def test_encode_utf8(self): + result = network.LineProtocol.encode(self.mock, u'æøå') + self.assertEqual(u'æøå'.encode('utf-8'), result) + self.assertEqual(str, type(result)) + + def test_encode_invalid_data(self): + string = Mock() + string.encode.side_effect = UnicodeError + + network.LineProtocol.encode(self.mock, string) + self.mock.stop.assert_called_once_with() + + def test_host_property(self): + mock = Mock(spec=network.Connection) + mock.host = sentinel.host + + lineprotocol = network.LineProtocol(mock) + self.assertEqual(sentinel.host, lineprotocol.host) + + def test_port_property(self): + mock = Mock(spec=network.Connection) + mock.port = sentinel.port + + lineprotocol = network.LineProtocol(mock) + self.assertEqual(sentinel.port, lineprotocol.port) diff --git a/tests/utils/network/server_test.py b/tests/utils/network/server_test.py new file mode 100644 index 00000000..e0399525 --- /dev/null +++ b/tests/utils/network/server_test.py @@ -0,0 +1,186 @@ +import errno +import gobject +import socket +from mock import patch, sentinel, Mock + +from mopidy.utils import network + +from tests import unittest, any_int + + +class ServerTest(unittest.TestCase): + def setUp(self): + self.mock = Mock(spec=network.Server) + + def test_init_calls_create_server_socket(self): + network.Server.__init__(self.mock, sentinel.host, + sentinel.port, sentinel.protocol) + self.mock.create_server_socket.assert_called_once_with( + sentinel.host, sentinel.port) + + def test_init_calls_register_server(self): + sock = Mock(spec=socket.SocketType) + sock.fileno.return_value = sentinel.fileno + self.mock.create_server_socket.return_value = sock + + network.Server.__init__(self.mock, sentinel.host, + sentinel.port, sentinel.protocol) + self.mock.register_server_socket.assert_called_once_with( + sentinel.fileno) + + def test_init_fails_on_fileno_call(self): + sock = Mock(spec=socket.SocketType) + sock.fileno.side_effect = socket.error + self.mock.create_server_socket.return_value = sock + + self.assertRaises(socket.error, network.Server.__init__, + self.mock, sentinel.host, sentinel.port, sentinel.protocol) + + def test_init_stores_values_in_attributes(self): + # This need to be a mock and no a sentinel as fileno() is called on it + sock = Mock(spec=socket.SocketType) + self.mock.create_server_socket.return_value = sock + + network.Server.__init__(self.mock, sentinel.host, sentinel.port, + sentinel.protocol, max_connections=sentinel.max_connections, + timeout=sentinel.timeout) + self.assertEqual(sentinel.protocol, self.mock.protocol) + self.assertEqual(sentinel.max_connections, self.mock.max_connections) + self.assertEqual(sentinel.timeout, self.mock.timeout) + self.assertEqual(sock, self.mock.server_socket) + + @patch.object(network, 'create_socket', spec=socket.SocketType) + def test_create_server_socket_sets_up_listener(self, create_socket): + sock = create_socket.return_value + + network.Server.create_server_socket(self.mock, + sentinel.host, sentinel.port) + sock.setblocking.assert_called_once_with(False) + sock.bind.assert_called_once_with((sentinel.host, sentinel.port)) + sock.listen.assert_called_once_with(any_int) + + @patch.object(network, 'create_socket', new=Mock()) + def test_create_server_socket_fails(self): + network.create_socket.side_effect = socket.error + self.assertRaises(socket.error, network.Server.create_server_socket, + self.mock, sentinel.host, sentinel.port) + + @patch.object(network, 'create_socket', new=Mock()) + def test_create_server_bind_fails(self): + sock = network.create_socket.return_value + sock.bind.side_effect = socket.error + + self.assertRaises(socket.error, network.Server.create_server_socket, + self.mock, sentinel.host, sentinel.port) + + @patch.object(network, 'create_socket', new=Mock()) + def test_create_server_listen_fails(self): + sock = network.create_socket.return_value + sock.listen.side_effect = socket.error + + self.assertRaises(socket.error, network.Server.create_server_socket, + self.mock, sentinel.host, sentinel.port) + + @patch.object(gobject, 'io_add_watch', new=Mock()) + def test_register_server_socket_sets_up_io_watch(self): + network.Server.register_server_socket(self.mock, sentinel.fileno) + gobject.io_add_watch.assert_called_once_with(sentinel.fileno, + gobject.IO_IN, self.mock.handle_connection) + + def test_handle_connection(self): + self.mock.accept_connection.return_value = ( + sentinel.sock, sentinel.addr) + self.mock.maximum_connections_exceeded.return_value = False + + self.assertTrue(network.Server.handle_connection( + self.mock, sentinel.fileno, gobject.IO_IN)) + self.mock.accept_connection.assert_called_once_with() + self.mock.maximum_connections_exceeded.assert_called_once_with() + self.mock.init_connection.assert_called_once_with( + sentinel.sock, sentinel.addr) + self.assertEqual(0, self.mock.reject_connection.call_count) + + def test_handle_connection_exceeded_connections(self): + self.mock.accept_connection.return_value = ( + sentinel.sock, sentinel.addr) + self.mock.maximum_connections_exceeded.return_value = True + + self.assertTrue(network.Server.handle_connection( + self.mock, sentinel.fileno, gobject.IO_IN)) + self.mock.accept_connection.assert_called_once_with() + self.mock.maximum_connections_exceeded.assert_called_once_with() + self.mock.reject_connection.assert_called_once_with( + sentinel.sock, sentinel.addr) + self.assertEqual(0, self.mock.init_connection.call_count) + + def test_accept_connection(self): + sock = Mock(spec=socket.SocketType) + sock.accept.return_value = (sentinel.sock, sentinel.addr) + self.mock.server_socket = sock + + sock, addr = network.Server.accept_connection(self.mock) + self.assertEqual(sentinel.sock, sock) + self.assertEqual(sentinel.addr, addr) + + def test_accept_connection_recoverable_error(self): + sock = Mock(spec=socket.SocketType) + self.mock.server_socket = sock + + for error in (errno.EAGAIN, errno.EINTR): + sock.accept.side_effect = socket.error(error, '') + self.assertRaises(network.ShouldRetrySocketCall, + network.Server.accept_connection, self.mock) + + # FIXME decide if this should be allowed to propegate + def test_accept_connection_unrecoverable_error(self): + sock = Mock(spec=socket.SocketType) + self.mock.server_socket = sock + sock.accept.side_effect = socket.error + self.assertRaises(socket.error, + network.Server.accept_connection, self.mock) + + def test_maximum_connections_exceeded(self): + self.mock.max_connections = 10 + + self.mock.number_of_connections.return_value = 11 + self.assertTrue(network.Server.maximum_connections_exceeded(self.mock)) + + self.mock.number_of_connections.return_value = 10 + self.assertTrue(network.Server.maximum_connections_exceeded(self.mock)) + + self.mock.number_of_connections.return_value = 9 + self.assertFalse(network.Server.maximum_connections_exceeded(self.mock)) + + @patch('pykka.registry.ActorRegistry.get_by_class') + def test_number_of_connections(self, get_by_class): + self.mock.protocol = sentinel.protocol + + get_by_class.return_value = [1, 2, 3] + self.assertEqual(3, network.Server.number_of_connections(self.mock)) + + get_by_class.return_value = [] + self.assertEqual(0, network.Server.number_of_connections(self.mock)) + + @patch.object(network, 'Connection', new=Mock()) + def test_init_connection(self): + self.mock.protocol = sentinel.protocol + self.mock.timeout = sentinel.timeout + + network.Server.init_connection(self.mock, sentinel.sock, sentinel.addr) + network.Connection.assert_called_once_with(sentinel.protocol, + sentinel.sock, sentinel.addr, sentinel.timeout) + + def test_reject_connection(self): + sock = Mock(spec=socket.SocketType) + + network.Server.reject_connection(self.mock, sock, + (sentinel.host, sentinel.port)) + sock.close.assert_called_once_with() + + def test_reject_connection_error(self): + sock = Mock(spec=socket.SocketType) + sock.close.side_effect = socket.error + + network.Server.reject_connection(self.mock, sock, + (sentinel.host, sentinel.port)) + sock.close.assert_called_once_with() diff --git a/tests/utils/network_test.py b/tests/utils/network/utils_test.py similarity index 58% rename from tests/utils/network_test.py rename to tests/utils/network/utils_test.py index 66229036..1e11673e 100644 --- a/tests/utils/network_test.py +++ b/tests/utils/network/utils_test.py @@ -1,57 +1,57 @@ -import mock import socket -import unittest +from mock import patch, Mock from mopidy.utils import network -from tests import SkipTest +from tests import unittest + class FormatHostnameTest(unittest.TestCase): - @mock.patch('mopidy.utils.network.has_ipv6', True) + @patch('mopidy.utils.network.has_ipv6', True) def test_format_hostname_prefixes_ipv4_addresses_when_ipv6_available(self): network.has_ipv6 = True self.assertEqual(network.format_hostname('0.0.0.0'), '::ffff:0.0.0.0') self.assertEqual(network.format_hostname('1.0.0.1'), '::ffff:1.0.0.1') - @mock.patch('mopidy.utils.network.has_ipv6', False) + @patch('mopidy.utils.network.has_ipv6', False) def test_format_hostname_does_nothing_when_only_ipv4_available(self): network.has_ipv6 = False - self.assertEquals(network.format_hostname('0.0.0.0'), '0.0.0.0') + self.assertEqual(network.format_hostname('0.0.0.0'), '0.0.0.0') class TryIPv6SocketTest(unittest.TestCase): - @mock.patch('socket.has_ipv6', False) + @patch('socket.has_ipv6', False) def test_system_that_claims_no_ipv6_support(self): - self.assertFalse(network._try_ipv6_socket()) + self.assertFalse(network.try_ipv6_socket()) - @mock.patch('socket.has_ipv6', True) - @mock.patch('socket.socket') + @patch('socket.has_ipv6', True) + @patch('socket.socket') def test_system_with_broken_ipv6(self, socket_mock): socket_mock.side_effect = IOError() - self.assertFalse(network._try_ipv6_socket()) + self.assertFalse(network.try_ipv6_socket()) - @mock.patch('socket.has_ipv6', True) - @mock.patch('socket.socket') + @patch('socket.has_ipv6', True) + @patch('socket.socket') def test_with_working_ipv6(self, socket_mock): - socket_mock.return_value = mock.Mock() - self.assertTrue(network._try_ipv6_socket()) + socket_mock.return_value = Mock() + self.assertTrue(network.try_ipv6_socket()) class CreateSocketTest(unittest.TestCase): - @mock.patch('mopidy.utils.network.has_ipv6', False) - @mock.patch('socket.socket') + @patch('mopidy.utils.network.has_ipv6', False) + @patch('socket.socket') def test_ipv4_socket(self, socket_mock): network.create_socket() self.assertEqual(socket_mock.call_args[0], (socket.AF_INET, socket.SOCK_STREAM)) - @mock.patch('mopidy.utils.network.has_ipv6', True) - @mock.patch('socket.socket') + @patch('mopidy.utils.network.has_ipv6', True) + @patch('socket.socket') def test_ipv6_socket(self, socket_mock): network.create_socket() self.assertEqual(socket_mock.call_args[0], (socket.AF_INET6, socket.SOCK_STREAM)) - @SkipTest + @unittest.SkipTest def test_ipv6_only_is_set(self): pass diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index 088a7049..19bae375 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -4,12 +4,12 @@ import os import shutil import sys import tempfile -import unittest from mopidy.utils.path import (get_or_create_folder, mtime, path_to_uri, uri_to_path, split_path, find_files) -from tests import path_to_data_dir +from tests import unittest, path_to_data_dir + class GetOrCreateFolderTest(unittest.TestCase): def setUp(self): @@ -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/utils/settings_test.py b/tests/utils/settings_test.py index ec470ea9..55e1156b 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -1,10 +1,12 @@ import os -import unittest from mopidy import settings as default_settings_module, SettingsError from mopidy.utils.settings import (format_settings_list, mask_value_if_secret, SettingsProxy, validate_settings) +from tests import unittest + + class ValidateSettingsTest(unittest.TestCase): def setUp(self): self.defaults = { diff --git a/tests/version_test.py b/tests/version_test.py index 9b53c63f..26045ac1 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -1,12 +1,14 @@ from distutils.version import StrictVersion as SV -import unittest import platform -from mopidy import get_version, 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')) @@ -20,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/idle.py b/tools/idle.py new file mode 100644 index 00000000..aa56dce2 --- /dev/null +++ b/tools/idle.py @@ -0,0 +1,201 @@ +#! /usr/bin/env python + +# This script is helper to systematicly test the behaviour of MPD's idle +# command. It is simply provided as a quick hack, expect nothing more. + +import logging +import pprint +import socket + +host = '' +port = 6601 + +url = "13 - a-ha - White Canvas.mp3" +artist = "a-ha" + +data = {'id': None, 'id2': None, 'url': url, 'artist': artist} + +# Commands to run before test requests to coerce MPD into right state +setup_requests = [ + 'clear', + 'add "%(url)s"', + 'add "%(url)s"', + 'add "%(url)s"', + 'play', +# 'pause', # Uncomment to test paused idle behaviour +# 'stop', # Uncomment to test stopped idle behaviour +] + +# List of commands to test for idle behaviour. Ordering of list is important in +# order to keep MPD state as intended. Commands that are obviously +# informational only or "harmfull" have been excluded. +test_requests = [ + 'add "%(url)s"', + 'addid "%(url)s" "1"', + 'clear', +# 'clearerror', +# 'close', +# 'commands', + 'consume "1"', + 'consume "0"', +# 'count', + 'crossfade "1"', + 'crossfade "0"', +# 'currentsong', +# 'delete "1:2"', + 'delete "0"', + 'deleteid "%(id)s"', + 'disableoutput "0"', + 'enableoutput "0"', +# 'find', +# 'findadd "artist" "%(artist)s"', +# 'idle', +# 'kill', +# 'list', +# 'listall', +# 'listallinfo', +# 'listplaylist', +# 'listplaylistinfo', +# 'listplaylists', +# 'lsinfo', + 'move "0:1" "2"', + 'move "0" "1"', + 'moveid "%(id)s" "1"', + 'next', +# 'notcommands', +# 'outputs', +# 'password', + 'pause', +# 'ping', + 'play', + 'playid "%(id)s"', +# 'playlist', + 'playlistadd "foo" "%(url)s"', + 'playlistclear "foo"', + 'playlistadd "foo" "%(url)s"', + 'playlistdelete "foo" "0"', +# 'playlistfind', +# 'playlistid', +# 'playlistinfo', + 'playlistadd "foo" "%(url)s"', + 'playlistadd "foo" "%(url)s"', + 'playlistmove "foo" "0" "1"', +# 'playlistsearch', +# 'plchanges', +# 'plchangesposid', + 'previous', + 'random "1"', + 'random "0"', + 'rm "bar"', + 'rename "foo" "bar"', + 'repeat "0"', + 'rm "bar"', + 'save "bar"', + 'load "bar"', +# 'search', + 'seek "1" "10"', + 'seekid "%(id)s" "10"', +# 'setvol "10"', + 'shuffle', + 'shuffle "0:1"', + 'single "1"', + 'single "0"', +# 'stats', +# 'status', + 'stop', + 'swap "1" "2"', + 'swapid "%(id)s" "%(id2)s"', +# 'tagtypes', +# 'update', +# 'urlhandlers', +# 'volume', +] + + +def create_socketfile(): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((host, port)) + sock.settimeout(0.5) + fd = sock.makefile('rw', 1) # 1 = line buffered + fd.readline() # Read banner + return fd + + +def wait(fd, prefix=None, collect=None): + while True: + line = fd.readline().rstrip() + if prefix: + logging.debug('%s: %s', prefix, repr(line)) + if line.split()[0] in ('OK', 'ACK'): + break + + +def collect_ids(fd): + fd.write('playlistinfo\n') + + ids = [] + while True: + line = fd.readline() + if line.split()[0] == 'OK': + break + if line.split()[0] == 'Id:': + ids.append(line.split()[1]) + return ids + + +def main(): + subsystems = {} + + command = create_socketfile() + + for test in test_requests: + # Remove any old ids + del data['id'] + del data['id2'] + + # Run setup code to force MPD into known state + for setup in setup_requests: + command.write(setup % data + '\n') + wait(command) + + data['id'], data['id2'] = collect_ids(command)[:2] + + # This connection needs to be make after setup commands are done or + # else they will cause idle events. + idle = create_socketfile() + + # Wait for new idle events + idle.write('idle\n') + + test = test % data + + logging.debug('idle: %s', repr('idle')) + logging.debug('command: %s', repr(test)) + + command.write(test + '\n') + wait(command, prefix='command') + + while True: + try: + line = idle.readline().rstrip() + except socket.timeout: + # Abort try if we time out. + idle.write('noidle\n') + break + + logging.debug('idle: %s', repr(line)) + + if line == 'OK': + break + + request_type = test.split()[0] + subsystem = line.split()[1] + subsystems.setdefault(request_type, set()).add(subsystem) + + logging.debug('---') + + pprint.pprint(subsystems) + + +if __name__ == '__main__': + main()