Merge branch 'develop' into feature/simplify-outputs

Conflicts:
	docs/changes.rst
	mopidy/gstreamer.py
This commit is contained in:
Thomas Adamcik 2012-08-23 00:06:01 +02:00
commit 74a58be60c
137 changed files with 6141 additions and 2526 deletions

View File

@ -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. platforms, including Windows, Mac OS X, Linux, Android and iOS.
To install Mopidy, check out To install Mopidy, check out
`the installation docs <http://www.mopidy.com/docs/master/installation/>`_. `the installation docs <http://docs.mopidy.com/en/latest/installation/>`_.
- `Documentation for the latest release <http://www.mopidy.com/docs/master/>`_ - `Documentation <http://docs.mopidy.com/>`_
- `Documentation for the development version
<http://www.mopidy.com/docs/develop/>`_
- `Source code <http://github.com/mopidy/mopidy>`_ - `Source code <http://github.com/mopidy/mopidy>`_
- `Issue tracker <http://github.com/mopidy/mopidy/issues>`_ - `Issue tracker <http://github.com/mopidy/mopidy/issues>`_
- IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_ - IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_

View File

@ -8,3 +8,4 @@ TryExec=mopidy
Exec=mopidy Exec=mopidy
Terminal=true Terminal=true
Categories=AudioVideo;Audio;Player;ConsoleOnly; Categories=AudioVideo;Audio;Player;ConsoleOnly;
StartupNotify=true

View File

@ -1,15 +0,0 @@
{% extends "!layout.html" %}
{% block footer %}
{{ super() }}
<script type="text/javascript">
var gaJsHost = (("https:" == document.location.protocol) ? "https://ssl." : "http://www.");
document.write(unescape("%3Cscript src='" + gaJsHost + "google-analytics.com/ga.js' type='text/javascript'%3E%3C/script%3E"));
</script>
<script type="text/javascript">
try {
var pageTracker = _gat._getTracker("UA-15510432-1");
pageTracker._trackPageview();
} catch(err) {}
</script>
{% endblock %}

View File

@ -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;
}

View File

@ -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 */

View File

@ -1,4 +0,0 @@
[theme]
inherit = basic
stylesheet = nature.css
pygments_style = tango

View File

@ -7,7 +7,7 @@ The following requirements applies to any frontend implementation:
- A frontend MAY do mostly whatever it wants to, including creating threads, - A frontend MAY do mostly whatever it wants to, including creating threads,
opening TCP ports and exposing Mopidy for a group of clients. opening TCP ports and exposing Mopidy for a group of clients.
- A frontend MUST implement at least one `Pykka - A frontend MUST implement at least one `Pykka
<http://jodal.github.com/pykka/>`_ actor, called the "main actor" from here <http://pykka.readthedocs.org/>`_ actor, called the "main actor" from here
on. on.
- It MAY use additional actors to implement whatever it does, and using actors - It MAY use additional actors to implement whatever it does, and using actors
in frontend implementations is encouraged. in frontend implementations is encouraged.
@ -28,3 +28,4 @@ Frontend implementations
* :mod:`mopidy.frontends.lastfm` * :mod:`mopidy.frontends.lastfm`
* :mod:`mopidy.frontends.mpd` * :mod:`mopidy.frontends.mpd`
* :mod:`mopidy.frontends.mpris`

View File

@ -4,14 +4,144 @@ Changes
This change log is used to track all major changes to Mopidy. 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** **Important changes**
- Pykka 0.12.3 or greater is required. - 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 <http://www.mpris.org/>`_ over D-Bus. In
practice, this makes it possible to control Mopidy through the `Ubuntu Sound
Menu <https://wiki.ubuntu.com/SoundMenu>`_.
**Changes** **Changes**
- Replace :attr:`mopidy.backends.base.Backend.uri_handlers` with - 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 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. 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 - Fix metadata update in Shoutcast streaming. (Fixes: :issue:`122`)
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 - Unescape all incoming MPD requests. (Fixes: :issue:`113`)
it is still the case that switching during playback does not funtion.
- 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) 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. - 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) 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. minor bugs.
**Bugfixes** **Bug fixes**
- Fix broken audio on at least GStreamer 0.10.30, which affects Ubuntu 10.10. - 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. 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** **Important changes**
- Mopidy now depends on `Pykka <http://jodal.github.com/pykka>`_ >=0.12. If you - Mopidy now depends on `Pykka <http://pykka.readthedocs.org/>`_ >=0.12. If you
install from APT, Pykka will automatically be installed. If you are not install from APT, Pykka will automatically be installed. If you are not
installing from APT, you may install Pykka from PyPI:: 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. 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. 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. - 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. 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 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. This is a maintenance release without any new features.
**Bugfixes** **Bug fixes**
- Fix crash in :mod:`mopidy.frontends.lastfm` which occurred at playback if - 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 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: - 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. - Several new generic features, like shuffle, consume, and playlist repeat.
(Fixes: :issue:`3`) (Fixes: :issue:`3`)
- **[Work in Progress]** A new backend for playing music from a local music - **[Work in Progress]** A new backend for playing music from a local music

View File

@ -20,9 +20,8 @@ A command line client. Version 0.14 had some issues with Mopidy (see
ncmpc ncmpc
----- -----
A console client. Uses the ``idle`` command heavily, which Mopidy doesn't A console client. Works with Mopidy 0.6 and upwards. Uses the ``idle`` MPD
support yet (see :issue:`32`). If you want a console client, use ncmpcpp command, but in a resource inefficient way.
instead.
ncmpcpp ncmpcpp
@ -48,15 +47,15 @@ from `Launchpad <https://launchpad.net/ubuntu/+source/ncmpcpp>`_.
Communication mode Communication mode
^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^
In newer versions of ncmpcpp, like 0.5.5 shipped with Ubuntu 11.04, ncmcpp In newer versions of ncmpcpp, like ncmpcpp 0.5.5 shipped with Ubuntu 11.04,
defaults to "notifications" mode for MPD communications, which Mopidy currently ncmcpp defaults to "notifications" mode for MPD communications, which Mopidy
does not support. To workaround this limitation in Mopidy, edit the ncmpcpp did not support before Mopidy 0.6. To workaround this limitation in earlier
configuration file at ``~/.ncmpcpp/config`` and add the following setting:: versions of Mopidy, edit the ncmpcpp configuration file at
``~/.ncmpcpp/config`` and add the following setting::
mpd_communication_mode = "polling" mpd_communication_mode = "polling"
You can track the development of "notifications" mode support in Mopidy in If you use Mopidy 0.6 or newer, you don't need to change anything.
:issue:`32`.
Graphical clients Graphical clients

View File

@ -11,7 +11,50 @@
# All configuration values have a default; values that are commented out # All configuration values have a default; values that are commented out
# serve to show the default. # 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, # 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 # 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__)))
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 ----------------------------------------------------- # -- General configuration -----------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be extensions # Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. # coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = ['sphinx.ext.autodoc', extensions = ['sphinx.ext.autodoc', 'sphinx.ext.graphviz',
'sphinx.ext.graphviz', 'sphinx.ext.inheritance_diagram',
'sphinx.ext.extlinks', 'sphinx.ext.viewcode'] 'sphinx.ext.extlinks', 'sphinx.ext.viewcode']
# Add any paths that contain templates here, relative to this directory. # Add any paths that contain templates here, relative to this directory.
@ -43,14 +87,14 @@ master_doc = 'index'
# General information about the project. # General information about the project.
project = u'Mopidy' 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 # The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the # |version| and |release|, also used in various other places throughout the
# built documents. # built documents.
# #
# The full version, including alpha/beta/rc tags. # The full version, including alpha/beta/rc tags.
release = mopidy.get_version() release = get_version()
# The short X.Y version. # The short X.Y version.
version = '.'.join(release.split('.')[:2]) 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 # The theme to use for HTML and HTML Help pages. Major themes that come with
# Sphinx are currently 'default' and 'sphinxdoc'. # 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 # 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 # 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 # The name of an image file (relative to this directory) to place at the top
# of the sidebar. # 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 # 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 # 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, # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format. # 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 # If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities. # typographically correct entities.
@ -202,4 +247,4 @@ latex_documents = [
needs_sphinx = '1.0' needs_sphinx = '1.0'
extlinks = {'issue': ('http://github.com/mopidy/mopidy/issues/%s', 'GH-')} extlinks = {'issue': ('https://github.com/mopidy/mopidy/issues/%s', 'GH-')}

View File

@ -74,7 +74,7 @@ Running tests
To run tests, you need a couple of dependencies. They can be installed through To run tests, you need a couple of dependencies. They can be installed through
Debian/Ubuntu package management:: 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``:: 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:: 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:: Then, to generate docs::
@ -134,18 +134,8 @@ Then, to generate docs::
make # For help on available targets make # For help on available targets
make html # To generate HTML docs make html # To generate HTML docs
.. note:: The documentation at http://docs.mopidy.com/ is automatically updated when a
documentation update is pushed to ``mopidy/mopidy`` at GitHub.
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.
Creating releases Creating releases

View File

@ -19,9 +19,7 @@ please create an issue in the `issue tracker
Project resources Project resources
================= =================
- `Documentation for the latest release <http://www.mopidy.com/docs/master/>`_ - `Documentation <http://docs.mopidy.com/>`_
- `Documentation for the development version
<http://www.mopidy.com/docs/develop/>`_
- `Source code <http://github.com/mopidy/mopidy>`_ - `Source code <http://github.com/mopidy/mopidy>`_
- `Issue tracker <http://github.com/mopidy/mopidy/issues>`_ - `Issue tracker <http://github.com/mopidy/mopidy/issues>`_
- IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_ - IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_

View File

@ -2,19 +2,21 @@
GStreamer installation 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 Installing GStreamer on Linux
==================== =============================
On Linux
--------
GStreamer is packaged for most popular Linux distributions. Search for GStreamer is packaged for most popular Linux distributions. Search for
GStreamer in your package manager, and make sure to install the Python GStreamer in your package manager, and make sure to install the Python
bindings, and the "good" and "ugly" plugin sets. bindings, and the "good" and "ugly" plugin sets.
Debian/Ubuntu
-------------
If you use Debian/Ubuntu you can install GStreamer like this:: If you use Debian/Ubuntu you can install GStreamer like this::
sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \ 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. 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:: .. note::
We have created GStreamer formulas for Homebrew to make the GStreamer We have been working with `Homebrew <https://github.com/mxcl/homebrew>`_ to
installation easy for you, but not all our formulas have been merged into make all the GStreamer packages easily installable on OS X using Homebrew.
Homebrew's master branch yet. You should either fetch the formula files We've gotten most of our packages included, but the Homebrew guys aren't
from `Homebrew's issue #1612 very happy to include Python specific packages into Homebrew, even though
<http://github.com/mxcl/homebrew/issues/issue/1612>`_ yourself, or fall they are not installable by pip. If you're interested, see the discussion
back to using MacPorts. in `Homebrew's issue #1612
<https://github.com/mxcl/homebrew/issues/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 <https://github.com/mxcl/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 \ brew install gst-python gst-plugins-good gst-plugins-ugly
gstreamer-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 Testing the installation

View File

@ -18,33 +18,36 @@ Requirements
gstreamer gstreamer
libspotify libspotify
If you install Mopidy from the APT archive, as described below, you can skip If you install Mopidy from the APT archive, as described below, APT will take
the dependency installation part. 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 <http://jodal.github.com/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 - GStreamer 0.10.x, with Python bindings. See :doc:`gstreamer`.
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.
- Optional dependencies: - Optional dependencies:
- To use the Last.FM scrobbler, see :mod:`mopidy.frontends.lastfm` for - For Spotify support, you need libspotify and pyspotify. See
additional requirements. :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 Install latest stable release
@ -97,8 +100,8 @@ install Mopidy from PyPI using Pip.
#. Then, you need to install Pip:: #. Then, you need to install Pip::
sudo aptitude install python-setuptools python-pip # On Ubuntu/Debian sudo apt-get install python-setuptools python-pip # On Ubuntu/Debian
sudo easy_install pip # On OS X sudo easy_install pip # On OS X
#. To install the currently latest stable release of Mopidy:: #. 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 </settings>`, and then #. Next, you need to set a couple of :doc:`settings </settings>`, and then
you're ready to :doc:`run Mopidy </running>`. you're ready to :doc:`run Mopidy </running>`.
If you for some reason can't use Pip, try ``easy_install`` instead.
Install development version Install development version
=========================== ===========================
@ -131,8 +132,8 @@ Mopidy's ``develop`` branch.
#. Then, you need to install Pip:: #. Then, you need to install Pip::
sudo aptitude install python-setuptools python-pip # On Ubuntu/Debian sudo apt-get install python-setuptools python-pip # On Ubuntu/Debian
sudo easy_install pip # On OS X sudo easy_install pip # On OS X
#. To install the latest snapshot of Mopidy, run:: #. 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:: #. 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 sudo brew install git # On OS X using Homebrew
#. Clone the official Mopidy repository, or your own fork of it:: #. Clone the official Mopidy repository, or your own fork of it::

View File

@ -12,12 +12,6 @@ install libspotify and `pyspotify <http://pyspotify.mopidy.com/>`_.
This backend requires a paid `Spotify premium account This backend requires a paid `Spotify premium account
<http://www.spotify.com/no/get-spotify/premium/>`_. <http://www.spotify.com/no/get-spotify/premium/>`_.
.. 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 Installing libspotify
===================== =====================
@ -26,23 +20,20 @@ Installing libspotify
On Linux from APT archive On Linux from APT archive
------------------------- -------------------------
If you run a Debian based Linux distribution, like Ubuntu, see If you install from APT, jump directly to :ref:`pyspotify_installation` below.
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`.
On Linux from source On Linux from source
-------------------- --------------------
Download and install libspotify 0.0.8 for your OS and CPU architecture from First, check pyspotify's changelog to see what's the latest version of
https://developer.spotify.com/en/libspotify/. 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 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 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 make install prefix=/usr/local
sudo ldconfig 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 When libspotify has been installed, continue with
:ref:`pyspotify_installation`. :ref:`pyspotify_installation`.
@ -66,7 +60,7 @@ libspotify::
To update your existing libspotify installation using Homebrew:: To update your existing libspotify installation using Homebrew::
brew update brew update
brew install `brew outdated` brew upgrade
When libspotify has been installed, continue with When libspotify has been installed, continue with
:ref:`pyspotify_installation`. :ref:`pyspotify_installation`.
@ -84,29 +78,35 @@ by installing pyspotify.
On Linux from APT archive On Linux from APT archive
------------------------- -------------------------
Assuming that you've already set up http://apt.mopidy.com/ as a software If you run a Debian based Linux distribution, like Ubuntu, see
source, run:: 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 sudo apt-get install python-spotify
If you haven't already installed libspotify, this command will install both This command will install both libspotify and pyspotify for you.
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 On Linux, you need to get the Python development files installed. On
Debian/Ubuntu systems run:: Debian/Ubuntu systems run::
sudo apt-get install python-dev 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``:: Then get, build, and install the latest releast of pyspotify using ``pip``::
sudo pip install -U pyspotify 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

View File

@ -8,7 +8,7 @@ contributed what, please refer to our git repository.
Source code license 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -26,7 +26,7 @@ limitations under the License.
Documentation 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 This work is licensed under the Creative Commons Attribution-ShareAlike 3.0
Unported License. To view a copy of this license, visit Unported License. To view a copy of this license, visit

View File

@ -2,38 +2,14 @@
:mod:`mopidy.frontends.mpd` -- MPD server :mod:`mopidy.frontends.mpd` -- MPD server
***************************************** *****************************************
.. inheritance-diagram:: mopidy.frontends.mpd
.. automodule:: mopidy.frontends.mpd .. automodule:: mopidy.frontends.mpd
:synopsis: MPD server frontend :synopsis: MPD server frontend
:members: :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 MPD dispatcher
============== ==============
.. inheritance-diagram:: mopidy.frontends.mpd.dispatcher
.. automodule:: mopidy.frontends.mpd.dispatcher .. automodule:: mopidy.frontends.mpd.dispatcher
:synopsis: MPD request dispatcher :synopsis: MPD request dispatcher
:members: :members:

View File

@ -0,0 +1,7 @@
***********************************************
:mod:`mopidy.frontends.mpris` -- MPRIS frontend
***********************************************
.. automodule:: mopidy.frontends.mpris
:synopsis: MPRIS frontend
:members:

View File

@ -2,8 +2,6 @@
:mod:`mopidy.gstreamer` -- GStreamer adapter :mod:`mopidy.gstreamer` -- GStreamer adapter
******************************************** ********************************************
.. inheritance-diagram:: mopidy.gstreamer
.. automodule:: mopidy.gstreamer .. automodule:: mopidy.gstreamer
:synopsis: GStreamer adapter :synopsis: GStreamer adapter
:members: :members:

View File

@ -2,8 +2,6 @@
:mod:`mopidy.mixers.alsa` -- ALSA mixer for Linux :mod:`mopidy.mixers.alsa` -- ALSA mixer for Linux
************************************************* *************************************************
.. inheritance-diagram:: mopidy.mixers.alsa
.. automodule:: mopidy.mixers.alsa .. automodule:: mopidy.mixers.alsa
:synopsis: ALSA mixer for Linux :synopsis: ALSA mixer for Linux
:members: :members:

View File

@ -2,8 +2,6 @@
:mod:`mopidy.mixers.denon` -- Hardware mixer for Denon amplifiers :mod:`mopidy.mixers.denon` -- Hardware mixer for Denon amplifiers
***************************************************************** *****************************************************************
.. inheritance-diagram:: mopidy.mixers.denon
.. automodule:: mopidy.mixers.denon .. automodule:: mopidy.mixers.denon
:synopsis: Hardware mixer for Denon amplifiers :synopsis: Hardware mixer for Denon amplifiers
:members: :members:

View File

@ -2,8 +2,6 @@
:mod:`mopidy.mixers.dummy` -- Dummy mixer for testing :mod:`mopidy.mixers.dummy` -- Dummy mixer for testing
***************************************************** *****************************************************
.. inheritance-diagram:: mopidy.mixers.dummy
.. automodule:: mopidy.mixers.dummy .. automodule:: mopidy.mixers.dummy
:synopsis: Dummy mixer for testing :synopsis: Dummy mixer for testing
:members: :members:

View File

@ -2,8 +2,6 @@
:mod:`mopidy.mixers.gstreamer_software` -- Software mixer for all platforms :mod:`mopidy.mixers.gstreamer_software` -- Software mixer for all platforms
*************************************************************************** ***************************************************************************
.. inheritance-diagram:: mopidy.mixers.gstreamer_software
.. automodule:: mopidy.mixers.gstreamer_software .. automodule:: mopidy.mixers.gstreamer_software
:synopsis: Software mixer for all platforms :synopsis: Software mixer for all platforms
:members: :members:

View File

@ -2,8 +2,6 @@
:mod:`mopidy.mixers.nad` -- Hardware mixer for NAD amplifiers :mod:`mopidy.mixers.nad` -- Hardware mixer for NAD amplifiers
************************************************************* *************************************************************
.. inheritance-diagram:: mopidy.mixers.nad
.. automodule:: mopidy.mixers.nad .. automodule:: mopidy.mixers.nad
:synopsis: Hardware mixer for NAD amplifiers :synopsis: Hardware mixer for NAD amplifiers
:members: :members:

View File

@ -2,8 +2,6 @@
:mod:`mopidy.mixers.osa` -- Osa mixer for OS X :mod:`mopidy.mixers.osa` -- Osa mixer for OS X
********************************************** **********************************************
.. inheritance-diagram:: mopidy.mixers.osa
.. automodule:: mopidy.mixers.osa .. automodule:: mopidy.mixers.osa
:synopsis: Osa mixer for OS X :synopsis: Osa mixer for OS X
:members: :members:

View File

@ -4,11 +4,8 @@
The following GStreamer audio outputs implements the :ref:`output-api`. The following GStreamer audio outputs implements the :ref:`output-api`.
.. inheritance-diagram:: mopidy.outputs.custom
.. autoclass:: mopidy.outputs.custom.CustomOutput .. autoclass:: mopidy.outputs.custom.CustomOutput
.. inheritance-diagram:: mopidy.outputs.local
.. autoclass:: mopidy.outputs.local.LocalOutput .. autoclass:: mopidy.outputs.local.LocalOutput
.. inheritance-diagram:: mopidy.outputs.shoutcast
.. autoclass:: mopidy.outputs.shoutcast.ShoutcastOutput .. autoclass:: mopidy.outputs.shoutcast.ShoutcastOutput

View File

@ -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 accept connections by any MPD client. Check out our non-exhaustive
:doc:`/clients/mpd` list to find recommended clients. :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.

View File

@ -10,10 +10,10 @@ changes you may want to do, and a complete listing of available settings.
Changing settings Changing settings
================= =================
Mopidy reads settings from the file ``~/.mopidy/settings.py``, where ``~`` Mopidy reads settings from the file ``~/.config/mopidy/settings.py``, where
means your *home directory*. If your username is ``alice`` and you are running ``~`` means your *home directory*. If your username is ``alice`` and you are
Linux, the settings file should probably be at running Linux, the settings file should probably be at
``/home/alice/.mopidy/settings.py``. ``/home/alice/.config/mopidy/settings.py``.
You can either create the settings file yourself, or run the ``mopidy`` You can either create the settings file yourself, or run the ``mopidy``
command, and it will create an empty settings file for you. 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, 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. 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'::' MPD_SERVER_HOSTNAME = u'::'
SPOTIFY_USERNAME = u'alice' SPOTIFY_USERNAME = u'alice'
@ -77,7 +77,7 @@ To make a ``tag_cache`` of your local music available for Mopidy:
mopidy --list-settings 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 ``stdout``, which means that you will need to redirect the output to a file
yourself:: yourself::
@ -92,7 +92,6 @@ To make a ``tag_cache`` of your local music available for Mopidy:
.. _use_mpd_on_a_network: .. _use_mpd_on_a_network:
Connecting from other machines on the network Connecting from other machines on the network
============================================= =============================================
@ -120,6 +119,33 @@ file::
LASTFM_PASSWORD = u'mysecret' 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
<https://wiki.ubuntu.com/SoundMenu>`_ 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 <http://www.mpris.org/>`_. The
``TrackList`` and the ``Playlists`` interfaces of the spec are not supported.
Streaming audio through a SHOUTcast/Icecast server Streaming audio through a SHOUTcast/Icecast server
================================================== ==================================================

View File

@ -1,17 +1,25 @@
import platform
import sys import sys
if not (2, 6) <= sys.version_info < (3,): if not (2, 6) <= sys.version_info < (3,):
sys.exit(u'Mopidy requires Python >= 2.6, < 3') sys.exit(u'Mopidy requires Python >= 2.6, < 3')
import os
import platform
from subprocess import PIPE, Popen 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(): def get_version():
try: try:
return get_git_version() return get_git_version()
except EnvironmentError: except EnvironmentError:
return get_plain_version() return __version__
def get_git_version(): def get_git_version():
process = Popen(['git', 'describe'], stdout=PIPE, stderr=PIPE) process = Popen(['git', 'describe'], stdout=PIPE, stderr=PIPE)
@ -22,9 +30,6 @@ def get_git_version():
version = version[1:] version = version[1:]
return version return version
def get_plain_version():
return '.'.join(map(str, VERSION))
def get_platform(): def get_platform():
return platform.platform() return platform.platform()

View File

@ -2,6 +2,7 @@ from copy import copy
import logging import logging
import random import random
from mopidy.listeners import BackendListener
from mopidy.models import CpTrack from mopidy.models import CpTrack
logger = logging.getLogger('mopidy.backends.base') logger = logging.getLogger('mopidy.backends.base')
@ -16,13 +17,10 @@ class CurrentPlaylistController(object):
def __init__(self, backend): def __init__(self, backend):
self.backend = backend self.backend = backend
self.cp_id = 0
self._cp_tracks = [] self._cp_tracks = []
self._version = 0 self._version = 0
def destroy(self):
"""Cleanup after component."""
pass
@property @property
def cp_tracks(self): def cp_tracks(self):
""" """
@ -30,7 +28,7 @@ class CurrentPlaylistController(object):
Read-only. Read-only.
""" """
return [copy(ct) for ct in self._cp_tracks] return [copy(cp_track) for cp_track in self._cp_tracks]
@property @property
def tracks(self): def tracks(self):
@ -39,7 +37,14 @@ class CurrentPlaylistController(object):
Read-only. 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 @property
def version(self): def version(self):
@ -53,8 +58,9 @@ class CurrentPlaylistController(object):
def version(self, version): def version(self, version):
self._version = version self._version = version
self.backend.playback.on_current_playlist_change() 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 Add the track to the end of, or at the given position in the current
playlist. playlist.
@ -68,12 +74,14 @@ class CurrentPlaylistController(object):
""" """
assert at_position <= len(self._cp_tracks), \ assert at_position <= len(self._cp_tracks), \
u'at_position can not be greater than playlist length' 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: if at_position is not None:
self._cp_tracks.insert(at_position, cp_track) self._cp_tracks.insert(at_position, cp_track)
else: else:
self._cp_tracks.append(cp_track) self._cp_tracks.append(cp_track)
self.version += 1 if increase_version:
self.version += 1
self.cp_id += 1
return cp_track return cp_track
def append(self, tracks): def append(self, tracks):
@ -84,7 +92,10 @@ class CurrentPlaylistController(object):
:type tracks: list of :class:`mopidy.models.Track` :type tracks: list of :class:`mopidy.models.Track`
""" """
for track in tracks: for track in tracks:
self.add(track) self.add(track, increase_version=False)
if tracks:
self.version += 1
def clear(self): def clear(self):
"""Clear the current playlist.""" """Clear the current playlist."""
@ -112,9 +123,9 @@ class CurrentPlaylistController(object):
matches = self._cp_tracks matches = self._cp_tracks
for (key, value) in criteria.iteritems(): for (key, value) in criteria.iteritems():
if key == 'cpid': if key == 'cpid':
matches = filter(lambda ct: ct[0] == value, matches) matches = filter(lambda ct: ct.cpid == value, matches)
else: else:
matches = filter(lambda ct: getattr(ct[1], key) == value, matches = filter(lambda ct: getattr(ct.track, key) == value,
matches) matches)
if len(matches) == 1: if len(matches) == 1:
return matches[0] return matches[0]
@ -125,6 +136,19 @@ class CurrentPlaylistController(object):
else: else:
raise LookupError(u'"%s" match multiple tracks' % criteria_string) 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): def move(self, start, end, to_position):
""" """
Move the tracks in the slice ``[start:end]`` to ``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 :param criteria: on or more criteria to match by
:type criteria: dict :type criteria: dict
:type track: :class:`mopidy.models.Track`
""" """
cp_track = self.get(**criteria) cp_track = self.get(**criteria)
position = self._cp_tracks.index(cp_track) position = self._cp_tracks.index(cp_track)
@ -199,3 +222,20 @@ class CurrentPlaylistController(object):
random.shuffle(shuffled) random.shuffle(shuffled)
self._cp_tracks = before + shuffled + after self._cp_tracks = before + shuffled + after
self.version += 1 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')

View File

@ -16,10 +16,6 @@ class LibraryController(object):
self.backend = backend self.backend = backend
self.provider = provider self.provider = provider
def destroy(self):
"""Cleanup after component."""
self.provider.destroy()
def find_exact(self, **query): def find_exact(self, **query):
""" """
Search the library for tracks where ``field`` is ``values``. Search the library for tracks where ``field`` is ``values``.
@ -89,14 +85,6 @@ class BaseLibraryProvider(object):
def __init__(self, backend): def __init__(self, backend):
self.backend = backend self.backend = backend
def destroy(self):
"""
Cleanup after component.
*MAY be implemented by subclasses.*
"""
pass
def find_exact(self, **query): def find_exact(self, **query):
""" """
See :meth:`mopidy.backends.base.LibraryController.find_exact`. See :meth:`mopidy.backends.base.LibraryController.find_exact`.

View File

@ -2,12 +2,21 @@ import logging
import random import random
import time import time
from pykka.registry import ActorRegistry
from mopidy.listeners import BackendListener from mopidy.listeners import BackendListener
logger = logging.getLogger('mopidy.backends.base') 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): class PlaybackController(object):
""" """
:param backend: the backend :param backend: the backend
@ -34,7 +43,7 @@ class PlaybackController(object):
#: Tracks are removed from the playlist when they have been played. #: Tracks are removed from the playlist when they have been played.
#: :class:`False` #: :class:`False`
#: Tracks are not removed from the playlist. #: Tracks are not removed from the playlist.
consume = False consume = option_wrapper('_consume', False)
#: The currently playing or selected track. #: The currently playing or selected track.
#: #:
@ -46,21 +55,21 @@ class PlaybackController(object):
#: Tracks are selected at random from the playlist. #: Tracks are selected at random from the playlist.
#: :class:`False` #: :class:`False`
#: Tracks are played in the order of the playlist. #: Tracks are played in the order of the playlist.
random = False random = option_wrapper('_random', False)
#: :class:`True` #: :class:`True`
#: The current playlist is played repeatedly. To repeat a single track, #: The current playlist is played repeatedly. To repeat a single track,
#: select both :attr:`repeat` and :attr:`single`. #: select both :attr:`repeat` and :attr:`single`.
#: :class:`False` #: :class:`False`
#: The current playlist is played once. #: The current playlist is played once.
repeat = False repeat = option_wrapper('_repeat', False)
#: :class:`True` #: :class:`True`
#: Playback is stopped after current song, unless in :attr:`repeat` #: Playback is stopped after current song, unless in :attr:`repeat`
#: mode. #: mode.
#: :class:`False` #: :class:`False`
#: Playback continues after current song. #: Playback continues after current song.
single = False single = option_wrapper('_single', False)
def __init__(self, backend, provider): def __init__(self, backend, provider):
self.backend = backend self.backend = backend
@ -71,12 +80,6 @@ class PlaybackController(object):
self.play_time_accumulated = 0 self.play_time_accumulated = 0
self.play_time_started = None self.play_time_started = None
def destroy(self):
"""
Cleanup after component.
"""
self.provider.destroy()
def _get_cpid(self, cp_track): def _get_cpid(self, cp_track):
if cp_track is None: if cp_track is None:
return None return None
@ -276,6 +279,9 @@ class PlaybackController(object):
def state(self, new_state): def state(self, new_state):
(old_state, self._state) = (self.state, new_state) (old_state, self._state) = (self.state, new_state)
logger.debug(u'Changing state: %s -> %s', old_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 # FIXME play_time stuff assumes backend does not have a better way of
# handeling this stuff :/ # handeling this stuff :/
if (old_state in (self.PLAYING, self.STOPPED) if (old_state in (self.PLAYING, self.STOPPED)
@ -313,6 +319,26 @@ class PlaybackController(object):
def _current_wall_time(self): def _current_wall_time(self):
return int(time.time() * 1000) 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): def on_end_of_track(self):
""" """
Tell the playback controller that end of track is reached. Tell the playback controller that end of track is reached.
@ -326,7 +352,7 @@ class PlaybackController(object):
original_cp_track = self.current_cp_track original_cp_track = self.current_cp_track
if self.cp_track_at_eot: if self.cp_track_at_eot:
self._trigger_stopped_playing_event() self._trigger_track_playback_ended()
self.play(self.cp_track_at_eot) self.play(self.cp_track_at_eot)
else: else:
self.stop(clear_current_track=True) self.stop(clear_current_track=True)
@ -349,20 +375,23 @@ class PlaybackController(object):
self.stop(clear_current_track=True) self.stop(clear_current_track=True)
def next(self): def next(self):
"""Play the next track.""" """
if self.state == self.STOPPED: Change to the next track.
return
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: if self.cp_track_at_next:
self._trigger_stopped_playing_event() self._trigger_track_playback_ended()
self.play(self.cp_track_at_next) self.change_track(self.cp_track_at_next)
else: else:
self.stop(clear_current_track=True) self.stop(clear_current_track=True)
def pause(self): def pause(self):
"""Pause playback.""" """Pause playback."""
if self.state == self.PLAYING and self.provider.pause(): if self.provider.pause():
self.state = self.PAUSED self.state = self.PAUSED
self._trigger_track_playback_paused()
def play(self, cp_track=None, on_error_step=1): def play(self, cp_track=None, on_error_step=1):
""" """
@ -379,15 +408,17 @@ class PlaybackController(object):
if cp_track is not None: if cp_track is not None:
assert cp_track in self.backend.current_playlist.cp_tracks assert cp_track in self.backend.current_playlist.cp_tracks
elif cp_track is None:
if cp_track is None and self.current_cp_track is None: if self.state == self.PAUSED:
cp_track = self.cp_track_at_next return self.resume()
elif self.current_cp_track is not None:
if cp_track is None and self.state == self.PAUSED: cp_track = self.current_cp_track
self.resume() 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: if cp_track is not None:
self.state = self.STOPPED
self.current_cp_track = cp_track self.current_cp_track = cp_track
self.state = self.PLAYING self.state = self.PLAYING
if not self.provider.play(cp_track.track): 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: if self.random and self.current_cp_track in self._shuffled:
self._shuffled.remove(self.current_cp_track) self._shuffled.remove(self.current_cp_track)
self._trigger_started_playing_event() self._trigger_track_playback_started()
def previous(self): def previous(self):
"""Play the previous track.""" """
if self.cp_track_at_previous is None: Change to the previous track.
return
if self.state == self.STOPPED: The current playback state will be kept. If it was playing, playing
return will continue. If it was paused, it will still be paused, etc.
self._trigger_stopped_playing_event() """
self.play(self.cp_track_at_previous, on_error_step=-1) self._trigger_track_playback_ended()
self.change_track(self.cp_track_at_previous, on_error_step=-1)
def resume(self): def resume(self):
"""If paused, resume playing the current track.""" """If paused, resume playing the current track."""
if self.state == self.PAUSED and self.provider.resume(): if self.state == self.PAUSED and self.provider.resume():
self.state = self.PLAYING self.state = self.PLAYING
self._trigger_track_playback_resumed()
def seek(self, time_position): def seek(self, time_position):
""" """
@ -443,7 +476,10 @@ class PlaybackController(object):
self.play_time_started = self._current_wall_time self.play_time_started = self._current_wall_time
self.play_time_accumulated = time_position 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): def stop(self, clear_current_track=False):
""" """
@ -454,37 +490,54 @@ class PlaybackController(object):
:type clear_current_track: boolean :type clear_current_track: boolean
""" """
if self.state != self.STOPPED: if self.state != self.STOPPED:
self._trigger_stopped_playing_event()
if self.provider.stop(): if self.provider.stop():
self._trigger_track_playback_ended()
self.state = self.STOPPED self.state = self.STOPPED
if clear_current_track: if clear_current_track:
self.current_cp_track = None self.current_cp_track = None
def _trigger_started_playing_event(self): def _trigger_track_playback_paused(self):
logger.debug(u'Triggering started playing event') logger.debug(u'Triggering track playback paused event')
if self.current_track is None: if self.current_track is None:
return return
ActorRegistry.broadcast({ BackendListener.send('track_playback_paused',
'command': 'pykka_call', track=self.current_track,
'attr_path': ('started_playing',), time_position=self.time_position)
'args': [],
'kwargs': {'track': self.current_track},
}, target_class=BackendListener)
def _trigger_stopped_playing_event(self): def _trigger_track_playback_resumed(self):
# TODO Test that this is called on next/prev/end-of-track logger.debug(u'Triggering track playback resumed event')
logger.debug(u'Triggering stopped playing event')
if self.current_track is None: if self.current_track is None:
return return
ActorRegistry.broadcast({ BackendListener.send('track_playback_resumed',
'command': 'pykka_call', track=self.current_track,
'attr_path': ('stopped_playing',), time_position=self.time_position)
'args': [],
'kwargs': { def _trigger_track_playback_started(self):
'track': self.current_track, logger.debug(u'Triggering track playback started event')
'time_position': self.time_position, if self.current_track is None:
}, return
}, target_class=BackendListener) 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): class BasePlaybackProvider(object):
@ -498,14 +551,6 @@ class BasePlaybackProvider(object):
def __init__(self, backend): def __init__(self, backend):
self.backend = backend self.backend = backend
def destroy(self):
"""
Cleanup after component.
*MAY be implemented by subclasses.*
"""
pass
def pause(self): def pause(self):
""" """
Pause playback. Pause playback.

View File

@ -17,10 +17,6 @@ class StoredPlaylistsController(object):
self.backend = backend self.backend = backend
self.provider = provider self.provider = provider
def destroy(self):
"""Cleanup after component."""
self.provider.destroy()
@property @property
def playlists(self): def playlists(self):
""" """
@ -133,14 +129,6 @@ class BaseStoredPlaylistsProvider(object):
self.backend = backend self.backend = backend
self._playlists = [] self._playlists = []
def destroy(self):
"""
Cleanup after component.
*MAY be implemented by subclass.*
"""
pass
@property @property
def playlists(self): def playlists(self):
""" """
@ -201,4 +189,3 @@ class BaseStoredPlaylistsProvider(object):
*MUST be implemented by subclass.* *MUST be implemented by subclass.*
""" """
raise NotImplementedError raise NotImplementedError

View File

@ -1,4 +1,5 @@
import glob import glob
import glib
import logging import logging
import os import os
import shutil import shutil
@ -6,7 +7,7 @@ import shutil
from pykka.actor import ThreadingActor from pykka.actor import ThreadingActor
from pykka.registry import ActorRegistry from pykka.registry import ActorRegistry
from mopidy import settings from mopidy import settings, DATA_PATH
from mopidy.backends.base import (Backend, CurrentPlaylistController, from mopidy.backends.base import (Backend, CurrentPlaylistController,
LibraryController, BaseLibraryProvider, PlaybackController, LibraryController, BaseLibraryProvider, PlaybackController,
BasePlaybackProvider, StoredPlaylistsController, BasePlaybackProvider, StoredPlaylistsController,
@ -18,6 +19,14 @@ from .translator import parse_m3u, parse_mpd_tag_cache
logger = logging.getLogger(u'mopidy.backends.local') 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): class LocalBackend(ThreadingActor, Backend):
""" """
A backend for playing music from a local music archive. A backend for playing music from a local music archive.
@ -58,7 +67,8 @@ class LocalBackend(ThreadingActor, Backend):
def on_start(self): def on_start(self):
gstreamer_refs = ActorRegistry.get_by_class(GStreamer) 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() self.gstreamer = gstreamer_refs[0].proxy()
@ -96,7 +106,7 @@ class LocalPlaybackProvider(BasePlaybackProvider):
class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider): class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(LocalStoredPlaylistsProvider, self).__init__(*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() self.refresh()
def lookup(self, uri): def lookup(self, uri):
@ -173,8 +183,8 @@ class LocalLibraryProvider(BaseLibraryProvider):
self.refresh() self.refresh()
def refresh(self, uri=None): def refresh(self, uri=None):
tag_cache = settings.LOCAL_TAG_CACHE_FILE tag_cache = settings.LOCAL_TAG_CACHE_FILE or DEFAULT_TAG_CACHE_FILE
music_folder = settings.LOCAL_MUSIC_PATH music_folder = settings.LOCAL_MUSIC_PATH or DEFAULT_MUSIC_PATH
tracks = parse_mpd_tag_cache(tag_cache, music_folder) tracks = parse_mpd_tag_cache(tag_cache, music_folder)

View File

@ -4,6 +4,7 @@ import os
logger = logging.getLogger('mopidy.backends.local.translator') logger = logging.getLogger('mopidy.backends.local.translator')
from mopidy.models import Track, Artist, Album from mopidy.models import Track, Artist, Album
from mopidy.utils import locale_decode
from mopidy.utils.path import path_to_uri from mopidy.utils.path import path_to_uri
def parse_m3u(file_path): def parse_m3u(file_path):
@ -33,8 +34,8 @@ def parse_m3u(file_path):
try: try:
with open(file_path) as m3u: with open(file_path) as m3u:
contents = m3u.readlines() contents = m3u.readlines()
except IOError, e: except IOError as error:
logger.error('Couldn\'t open m3u: %s', e) logger.error('Couldn\'t open m3u: %s', locale_decode(error))
return uris return uris
for line in contents: for line in contents:
@ -61,8 +62,8 @@ def parse_mpd_tag_cache(tag_cache, music_dir=''):
try: try:
with open(tag_cache) as library: with open(tag_cache) as library:
contents = library.read() contents = library.read()
except IOError, e: except IOError as error:
logger.error('Could not open tag cache: %s', e) logger.error('Could not open tag cache: %s', locale_decode(error))
return tracks return tracks
current = {} current = {}

View File

@ -10,7 +10,6 @@ from mopidy.gstreamer import GStreamer
logger = logging.getLogger('mopidy.backends.spotify') logger = logging.getLogger('mopidy.backends.spotify')
ENCODING = 'utf-8'
BITRATES = {96: 2, 160: 0, 320: 1} BITRATES = {96: 2, 160: 0, 320: 1}
class SpotifyBackend(ThreadingActor, Backend): class SpotifyBackend(ThreadingActor, Backend):
@ -32,8 +31,8 @@ class SpotifyBackend(ThreadingActor, Backend):
**Dependencies:** **Dependencies:**
- libspotify == 0.0.8 (libspotify8 package from apt.mopidy.com) - libspotify >= 10, < 11 (libspotify10 package from apt.mopidy.com)
- pyspotify == 1.3 (python-spotify package from apt.mopidy.com) - pyspotify >= 1.5 (python-spotify package from apt.mopidy.com)
**Settings:** **Settings:**
@ -78,12 +77,16 @@ class SpotifyBackend(ThreadingActor, Backend):
def on_start(self): def on_start(self):
gstreamer_refs = ActorRegistry.get_by_class(GStreamer) 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() self.gstreamer = gstreamer_refs[0].proxy()
logger.info(u'Mopidy uses SPOTIFY(R) CORE') logger.info(u'Mopidy uses SPOTIFY(R) CORE')
self.spotify = self._connect() self.spotify = self._connect()
def on_stop(self):
self.spotify.logout()
def _connect(self): def _connect(self):
from .session_manager import SpotifySessionManager from .session_manager import SpotifySessionManager

View File

@ -13,13 +13,15 @@ class SpotifyContainerManager(PyspotifyContainerManager):
def container_loaded(self, container, userdata): def container_loaded(self, container, userdata):
"""Callback used by pyspotify""" """Callback used by pyspotify"""
logger.debug(u'Callback called: playlist container loaded') logger.debug(u'Callback called: playlist container loaded')
self.session_manager.refresh_stored_playlists() self.session_manager.refresh_stored_playlists()
playlist_container = self.session_manager.session.playlist_container() count = 0
for playlist in playlist_container: for playlist in self.session_manager.session.playlist_container():
self.session_manager.playlist_manager.watch(playlist) if playlist.type() == 'playlist':
logger.debug(u'Watching %d playlist(s) for changes', self.session_manager.playlist_manager.watch(playlist)
len(playlist_container)) count += 1
logger.debug(u'Watching %d playlist(s) for changes', count)
def playlist_added(self, container, playlist, position, userdata): def playlist_added(self, container, playlist, position, userdata):
"""Callback used by pyspotify""" """Callback used by pyspotify"""

View File

@ -4,7 +4,6 @@ import Queue
from spotify import Link, SpotifyError from spotify import Link, SpotifyError
from mopidy.backends.base import BaseLibraryProvider from mopidy.backends.base import BaseLibraryProvider
from mopidy.backends.spotify import ENCODING
from mopidy.backends.spotify.translator import SpotifyTranslator from mopidy.backends.spotify.translator import SpotifyTranslator
from mopidy.models import Playlist from mopidy.models import Playlist
@ -55,7 +54,7 @@ class SpotifyLibraryProvider(BaseLibraryProvider):
spotify_query = u' '.join(spotify_query) spotify_query = u' '.join(spotify_query)
logger.debug(u'Spotify search query: %s' % spotify_query) logger.debug(u'Spotify search query: %s' % spotify_query)
queue = Queue.Queue() queue = Queue.Queue()
self.backend.spotify.search(spotify_query.encode(ENCODING), queue) self.backend.spotify.search(spotify_query, queue)
try: try:
return queue.get(timeout=3) # XXX What is an reasonable timeout? return queue.get(timeout=3) # XXX What is an reasonable timeout?
except Queue.Empty: except Queue.Empty:

View File

@ -27,7 +27,8 @@ class SpotifyPlaylistManager(PyspotifyPlaylistManager):
def tracks_removed(self, playlist, tracks, userdata): def tracks_removed(self, playlist, tracks, userdata):
"""Callback used by pyspotify""" """Callback used by pyspotify"""
logger.debug(u'Callback called: ' logger.debug(u'Callback called: '
u'%d track(s) removed from playlist "%s"', len(tracks), playlist.name()) u'%d track(s) removed from playlist "%s"',
len(tracks), playlist.name())
self.session_manager.refresh_stored_playlists() self.session_manager.refresh_stored_playlists()
def playlist_renamed(self, playlist, userdata): def playlist_renamed(self, playlist, userdata):

View File

@ -6,7 +6,7 @@ from spotify.manager import SpotifySessionManager as PyspotifySessionManager
from pykka.registry import ActorRegistry 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.base import Backend
from mopidy.backends.spotify import BITRATES from mopidy.backends.spotify import BITRATES
from mopidy.backends.spotify.container_manager import SpotifyContainerManager from mopidy.backends.spotify.container_manager import SpotifyContainerManager
@ -21,9 +21,11 @@ logger = logging.getLogger('mopidy.backends.spotify.session_manager')
# pylint: disable = R0901 # pylint: disable = R0901
# SpotifySessionManager: Too many ancestors (9/7) # SpotifySessionManager: Too many ancestors (9/7)
class SpotifySessionManager(BaseThread, PyspotifySessionManager): class SpotifySessionManager(BaseThread, PyspotifySessionManager):
cache_location = settings.SPOTIFY_CACHE_PATH cache_location = (settings.SPOTIFY_CACHE_PATH
settings_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') appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key')
user_agent = 'Mopidy %s' % get_version() user_agent = 'Mopidy %s' % get_version()
@ -41,6 +43,8 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
self.container_manager = None self.container_manager = None
self.playlist_manager = None self.playlist_manager = None
self._initial_data_receive_completed = False
def run_inside_try(self): def run_inside_try(self):
self.setup() self.setup()
self.connect() self.connect()
@ -95,10 +99,6 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
"""Callback used by pyspotify""" """Callback used by pyspotify"""
logger.debug(u'User message: %s', message.strip()) 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, def music_delivery(self, session, frames, frame_size, num_frames,
sample_type, sample_rate, channels): sample_type, sample_rate, channels):
"""Callback used by pyspotify""" """Callback used by pyspotify"""
@ -128,6 +128,17 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
def log_message(self, session, data): def log_message(self, session, data):
"""Callback used by pyspotify""" """Callback used by pyspotify"""
logger.debug(u'System message: %s' % data.strip()) 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): def end_of_track(self, session):
"""Callback used by pyspotify""" """Callback used by pyspotify"""
@ -137,10 +148,11 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
def refresh_stored_playlists(self): def refresh_stored_playlists(self):
"""Refresh the stored playlists in the backend with fresh meta data """Refresh the stored playlists in the backend with fresh meta data
from Spotify""" from Spotify"""
playlists = [] if not self._initial_data_receive_completed:
for spotify_playlist in self.session.playlist_container(): logger.debug(u'Still getting data; skipped refresh of playlists')
playlists.append( return
SpotifyTranslator.to_mopidy_playlist(spotify_playlist)) playlists = map(SpotifyTranslator.to_mopidy_playlist,
self.session.playlist_container())
playlists = filter(None, playlists) playlists = filter(None, playlists)
self.backend.stored_playlists.playlists = playlists self.backend.stored_playlists.playlists = playlists
logger.debug(u'Refreshed %d stored playlist(s)', len(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""" """Search method used by Mopidy backend"""
def callback(results, userdata=None): def callback(results, userdata=None):
# TODO Include results from results.albums(), etc. too # 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=[ playlist = Playlist(tracks=[
SpotifyTranslator.to_mopidy_track(t) SpotifyTranslator.to_mopidy_track(t)
for t in results.tracks()]) for t in results.tracks()])
queue.put(playlist) queue.put(playlist)
self.connected.wait() 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()

View File

@ -4,7 +4,6 @@ import logging
from spotify import Link, SpotifyError from spotify import Link, SpotifyError
from mopidy import settings from mopidy import settings
from mopidy.backends.spotify import ENCODING
from mopidy.models import Artist, Album, Track, Playlist from mopidy.models import Artist, Album, Track, Playlist
logger = logging.getLogger('mopidy.backends.spotify.translator') logger = logging.getLogger('mopidy.backends.spotify.translator')
@ -31,9 +30,10 @@ class SpotifyTranslator(object):
uri = str(Link.from_track(spotify_track, 0)) uri = str(Link.from_track(spotify_track, 0))
if not spotify_track.is_loaded(): if not spotify_track.is_loaded():
return Track(uri=uri, name=u'[loading...]') return Track(uri=uri, name=u'[loading...]')
if (spotify_track.album() is not None and spotify_album = spotify_track.album()
dt.MINYEAR <= int(spotify_track.album().year()) <= dt.MAXYEAR): if (spotify_album is not None and spotify_album.is_loaded()
date = dt.date(spotify_track.album().year(), 1, 1) and dt.MINYEAR <= int(spotify_album.year()) <= dt.MAXYEAR):
date = dt.date(spotify_album.year(), 1, 1)
else: else:
date = None date = None
return Track( return Track(
@ -51,9 +51,8 @@ class SpotifyTranslator(object):
def to_mopidy_playlist(cls, spotify_playlist): def to_mopidy_playlist(cls, spotify_playlist):
if not spotify_playlist.is_loaded(): if not spotify_playlist.is_loaded():
return Playlist(name=u'[loading...]') return Playlist(name=u'[loading...]')
# FIXME Replace this try-except with a check on the playlist type, if spotify_playlist.type() != 'playlist':
# which is currently not supported by pyspotify, to avoid handling return
# playlist folder boundaries like normal playlists.
try: try:
return Playlist( return Playlist(
uri=str(Link.from_playlist(spotify_playlist)), uri=str(Link.from_playlist(spotify_playlist)),
@ -63,5 +62,4 @@ class SpotifyTranslator(object):
if str(Link.from_track(t, 0))], if str(Link.from_track(t, 0))],
) )
except SpotifyError, e: except SpotifyError, e:
logger.info(u'Failed translating Spotify playlist ' logger.warning(u'Failed translating Spotify playlist: %s', e)
'(probably a playlist folder boundary): %s', e)

View File

@ -1,47 +1,48 @@
import logging import logging
import optparse import optparse
import os
import signal import signal
import sys import sys
import time
import gobject
gobject.threads_init()
# Extract any non-GStreamer arguments, and leave the GStreamer arguments for # Extract any non-GStreamer arguments, and leave the GStreamer arguments for
# processing by GStreamer. This needs to be done before GStreamer is imported, # processing by GStreamer. This needs to be done before GStreamer is imported,
# so that GStreamer doesn't hijack e.g. ``--help``. # so that GStreamer doesn't hijack e.g. ``--help``.
# NOTE This naive fix does not support values like ``bar`` in # NOTE This naive fix does not support values like ``bar`` in
# ``--gst-foo bar``. Use equals to pass values, like ``--gst-foo=bar``. # ``--gst-foo bar``. Use equals to pass values, like ``--gst-foo=bar``.
def is_gst_arg(arg): def is_gst_arg(argument):
return arg.startswith('--gst') or arg == '--help-gst' return argument.startswith('--gst') or argument == '--help-gst'
gstreamer_args = [arg for arg in sys.argv[1:] if is_gst_arg(arg)] 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)] mopidy_args = [arg for arg in sys.argv[1:] if not is_gst_arg(arg)]
sys.argv[1:] = gstreamer_args sys.argv[1:] = gstreamer_args
from pykka.registry import ActorRegistry
from mopidy import (get_version, settings, OptionalDependencyError, from mopidy import (get_version, settings, OptionalDependencyError,
SettingsError) SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE)
from mopidy.gstreamer import GStreamer from mopidy.gstreamer import GStreamer
from mopidy.utils import get_class from mopidy.utils import get_class
from mopidy.utils.log import setup_logging from mopidy.utils.log import setup_logging
from mopidy.utils.path import get_or_create_folder, get_or_create_file from mopidy.utils.path import get_or_create_folder, get_or_create_file
from mopidy.utils.process import (GObjectEventThread, exit_handler, from mopidy.utils.process import (exit_handler, stop_remaining_actors,
stop_remaining_actors, stop_actors_by_class) stop_actors_by_class)
from mopidy.utils.settings import list_settings_optparse_callback from mopidy.utils.settings import list_settings_optparse_callback
logger = logging.getLogger('mopidy.core') logger = logging.getLogger('mopidy.core')
def main(): def main():
signal.signal(signal.SIGTERM, exit_handler) signal.signal(signal.SIGTERM, exit_handler)
loop = gobject.MainLoop()
try: try:
options = parse_options() options = parse_options()
setup_logging(options.verbosity_level, options.save_debug_log) setup_logging(options.verbosity_level, options.save_debug_log)
check_old_folders()
setup_settings(options.interactive) setup_settings(options.interactive)
setup_gobject_loop()
setup_gstreamer() setup_gstreamer()
setup_mixer() setup_mixer()
setup_backend() setup_backend()
setup_frontends() setup_frontends()
while True: loop.run()
time.sleep(1)
except SettingsError as e: except SettingsError as e:
logger.error(e.message) logger.error(e.message)
except KeyboardInterrupt: except KeyboardInterrupt:
@ -49,6 +50,7 @@ def main():
except Exception as e: except Exception as e:
logger.exception(e) logger.exception(e)
finally: finally:
loop.quit()
stop_frontends() stop_frontends()
stop_backend() stop_backend()
stop_mixer() stop_mixer()
@ -67,7 +69,7 @@ def parse_options():
action='store_const', const=0, dest='verbosity_level', action='store_const', const=0, dest='verbosity_level',
help='less output (warning level)') help='less output (warning level)')
parser.add_option('-v', '--verbose', 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)') help='more output (debug level)')
parser.add_option('--save-debug-log', parser.add_option('--save-debug-log',
action='store_true', dest='save_debug_log', action='store_true', dest='save_debug_log',
@ -77,18 +79,26 @@ def parse_options():
help='list current settings') help='list current settings')
return parser.parse_args(args=mopidy_args)[0] 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): def setup_settings(interactive):
get_or_create_folder('~/.mopidy/') get_or_create_folder(SETTINGS_PATH)
get_or_create_file('~/.mopidy/settings.py') get_or_create_folder(DATA_PATH)
get_or_create_file(SETTINGS_FILE)
try: try:
settings.validate(interactive) settings.validate(interactive)
except SettingsError, e: except SettingsError, e:
logger.error(e.message) logger.error(e.message)
sys.exit(1) sys.exit(1)
def setup_gobject_loop():
GObjectEventThread().start()
def setup_gstreamer(): def setup_gstreamer():
GStreamer.start() GStreamer.start()

View File

@ -37,6 +37,7 @@ class LastfmFrontend(ThreadingActor, BackendListener):
""" """
def __init__(self): def __init__(self):
super(LastfmFrontend, self).__init__()
self.lastfm = None self.lastfm = None
self.last_start_time = None self.last_start_time = None
@ -57,7 +58,7 @@ class LastfmFrontend(ThreadingActor, BackendListener):
logger.error(u'Error during Last.fm setup: %s', e) logger.error(u'Error during Last.fm setup: %s', e)
self.stop() self.stop()
def started_playing(self, track): def track_playback_started(self, track):
artists = ', '.join([a.name for a in track.artists]) artists = ', '.join([a.name for a in track.artists])
duration = track.length and track.length // 1000 or 0 duration = track.length and track.length // 1000 or 0
self.last_start_time = int(time.time()) self.last_start_time = int(time.time())
@ -74,7 +75,7 @@ class LastfmFrontend(ThreadingActor, BackendListener):
pylast.MalformedResponseError, pylast.WSError) as e: pylast.MalformedResponseError, pylast.WSError) as e:
logger.warning(u'Error submitting playing track to Last.fm: %s', 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]) artists = ', '.join([a.name for a in track.artists])
duration = track.length and track.length // 1000 or 0 duration = track.length and track.length // 1000 or 0
time_position = time_position // 1000 time_position = time_position // 1000

View File

@ -1,14 +1,15 @@
import asyncore
import logging import logging
import sys
from pykka.actor import ThreadingActor from pykka import registry, actor
from mopidy.frontends.mpd.server import MpdServer from mopidy import listeners, settings
from mopidy.utils.process import BaseThread from mopidy.frontends.mpd import dispatcher, protocol
from mopidy.utils import locale_decode, log, network, process
logger = logging.getLogger('mopidy.frontends.mpd') logger = logging.getLogger('mopidy.frontends.mpd')
class MpdFrontend(ThreadingActor): class MpdFrontend(actor.ThreadingActor, listeners.BackendListener):
""" """
The MPD frontend. The MPD frontend.
@ -24,23 +25,86 @@ class MpdFrontend(ThreadingActor):
""" """
def __init__(self): 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): def on_start(self):
self._thread = MpdThread() logger.info(u'New MPD connection from [%s]:%s', self.host, self.port)
self._thread.start() self.send_lines([u'OK MPD %s' % protocol.VERSION])
def on_receive(self, message): def on_line_received(self, line):
pass # Ignore any messages 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): logger.debug(u'Response to [%s]:%s from %s: %s', self.host, self.port,
def __init__(self): self.actor_urn, log.indent(self.terminator.join(response)))
super(MpdThread, self).__init__()
self.name = u'MpdThread'
def run_inside_try(self): self.send_lines(response)
logger.debug(u'Starting MPD server thread')
server = MpdServer() def on_idle(self, subsystem):
server.start() self.dispatcher.handle_idle(subsystem)
asyncore.loop()
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()

View File

@ -27,6 +27,8 @@ class MpdDispatcher(object):
back to the MPD session. back to the MPD session.
""" """
_noidle = re.compile(r'^noidle$')
def __init__(self, session=None): def __init__(self, session=None):
self.authenticated = False self.authenticated = False
self.command_list = False self.command_list = False
@ -42,11 +44,28 @@ class MpdDispatcher(object):
self._catch_mpd_ack_errors_filter, self._catch_mpd_ack_errors_filter,
self._authenticate_filter, self._authenticate_filter,
self._command_list_filter, self._command_list_filter,
self._idle_filter,
self._add_ok_filter, self._add_ok_filter,
self._call_handler_filter, self._call_handler_filter,
] ]
return self._call_next_filter(request, response, filter_chain) 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): def _call_next_filter(self, request, response, filter_chain):
if filter_chain: if filter_chain:
next_filter = filter_chain.pop(0) next_filter = filter_chain.pop(0)
@ -71,7 +90,7 @@ class MpdDispatcher(object):
def _authenticate_filter(self, request, response, filter_chain): def _authenticate_filter(self, request, response, filter_chain):
if self.authenticated: if self.authenticated:
return self._call_next_filter(request, response, filter_chain) 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 self.authenticated = True
return self._call_next_filter(request, response, filter_chain) return self._call_next_filter(request, response, filter_chain)
else: else:
@ -108,6 +127,29 @@ class MpdDispatcher(object):
and request != u'command_list_end') 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 ### Filter: add OK
def _add_ok_filter(self, request, response, filter_chain): def _add_ok_filter(self, request, response, filter_chain):
@ -178,12 +220,20 @@ class MpdContext(object):
#: The current :class:`MpdDispatcher`. #: The current :class:`MpdDispatcher`.
dispatcher = None dispatcher = None
#: The current :class:`mopidy.frontends.mpd.session.MpdSession`. #: The current :class:`mopidy.frontends.mpd.MpdSession`.
session = None 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): def __init__(self, dispatcher, session=None):
self.dispatcher = dispatcher self.dispatcher = dispatcher
self.session = session self.session = session
self.events = set()
self.subscriptions = set()
self._backend = None self._backend = None
self._mixer = None self._mixer = None
@ -192,11 +242,11 @@ class MpdContext(object):
""" """
The backend. An instance of :class:`mopidy.backends.base.Backend`. The backend. An instance of :class:`mopidy.backends.base.Backend`.
""" """
if self._backend is not None: if self._backend is None:
return self._backend backend_refs = ActorRegistry.get_by_class(Backend)
backend_refs = ActorRegistry.get_by_class(Backend) assert len(backend_refs) == 1, \
assert len(backend_refs) == 1, 'Expected exactly one running backend.' 'Expected exactly one running backend.'
self._backend = backend_refs[0].proxy() self._backend = backend_refs[0].proxy()
return self._backend return self._backend
@property @property
@ -204,9 +254,8 @@ class MpdContext(object):
""" """
The mixer. An instance of :class:`mopidy.mixers.base.BaseMixer`. The mixer. An instance of :class:`mopidy.mixers.base.BaseMixer`.
""" """
if self._mixer is not None: if self._mixer is None:
return self._mixer mixer_refs = ActorRegistry.get_by_class(BaseMixer)
mixer_refs = ActorRegistry.get_by_class(BaseMixer) assert len(mixer_refs) == 1, 'Expected exactly one running mixer.'
assert len(mixer_refs) == 1, 'Expected exactly one running mixer.' self._mixer = mixer_refs[0].proxy()
self._mixer = mixer_refs[0].proxy()
return self._mixer return self._mixer

View File

@ -1,7 +1,8 @@
from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError, from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError,
MpdNotImplemented) MpdNotImplemented)
from mopidy.frontends.mpd.protocol import handle_request 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<uri>[^"]*)"$') @handle_request(r'^add "(?P<uri>[^"]*)"$')
def add(context, uri): def add(context, uri):
@ -74,8 +75,8 @@ def delete_range(context, start, end=None):
if end is not None: if end is not None:
end = int(end) end = int(end)
else: else:
end = len(context.backend.current_playlist.tracks.get()) end = context.backend.current_playlist.length.get()
cp_tracks = context.backend.current_playlist.cp_tracks.get()[start:end] cp_tracks = context.backend.current_playlist.slice(start, end).get()
if not cp_tracks: if not cp_tracks:
raise MpdArgError(u'Bad song index', command=u'delete') raise MpdArgError(u'Bad song index', command=u'delete')
for (cpid, _) in cp_tracks: for (cpid, _) in cp_tracks:
@ -86,7 +87,8 @@ def delete_songpos(context, songpos):
"""See :meth:`delete_range`""" """See :meth:`delete_range`"""
try: try:
songpos = int(songpos) 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) context.backend.current_playlist.remove(cpid=cpid)
except IndexError: except IndexError:
raise MpdArgError(u'Bad song index', command=u'delete') raise MpdArgError(u'Bad song index', command=u'delete')
@ -157,8 +159,7 @@ def moveid(context, cpid, to):
cpid = int(cpid) cpid = int(cpid)
to = int(to) to = int(to)
cp_track = context.backend.current_playlist.get(cpid=cpid).get() cp_track = context.backend.current_playlist.get(cpid=cpid).get()
position = context.backend.current_playlist.cp_tracks.get().index( position = context.backend.current_playlist.index(cp_track).get()
cp_track)
context.backend.current_playlist.move(position, position + 1, to) context.backend.current_playlist.move(position, position + 1, to)
@handle_request(r'^playlist$') @handle_request(r'^playlist$')
@ -193,10 +194,8 @@ def playlistfind(context, tag, needle):
if tag == 'filename': if tag == 'filename':
try: try:
cp_track = context.backend.current_playlist.get(uri=needle).get() cp_track = context.backend.current_playlist.get(uri=needle).get()
(cpid, track) = cp_track position = context.backend.current_playlist.index(cp_track).get()
position = context.backend.current_playlist.cp_tracks.get().index( return track_to_mpd_format(cp_track, position=position)
cp_track)
return track.mpd_format(cpid=cpid, position=position)
except LookupError: except LookupError:
return None return None
raise MpdNotImplemented # TODO raise MpdNotImplemented # TODO
@ -215,18 +214,16 @@ def playlistid(context, cpid=None):
try: try:
cpid = int(cpid) cpid = int(cpid)
cp_track = context.backend.current_playlist.get(cpid=cpid).get() cp_track = context.backend.current_playlist.get(cpid=cpid).get()
position = context.backend.current_playlist.cp_tracks.get().index( position = context.backend.current_playlist.index(cp_track).get()
cp_track) return track_to_mpd_format(cp_track, position=position)
return cp_track.track.mpd_format(position=position, cpid=cpid)
except LookupError: except LookupError:
raise MpdNoExistError(u'No such song', command=u'playlistid') raise MpdNoExistError(u'No such song', command=u'playlistid')
else: else:
cpids = [ct[0] for ct in
context.backend.current_playlist.cp_tracks.get()]
return tracks_to_mpd_format( 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$')
@handle_request(r'^playlistinfo "-1"$')
@handle_request(r'^playlistinfo "(?P<songpos>-?\d+)"$') @handle_request(r'^playlistinfo "(?P<songpos>-?\d+)"$')
@handle_request(r'^playlistinfo "(?P<start>\d+):(?P<end>\d+)*"$') @handle_request(r'^playlistinfo "(?P<start>\d+):(?P<end>\d+)*"$')
def playlistinfo(context, songpos=None, def playlistinfo(context, songpos=None,
@ -245,36 +242,22 @@ def playlistinfo(context, songpos=None,
- uses negative indexes, like ``playlistinfo "-1"``, to request - uses negative indexes, like ``playlistinfo "-1"``, to request
the entire playlist the entire playlist
""" """
if songpos == "-1":
songpos = None
if songpos is not None: if songpos is not None:
songpos = int(songpos) songpos = int(songpos)
start = songpos cp_track = context.backend.current_playlist.get(cpid=songpos).get()
end = songpos + 1 return track_to_mpd_format(cp_track, position=songpos)
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)
else: else:
if start is None: if start is None:
start = 0 start = 0
start = int(start) start = int(start)
if not (0 <= start <= len( if not (0 <= start <= context.backend.current_playlist.length.get()):
context.backend.current_playlist.tracks.get())):
raise MpdArgError(u'Bad song index', command=u'playlistinfo') raise MpdArgError(u'Bad song index', command=u'playlistinfo')
if end is not None: if end is not None:
end = int(end) end = int(end)
if end > len(context.backend.current_playlist.tracks.get()): if end > context.backend.current_playlist.length.get():
end = None end = None
cpids = [ct[0] for ct in cp_tracks = context.backend.current_playlist.cp_tracks.get()
context.backend.current_playlist.cp_tracks.get()] return tracks_to_mpd_format(cp_tracks, start, end)
return tracks_to_mpd_format(
context.backend.current_playlist.tracks.get(),
start, end, cpids=cpids)
@handle_request(r'^playlistsearch "(?P<tag>[^"]+)" "(?P<needle>[^"]+)"$') @handle_request(r'^playlistsearch "(?P<tag>[^"]+)" "(?P<needle>[^"]+)"$')
@handle_request(r'^playlistsearch (?P<tag>\S+) "(?P<needle>[^"]+)"$') @handle_request(r'^playlistsearch (?P<tag>\S+) "(?P<needle>[^"]+)"$')
@ -313,10 +296,8 @@ def plchanges(context, version):
""" """
# XXX Naive implementation that returns all tracks as changed # XXX Naive implementation that returns all tracks as changed
if int(version) < context.backend.current_playlist.version: 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( 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<version>\d+)"$') @handle_request(r'^plchangesposid "(?P<version>\d+)"$')
def plchangesposid(context, version): def plchangesposid(context, version):
@ -392,7 +373,6 @@ def swapid(context, cpid1, cpid2):
cpid2 = int(cpid2) cpid2 = int(cpid2)
cp_track1 = context.backend.current_playlist.get(cpid=cpid1).get() cp_track1 = context.backend.current_playlist.get(cpid=cpid1).get()
cp_track2 = context.backend.current_playlist.get(cpid=cpid2).get() cp_track2 = context.backend.current_playlist.get(cpid=cpid2).get()
cp_tracks = context.backend.current_playlist.cp_tracks.get() position1 = context.backend.current_playlist.index(cp_track1).get()
position1 = cp_tracks.index(cp_track1) position2 = context.backend.current_playlist.index(cp_track2).get()
position2 = cp_tracks.index(cp_track2)
swap(context, position1, position2) swap(context, position1, position2)

View File

@ -1,6 +1,6 @@
from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.protocol import handle_request
@handle_request(r'^$') @handle_request(r'^[ ]*$')
def empty(context): def empty(context):
"""The original MPD server returns ``OK`` on an empty request.""" """The original MPD server returns ``OK`` on an empty request."""
pass pass

View File

@ -1,8 +1,9 @@
import re import re
import shlex import shlex
from mopidy.frontends.mpd.protocol import handle_request, stored_playlists
from mopidy.frontends.mpd.exceptions import MpdArgError, MpdNotImplemented 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): def _build_query(mpd_query):
""" """
@ -68,7 +69,8 @@ def find(context, mpd_query):
- also uses the search type "date". - also uses the search type "date".
""" """
query = _build_query(mpd_query) 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 ' @handle_request(r'^findadd '
r'(?P<query>("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? ' r'(?P<query>("?([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.""" """Converts a ``list`` query to a Mopidy query."""
if mpd_query is None: if mpd_query is None:
return {} return {}
# shlex does not seem to be friends with unicode objects try:
tokens = shlex.split(mpd_query.encode('utf-8')) # 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] tokens = [t.decode('utf-8') for t in tokens]
if len(tokens) == 1: if len(tokens) == 1:
if field == u'album': if field == u'album':
@ -324,7 +332,8 @@ def search(context, mpd_query):
- also uses the search type "date". - also uses the search type "date".
""" """
query = _build_query(mpd_query) 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<uri>[^"]+)")*$') @handle_request(r'^update( "(?P<uri>[^"]+)")*$')
def update(context, uri=None, rescan_unmodified_files=False): def update(context, uri=None, rescan_unmodified_files=False):

View File

@ -178,7 +178,8 @@ def playpos(context, songpos):
if songpos == -1: if songpos == -1:
return _play_minus_one(context) return _play_minus_one(context)
try: 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() return context.backend.playback.play(cp_track).get()
except IndexError: except IndexError:
raise MpdArgError(u'Bad song index', command=u'play') 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: elif context.backend.playback.current_cp_track.get() is not None:
cp_track = context.backend.playback.current_cp_track.get() cp_track = context.backend.playback.current_cp_track.get()
return context.backend.playback.play(cp_track).get() return context.backend.playback.play(cp_track).get()
elif context.backend.current_playlist.cp_tracks.get(): elif context.backend.current_playlist.slice(0, 1).get():
cp_track = context.backend.current_playlist.cp_tracks.get()[0] cp_track = context.backend.current_playlist.slice(0, 1).get()[0]
return context.backend.playback.play(cp_track).get() return context.backend.playback.play(cp_track).get()
else: else:
return # Fail silently return # Fail silently

View File

@ -11,28 +11,16 @@ def commands(context):
Shows which commands the current user has access to. Shows which commands the current user has access to.
""" """
if context.dispatcher.authenticated: if context.dispatcher.authenticated:
command_names = [command.name for command in mpd_commands] command_names = set([command.name for command in mpd_commands])
else: else:
command_names = [command.name for command in mpd_commands command_names = set([command.name for command in mpd_commands
if not command.auth_required] if not command.auth_required])
# No permission to use # No one is permited to use kill, rest of commands are not listed by MPD,
if 'kill' in command_names: # so we shouldn't either.
command_names.remove('kill') command_names = command_names - set(['kill', 'command_list_begin',
'command_list_ok_begin', 'command_list_ok_begin', 'command_list_end',
# Not shown by MPD in its command list 'idle', 'noidle', 'sticker'])
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')
return [('command', command_name) for command_name in sorted(command_names)] return [('command', command_name) for command_name in sorted(command_names)]

View File

@ -1,8 +1,13 @@
import pykka.future import pykka.future
from mopidy.backends.base import PlaybackController 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.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$') @handle_request(r'^clearerror$')
def clearerror(context): def clearerror(context):
@ -28,9 +33,8 @@ def currentsong(context):
""" """
current_cp_track = context.backend.playback.current_cp_track.get() current_cp_track = context.backend.playback.current_cp_track.get()
if current_cp_track is not None: if current_cp_track is not None:
return current_cp_track.track.mpd_format( position = context.backend.playback.current_playlist_position.get()
position=context.backend.playback.current_playlist_position.get(), return track_to_mpd_format(current_cp_track, position=position)
cpid=current_cp_track.cpid)
@handle_request(r'^idle$') @handle_request(r'^idle$')
@handle_request(r'^idle (?P<subsystems>.+)$') @handle_request(r'^idle (?P<subsystems>.+)$')
@ -67,12 +71,36 @@ def idle(context, subsystems=None):
notifications when something changed in one of the specified notifications when something changed in one of the specified
subsystems. 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$') @handle_request(r'^noidle$')
def noidle(context): def noidle(context):
"""See :meth:`_status_idle`.""" """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$') @handle_request(r'^stats$')
def stats(context): def stats(context):
@ -125,15 +153,20 @@ def status(context):
- ``nextsongid``: playlist songid of the next song to be played - ``nextsongid``: playlist songid of the next song to be played
- ``time``: total time elapsed (of current playing/paused song) - ``time``: total time elapsed (of current playing/paused song)
- ``elapsed``: Total time elapsed within the current song, but with - ``elapsed``: Total time elapsed within the current song, but with
higher resolution. higher resolution.
- ``bitrate``: instantaneous bitrate in kbps - ``bitrate``: instantaneous bitrate in kbps
- ``xfade``: crossfade in seconds - ``xfade``: crossfade in seconds
- ``audio``: sampleRate``:bits``:channels - ``audio``: sampleRate``:bits``:channels
- ``updatings_db``: job id - ``updatings_db``: job id
- ``error``: if there is an error, returns message here - ``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 = { 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, 'current_playlist.version': context.backend.current_playlist.version,
'mixer.volume': context.mixer.volume, 'mixer.volume': context.mixer.volume,
'playback.consume': context.backend.playback.consume, 'playback.consume': context.backend.playback.consume,
@ -180,7 +213,7 @@ def _status_consume(futures):
return 0 return 0
def _status_playlist_length(futures): def _status_playlist_length(futures):
return len(futures['current_playlist.tracks'].get()) return futures['current_playlist.length'].get()
def _status_playlist_version(futures): def _status_playlist_version(futures):
return futures['current_playlist.version'].get() return futures['current_playlist.version'].get()
@ -214,11 +247,11 @@ def _status_state(futures):
return u'pause' return u'pause'
def _status_time(futures): 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) _status_time_total(futures) // 1000)
def _status_time_elapsed(futures): 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): def _status_time_total(futures):
current_cp_track = futures['playback.current_cp_track'].get() current_cp_track = futures['playback.current_cp_track'].get()

View File

@ -1,7 +1,8 @@
import datetime as dt import datetime as dt
from mopidy.frontends.mpd.protocol import handle_request
from mopidy.frontends.mpd.exceptions import MpdNoExistError, MpdNotImplemented 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<name>[^"]+)"$') @handle_request(r'^listplaylist "(?P<name>[^"]+)"$')
def listplaylist(context, name): def listplaylist(context, name):
@ -40,7 +41,7 @@ def listplaylistinfo(context, name):
""" """
try: try:
playlist = context.backend.stored_playlists.get(name=name).get() playlist = context.backend.stored_playlists.get(name=name).get()
return playlist.mpd_format() return playlist_to_mpd_format(playlist)
except LookupError: except LookupError:
raise MpdNoExistError( raise MpdNoExistError(
u'No such playlist', command=u'listplaylistinfo') u'No such playlist', command=u'listplaylistinfo')

View File

@ -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)

View File

@ -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)

View File

@ -2,26 +2,28 @@ import os
import re import re
from mopidy import settings from mopidy import settings
from mopidy.utils.path import mtime as get_mtime
from mopidy.frontends.mpd import protocol 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. Format track for output to MPD client.
:param track: the track :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 :param position: track's position in playlist
:type position: integer :type position: integer
:param cpid: track's CPID (current playlist ID)
:type cpid: integer
:param key: if we should set key :param key: if we should set key
:type key: boolean :type key: boolean
:param mtime: if we should set mtime :param mtime: if we should set mtime
:type mtime: boolean :type mtime: boolean
:rtype: list of two-tuples :rtype: list of two-tuples
""" """
if isinstance(track, CpTrack):
(cpid, track) = track
else:
(cpid, track) = (None, track)
result = [ result = [
('file', track.uri or ''), ('file', track.uri or ''),
('Time', track.length and (track.length // 1000) or 0), ('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) artists.sort(key=lambda a: a.name)
return u', '.join([a.name for a in artists if 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. Format list of tracks for output to MPD client.
Optionally limit output to the slice ``[start:end]`` of the list. Optionally limit output to the slice ``[start:end]`` of the list.
:param tracks: the tracks :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 :param start: position of first track to include in output
:type start: int (positive or negative) :type start: int (positive or negative)
:param end: position after last track to include in output :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) end = len(tracks)
tracks = tracks[start:end] tracks = tracks[start:end]
positions = range(start, end) positions = range(start, end)
cpids = cpids and cpids[start:end] or [None for _ in tracks] assert len(tracks) == len(positions)
assert len(tracks) == len(positions) == len(cpids)
result = [] result = []
for track, position, cpid in zip(tracks, positions, cpids): for track, position in zip(tracks, positions):
result.append(track_to_mpd_format(track, position, cpid)) result.append(track_to_mpd_format(track, position))
return result return result
def playlist_to_mpd_format(playlist, *args, **kwargs): def playlist_to_mpd_format(playlist, *args, **kwargs):

View File

@ -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 <http://www.mpris.org/>`_) D-Bus
interface.
An example of an MPRIS client is the `Ubuntu Sound Menu
<https://wiki.ubuntu.com/SoundMenu>`_.
**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 <https://wiki.ubuntu.com/SoundMenu>`_.
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'))

View File

@ -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

View File

@ -13,15 +13,6 @@ from mopidy.backends.base import Backend
logger = logging.getLogger('mopidy.gstreamer') 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): class GStreamer(ThreadingActor):
""" """
@ -34,6 +25,15 @@ class GStreamer(ThreadingActor):
""" """
def __init__(self): 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._pipeline = None
self._source = None self._source = None
self._uridecodebin = None self._uridecodebin = None
@ -42,9 +42,6 @@ class GStreamer(ThreadingActor):
self._handlers = {} self._handlers = {}
def on_start(self): 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_pipeline()
self._setup_outputs() self._setup_outputs()
self._setup_message_processor() self._setup_message_processor()
@ -78,12 +75,14 @@ class GStreamer(ThreadingActor):
def _on_new_source(self, element, pad): def _on_new_source(self, element, pad):
self._source = element.get_property('source') self._source = element.get_property('source')
try: try:
self._source.set_property('caps', default_caps) self._source.set_property('caps', self._default_caps)
except TypeError: except TypeError:
pass pass
def _on_new_pad(self, source, pad, target_pad): def _on_new_pad(self, source, pad, target_pad):
if not pad.is_linked(): if not pad.is_linked():
if target_pad.is_linked():
target_pad.get_peer().unlink(target_pad)
pad.link(target_pad) pad.link(target_pad)
def _on_message(self, bus, message): def _on_message(self, bus, message):
@ -300,5 +299,3 @@ class GStreamer(ThreadingActor):
output.sync_state_with_parent() # Required to add to running pipe output.sync_state_with_parent() # Required to add to running pipe
gst.element_link_many(self._volume, output) gst.element_link_many(self._volume, output)
logger.debug('Output set to %s', output.get_name()) logger.debug('Output set to %s', output.get_name())
# FIXME re-add disconnect / swap output code?

View File

@ -1,3 +1,5 @@
from pykka import registry
class BackendListener(object): class BackendListener(object):
""" """
Marker interface for recipients of events sent by the backend. Marker interface for recipients of events sent by the backend.
@ -9,7 +11,46 @@ class BackendListener(object):
interested in all events. 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. Called whenever a new track starts playing.
@ -20,9 +61,9 @@ class BackendListener(object):
""" """
pass 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. *MAY* be implemented by actor.
@ -32,3 +73,44 @@ class BackendListener(object):
:type time_position: int :type time_position: int
""" """
pass 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

View File

@ -23,6 +23,7 @@ class AlsaMixer(ThreadingActor, BaseMixer):
""" """
def __init__(self): def __init__(self):
super(AlsaMixer, self).__init__()
self._mixer = None self._mixer = None
def on_start(self): def on_start(self):

View File

@ -1,4 +1,8 @@
from mopidy import settings import logging
from mopidy import listeners, settings
logger = logging.getLogger('mopidy.mixers')
class BaseMixer(object): class BaseMixer(object):
""" """
@ -17,19 +21,31 @@ class BaseMixer(object):
Integer in range [0, 100]. :class:`None` if unknown. Values below 0 is Integer in range [0, 100]. :class:`None` if unknown. Values below 0 is
equal to 0. Values above 100 is equal to 100. equal to 0. Values above 100 is equal to 100.
""" """
if not hasattr(self, '_user_volume'):
self._user_volume = 0
volume = self.get_volume() volume = self.get_volume()
if volume is None: if volume is None or not self.amplification_factor < 1:
return None return volume
return int(volume / self.amplification_factor) 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 @volume.setter
def volume(self, volume): 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: if volume < 0:
volume = 0 volume = 0
elif volume > 100: elif volume > 100:
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): def get_volume(self):
""" """
@ -46,3 +62,7 @@ class BaseMixer(object):
*MUST be implemented by subclass.* *MUST be implemented by subclass.*
""" """
raise NotImplementedError raise NotImplementedError
def _trigger_volume_changed(self):
logger.debug(u'Triggering volume changed event')
listeners.BackendListener.send('volume_changed')

View File

@ -25,8 +25,9 @@ class DenonMixer(ThreadingActor, BaseMixer):
- :attr:`mopidy.settings.MIXER_EXT_PORT` -- Example: ``/dev/ttyUSB0`` - :attr:`mopidy.settings.MIXER_EXT_PORT` -- Example: ``/dev/ttyUSB0``
""" """
def __init__(self, *args, **kwargs): def __init__(self, device=None):
self._device = kwargs.get('device', None) super(DenonMixer, self).__init__()
self._device = device
self._levels = ['99'] + ["%(#)02d" % {'#': v} for v in range(0, 99)] self._levels = ['99'] + ["%(#)02d" % {'#': v} for v in range(0, 99)]
self._volume = 0 self._volume = 0

View File

@ -6,6 +6,7 @@ class DummyMixer(ThreadingActor, BaseMixer):
"""Mixer which just stores and reports the chosen volume.""" """Mixer which just stores and reports the chosen volume."""
def __init__(self): def __init__(self):
super(DummyMixer, self).__init__()
self._volume = None self._volume = None
def get_volume(self): def get_volume(self):

View File

@ -8,6 +8,7 @@ class GStreamerSoftwareMixer(ThreadingActor, BaseMixer):
"""Mixer which uses GStreamer to control volume in software.""" """Mixer which uses GStreamer to control volume in software."""
def __init__(self): def __init__(self):
super(GStreamerSoftwareMixer, self).__init__()
self.output = None self.output = None
def on_start(self): def on_start(self):

View File

@ -37,6 +37,7 @@ class NadMixer(ThreadingActor, BaseMixer):
""" """
def __init__(self): def __init__(self):
super(NadMixer, self).__init__()
self._volume_cache = None self._volume_cache = None
self._nad_talker = NadTalker.start().proxy() self._nad_talker = NadTalker.start().proxy()
@ -71,6 +72,7 @@ class NadTalker(ThreadingActor):
_nad_volume = None _nad_volume = None
def __init__(self): def __init__(self):
super(NadTalker, self).__init__()
self._device = None self._device = None
def on_start(self): def on_start(self):

View File

@ -185,10 +185,6 @@ class Track(ImmutableObject):
self.__dict__['artists'] = frozenset(kwargs.pop('artists', [])) self.__dict__['artists'] = frozenset(kwargs.pop('artists', []))
super(Track, self).__init__(*args, **kwargs) 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): class Playlist(ImmutableObject):
""" """
@ -224,7 +220,3 @@ class Playlist(ImmutableObject):
def length(self): def length(self):
"""The number of tracks in the playlist. Read-only.""" """The number of tracks in the playlist. Read-only."""
return len(self.tracks) 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)

View File

@ -4,7 +4,7 @@ Available settings and their default values.
.. warning:: .. warning::
Do *not* change settings directly in :mod:`mopidy.settings`. Instead, add a 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 #: 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'
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. #: List of server frontends to use.
#: #:
#: Default:: #: Default::
@ -56,10 +65,12 @@ DEBUG_LOG_FILENAME = u'mopidy.log'
#: FRONTENDS = ( #: FRONTENDS = (
#: u'mopidy.frontends.mpd.MpdFrontend', #: u'mopidy.frontends.mpd.MpdFrontend',
#: u'mopidy.frontends.lastfm.LastfmFrontend', #: u'mopidy.frontends.lastfm.LastfmFrontend',
#: u'mopidy.frontends.mpris.MprisFrontend',
#: ) #: )
FRONTENDS = ( FRONTENDS = (
u'mopidy.frontends.mpd.MpdFrontend', u'mopidy.frontends.mpd.MpdFrontend',
u'mopidy.frontends.lastfm.LastfmFrontend', u'mopidy.frontends.lastfm.LastfmFrontend',
u'mopidy.frontends.mpris.MprisFrontend',
) )
#: Your `Last.fm <http://www.last.fm/>`_ username. #: Your `Last.fm <http://www.last.fm/>`_ username.
@ -78,8 +89,9 @@ LASTFM_PASSWORD = u''
#: #:
#: Default:: #: Default::
#: #:
#: LOCAL_MUSIC_PATH = u'~/music' #: # Defaults to asking glib where music is stored, fallback is ~/music
LOCAL_MUSIC_PATH = u'~/music' #: LOCAL_MUSIC_PATH = None
LOCAL_MUSIC_PATH = None
#: Path to playlist folder with m3u files for local music. #: Path to playlist folder with m3u files for local music.
#: #:
@ -87,8 +99,8 @@ LOCAL_MUSIC_PATH = u'~/music'
#: #:
#: Default:: #: Default::
#: #:
#: LOCAL_PLAYLIST_PATH = u'~/.mopidy/playlists' #: LOCAL_PLAYLIST_PATH = None # Implies $XDG_DATA_DIR/mopidy/playlists
LOCAL_PLAYLIST_PATH = u'~/.mopidy/playlists' LOCAL_PLAYLIST_PATH = None
#: Path to tag cache for local music. #: Path to tag cache for local music.
#: #:
@ -96,8 +108,8 @@ LOCAL_PLAYLIST_PATH = u'~/.mopidy/playlists'
#: #:
#: Default:: #: Default::
#: #:
#: LOCAL_TAG_CACHE_FILE = u'~/.mopidy/tag_cache' #: LOCAL_TAG_CACHE_FILE = None # Implies $XDG_DATA_DIR/mopidy/tag_cache
LOCAL_TAG_CACHE_FILE = u'~/.mopidy/tag_cache' LOCAL_TAG_CACHE_FILE = None
#: Sound mixer to use. See :mod:`mopidy.mixers` for all available mixers. #: 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. #: Default: :class:`None`, which means no password required.
MPD_SERVER_PASSWORD = None 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 #: List of outputs to use. See :mod:`mopidy.outputs` for all available
#: backends #: backends
#: #:
@ -233,7 +250,7 @@ SHOUTCAST_OUTPUT_ENCODER = u'lame mode=stereo bitrate=320'
#: Path to the Spotify cache. #: Path to the Spotify cache.
#: #:
#: Used by :mod:`mopidy.backends.spotify`. #: Used by :mod:`mopidy.backends.spotify`.
SPOTIFY_CACHE_PATH = u'~/.mopidy/spotify_cache' SPOTIFY_CACHE_PATH = None
#: Your Spotify Premium username. #: Your Spotify Premium username.
#: #:

View File

@ -1,3 +1,4 @@
import locale
import logging import logging
import os import os
import sys import sys
@ -29,3 +30,9 @@ def get_class(name):
except (ImportError, AttributeError): except (ImportError, AttributeError):
raise ImportError("Couldn't load: %s" % name) raise ImportError("Couldn't load: %s" % name)
return class_object return class_object
def locale_decode(bytestr):
try:
return unicode(bytestr)
except UnicodeError:
return str(bytestr).decode(locale.getpreferredencoding())

View File

@ -20,7 +20,7 @@ def setup_console_logging(verbosity_level):
if verbosity_level == 0: if verbosity_level == 0:
log_level = logging.WARNING log_level = logging.WARNING
log_format = settings.CONSOLE_LOG_FORMAT log_format = settings.CONSOLE_LOG_FORMAT
elif verbosity_level == 2: elif verbosity_level >= 2:
log_level = logging.DEBUG log_level = logging.DEBUG
log_format = settings.DEBUG_LOG_FORMAT log_format = settings.DEBUG_LOG_FORMAT
else: else:
@ -33,6 +33,9 @@ def setup_console_logging(verbosity_level):
root = logging.getLogger('') root = logging.getLogger('')
root.addHandler(handler) root.addHandler(handler)
if verbosity_level < 3:
logging.getLogger('pykka').setLevel(logging.INFO)
def setup_debug_logging_to_file(): def setup_debug_logging_to_file():
formatter = logging.Formatter(settings.DEBUG_LOG_FORMAT) formatter = logging.Formatter(settings.DEBUG_LOG_FORMAT)
handler = logging.handlers.RotatingFileHandler( handler = logging.handlers.RotatingFileHandler(

View File

@ -1,23 +1,35 @@
import errno
import gobject
import logging import logging
import re import re
import socket 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') 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""" """Determine if system really supports IPv6"""
if not socket.has_ipv6: if not socket.has_ipv6:
return False return False
try: try:
socket.socket(socket.AF_INET6).close() socket.socket(socket.AF_INET6).close()
return True return True
except IOError, e: except IOError as error:
logger.debug(u'Platform supports IPv6, but socket ' logger.debug(u'Platform supports IPv6, but socket '
'creation failed, disabling: %s', e) 'creation failed, disabling: %s', locale_decode(error))
return False return False
#: Boolean value that indicates if creating an IPv6 socket will succeed. #: Boolean value that indicates if creating an IPv6 socket will succeed.
has_ipv6 = _try_ipv6_socket() has_ipv6 = try_ipv6_socket()
def create_socket(): def create_socket():
"""Create a TCP socket with or without IPv6 depending on system support""" """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) sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
else: else:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
return sock return sock
def format_hostname(hostname): 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): if (has_ipv6 and re.match('\d+.\d+.\d+.\d+', hostname) is not None):
hostname = '::ffff:%s' % hostname hostname = '::ffff:%s' % hostname
return 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))

View File

@ -8,9 +8,12 @@ logger = logging.getLogger('mopidy.utils.path')
def get_or_create_folder(folder): def get_or_create_folder(folder):
folder = os.path.expanduser(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) logger.info(u'Creating dir %s', folder)
os.mkdir(folder, 0755) os.makedirs(folder, 0755)
return folder return folder
def get_or_create_file(filename): def get_or_create_file(filename):
@ -60,6 +63,7 @@ def find_files(path):
yield filename yield filename
# pylint: enable = W0612 # pylint: enable = W0612
# FIXME replace with mock usage in tests.
class Mtime(object): class Mtime(object):
def __init__(self): def __init__(self):
self.fake = None self.fake = None

View File

@ -3,9 +3,6 @@ import signal
import thread import thread
import threading import threading
import gobject
gobject.threads_init()
from pykka import ActorDeadError from pykka import ActorDeadError
from pykka.registry import ActorRegistry from pykka.registry import ActorRegistry
@ -68,25 +65,3 @@ class BaseThread(threading.Thread):
def run_inside_try(self): def run_inside_try(self):
raise NotImplementedError 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()

View File

@ -7,7 +7,7 @@ import os
from pprint import pformat from pprint import pformat
import sys import sys
from mopidy import SettingsError from mopidy import SettingsError, SETTINGS_PATH, SETTINGS_FILE
from mopidy.utils.log import indent from mopidy.utils.log import indent
logger = logging.getLogger('mopidy.utils.settings') logger = logging.getLogger('mopidy.utils.settings')
@ -20,11 +20,9 @@ class SettingsProxy(object):
self.runtime = {} self.runtime = {}
def _get_local_settings(self): def _get_local_settings(self):
dotdir = os.path.expanduser(u'~/.mopidy/') if not os.path.isfile(SETTINGS_FILE):
settings_file = os.path.join(dotdir, u'settings.py')
if not os.path.isfile(settings_file):
return {} return {}
sys.path.insert(0, dotdir) sys.path.insert(0, SETTINGS_PATH)
# pylint: disable = F0401 # pylint: disable = F0401
import settings as local_settings_module import settings as local_settings_module
# pylint: enable = F0401 # pylint: enable = F0401

View File

@ -18,6 +18,7 @@
# R0921 - Abstract class not referenced # R0921 - Abstract class not referenced
# W0141 - Used builtin function '%s' # W0141 - Used builtin function '%s'
# W0142 - Used * or ** magic # W0142 - Used * or ** magic
# W0511 - TODO, FIXME and XXX in the code
# W0613 - Unused argument %r # 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

View File

@ -1,4 +1,6 @@
coverage coverage
mock mock >= 0.7
nose nose
tox tox
unittest2
yappi

View File

@ -6,9 +6,13 @@ from distutils.core import setup
from distutils.command.install_data import install_data from distutils.command.install_data import install_data
from distutils.command.install import INSTALL_SCHEMES from distutils.command.install import INSTALL_SCHEMES
import os import os
import re
import sys 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): class osx_install_data(install_data):
# On MacOS, the platform-specific lib dir is # On MacOS, the platform-specific lib dir is

View File

@ -1,24 +1,41 @@
import os import os
import sys
try: # 2.7 if sys.version_info < (2, 7):
# pylint: disable = E0611,F0401 import unittest2 as unittest
from unittest.case import SkipTest else:
# pylint: enable = E0611,F0401 import unittest
except ImportError:
try: # Nose
from nose.plugins.skip import SkipTest
except ImportError: # Failsafe
class SkipTest(Exception):
pass
from mopidy import settings from mopidy import settings
# Nuke any local settings to ensure same test env all over # Nuke any local settings to ensure same test env all over
settings.local.clear() settings.local.clear()
def path_to_data_dir(name): def path_to_data_dir(name):
path = os.path.dirname(__file__) path = os.path.dirname(__file__)
path = os.path.join(path, 'data') path = os.path.join(path, 'data')
path = os.path.abspath(path) path = os.path.abspath(path)
return os.path.join(path, name) 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)

View File

@ -1,4 +1,8 @@
import nose import nose
import yappi
if __name__ == '__main__': try:
yappi.start()
nose.main() nose.main()
finally:
yappi.print_stats()

View File

@ -1,12 +1,12 @@
import mock import mock
import multiprocessing
import random import random
from mopidy.models import Playlist, Track from mopidy.models import CpTrack, Playlist, Track
from mopidy.gstreamer import GStreamer from mopidy.gstreamer import GStreamer
from tests.backends.base import populate_playlist from tests.backends.base import populate_playlist
class CurrentPlaylistControllerTest(object): class CurrentPlaylistControllerTest(object):
tracks = [] tracks = []
@ -18,6 +18,13 @@ class CurrentPlaylistControllerTest(object):
assert len(self.tracks) == 3, 'Need three tracks to run tests.' 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): def test_add(self):
for track in self.tracks: for track in self.tracks:
cp_track = self.controller.add(track) 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.state, self.playback.STOPPED)
self.assertEqual(self.playback.current_track, None) 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 @populate_playlist
def test_move_single(self): def test_move_single(self):
self.controller.move(0, 0, 2) self.controller.move(0, 0, 2)
@ -241,6 +260,18 @@ class CurrentPlaylistControllerTest(object):
self.assertEqual(self.tracks[0], shuffled_tracks[0]) self.assertEqual(self.tracks[0], shuffled_tracks[0])
self.assertEqual(set(self.tracks), set(shuffled_tracks)) 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): def test_version_does_not_change_when_appending_nothing(self):
version = self.controller.version version = self.controller.version
self.controller.append([]) self.controller.append([])

View File

@ -1,6 +1,7 @@
from mopidy.models import Playlist, Track, Album, Artist 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): class LibraryControllerTest(object):
artists = [Artist(name='artist1'), Artist(name='artist2'), Artist()] artists = [Artist(name='artist1'), Artist(name='artist2'), Artist()]
@ -20,11 +21,13 @@ class LibraryControllerTest(object):
def test_refresh(self): def test_refresh(self):
self.library.refresh() self.library.refresh()
@unittest.SkipTest
def test_refresh_uri(self): def test_refresh_uri(self):
raise SkipTest pass
@unittest.SkipTest
def test_refresh_missing_uri(self): def test_refresh_missing_uri(self):
raise SkipTest pass
def test_lookup(self): def test_lookup(self):
track = self.library.lookup(self.tracks[0].uri) track = self.library.lookup(self.tracks[0].uri)

View File

@ -1,16 +1,16 @@
import mock import mock
import multiprocessing
import random import random
import time import time
from mopidy.models import Track from mopidy.models import Track
from mopidy.gstreamer import GStreamer from mopidy.gstreamer import GStreamer
from tests import SkipTest from tests import unittest
from tests.backends.base import populate_playlist from tests.backends.base import populate_playlist
# TODO Test 'playlist repeat', e.g. repeat=1,single=0 # TODO Test 'playlist repeat', e.g. repeat=1,single=0
class PlaybackControllerTest(object): class PlaybackControllerTest(object):
tracks = [] tracks = []
@ -520,7 +520,7 @@ class PlaybackControllerTest(object):
self.assert_(wrapper.called) self.assert_(wrapper.called)
@SkipTest # Blocks for 10ms @unittest.SkipTest # Blocks for 10ms
@populate_playlist @populate_playlist
def test_end_of_track_callback_gets_called(self): def test_end_of_track_callback_gets_called(self):
self.playback.play() self.playback.play()
@ -555,7 +555,7 @@ class PlaybackControllerTest(object):
@populate_playlist @populate_playlist
def test_pause_when_stopped(self): def test_pause_when_stopped(self):
self.playback.pause() self.playback.pause()
self.assertEqual(self.playback.state, self.playback.STOPPED) self.assertEqual(self.playback.state, self.playback.PAUSED)
@populate_playlist @populate_playlist
def test_pause_when_playing(self): def test_pause_when_playing(self):
@ -599,7 +599,7 @@ class PlaybackControllerTest(object):
self.playback.pause() self.playback.pause()
self.assertEqual(self.playback.resume(), None) 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 @populate_playlist
def test_resume_continues_from_right_position(self): def test_resume_continues_from_right_position(self):
self.playback.play() self.playback.play()
@ -668,7 +668,7 @@ class PlaybackControllerTest(object):
self.playback.seek(0) self.playback.seek(0)
self.assertEqual(self.playback.state, self.playback.PLAYING) self.assertEqual(self.playback.state, self.playback.PLAYING)
@SkipTest @unittest.SkipTest
@populate_playlist @populate_playlist
def test_seek_beyond_end_of_song(self): def test_seek_beyond_end_of_song(self):
# FIXME need to decide return value # FIXME need to decide return value
@ -688,7 +688,7 @@ class PlaybackControllerTest(object):
self.playback.seek(self.current_playlist.tracks[-1].length * 100) self.playback.seek(self.current_playlist.tracks[-1].length * 100)
self.assertEqual(self.playback.state, self.playback.STOPPED) self.assertEqual(self.playback.state, self.playback.STOPPED)
@SkipTest @unittest.SkipTest
@populate_playlist @populate_playlist
def test_seek_beyond_start_of_song(self): def test_seek_beyond_start_of_song(self):
# FIXME need to decide return value # FIXME need to decide return value
@ -741,7 +741,7 @@ class PlaybackControllerTest(object):
self.assertEqual(self.playback.time_position, 0) 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 @populate_playlist
def test_time_position_when_playing(self): def test_time_position_when_playing(self):
self.playback.play() self.playback.play()
@ -750,7 +750,7 @@ class PlaybackControllerTest(object):
second = self.playback.time_position second = self.playback.time_position
self.assert_(second > first, '%s - %s' % (first, second)) self.assert_(second > first, '%s - %s' % (first, second))
@SkipTest # Uses sleep @unittest.SkipTest # Uses sleep
@populate_playlist @populate_playlist
def test_time_position_when_paused(self): def test_time_position_when_paused(self):
self.playback.play() self.playback.play()

View File

@ -5,7 +5,8 @@ import tempfile
from mopidy import settings from mopidy import settings
from mopidy.models import Playlist from mopidy.models import Playlist
from tests import SkipTest, path_to_data_dir from tests import unittest, path_to_data_dir
class StoredPlaylistsControllerTest(object): class StoredPlaylistsControllerTest(object):
def setUp(self): def setUp(self):
@ -78,11 +79,13 @@ class StoredPlaylistsControllerTest(object):
except LookupError as e: except LookupError as e:
self.assertEqual(u'"name=c" match no playlists', e[0]) self.assertEqual(u'"name=c" match no playlists', e[0])
@unittest.SkipTest
def test_lookup(self): def test_lookup(self):
raise SkipTest pass
@unittest.SkipTest
def test_refresh(self): def test_refresh(self):
raise SkipTest pass
def test_rename(self): def test_rename(self):
playlist = self.stored.create('test') playlist = self.stored.create('test')
@ -100,5 +103,6 @@ class StoredPlaylistsControllerTest(object):
self.stored.save(playlist) self.stored.save(playlist)
self.assert_(playlist in self.stored.playlists) self.assert_(playlist in self.stored.playlists)
@unittest.SkipTest
def test_playlist_with_unknown_track(self): def test_playlist_with_unknown_track(self):
raise SkipTest pass

View File

@ -1,45 +1,53 @@
import threading import mock
import unittest
from pykka.actor import ThreadingActor
from pykka.registry import ActorRegistry from pykka.registry import ActorRegistry
from mopidy.backends.dummy import DummyBackend from mopidy.backends.dummy import DummyBackend
from mopidy.listeners import BackendListener from mopidy.listeners import BackendListener
from mopidy.models import Track from mopidy.models import Track
from tests import unittest
@mock.patch.object(BackendListener, 'send')
class BackendEventsTest(unittest.TestCase): class BackendEventsTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.events = {
'started_playing': threading.Event(),
'stopped_playing': threading.Event(),
}
self.backend = DummyBackend.start().proxy() self.backend = DummyBackend.start().proxy()
self.listener = DummyBackendListener.start(self.events).proxy()
def tearDown(self): def tearDown(self):
ActorRegistry.stop_all() ActorRegistry.stop_all()
def test_play_sends_started_playing_event(self): def test_pause_sends_track_playback_paused_event(self, send):
self.backend.current_playlist.add([Track(uri='a')]) 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.backend.playback.play()
self.events['started_playing'].wait(timeout=1) self.backend.playback.pause().get()
self.assertTrue(self.events['started_playing'].is_set()) 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): def test_play_sends_track_playback_started_event(self, send):
self.backend.current_playlist.add([Track(uri='a')]) self.backend.current_playlist.add(Track(uri='a'))
self.backend.playback.play() send.reset_mock()
self.backend.playback.stop() self.backend.playback.play().get()
self.events['stopped_playing'].wait(timeout=1) self.assertEqual(send.call_args[0][0], 'track_playback_started')
self.assertTrue(self.events['stopped_playing'].is_set())
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 test_seek_sends_seeked_event(self, send):
def __init__(self, events): self.backend.current_playlist.add(Track(uri='a', length=40000))
self.events = events self.backend.playback.play().get()
send.reset_mock()
def started_playing(self, track): self.backend.playback.seek(1000).get()
self.events['started_playing'].set() self.assertEqual(send.call_args[0][0], 'seeked')
def stopped_playing(self, track, time_position):
self.events['stopped_playing'].set()

View File

@ -1,18 +1,16 @@
import unittest
# FIXME Our Windows build server does not support GStreamer yet
import sys import sys
if sys.platform == 'win32':
from tests import SkipTest
raise SkipTest
from mopidy import settings from mopidy import settings
from mopidy.backends.local import LocalBackend from mopidy.backends.local import LocalBackend
from mopidy.models import Track from mopidy.models import Track
from tests import unittest
from tests.backends.base.current_playlist import CurrentPlaylistControllerTest from tests.backends.base.current_playlist import CurrentPlaylistControllerTest
from tests.backends.local import generate_song from tests.backends.local import generate_song
@unittest.skipIf(sys.platform == 'win32',
'Our Windows build server does not support GStreamer yet')
class LocalCurrentPlaylistControllerTest(CurrentPlaylistControllerTest, class LocalCurrentPlaylistControllerTest(CurrentPlaylistControllerTest,
unittest.TestCase): unittest.TestCase):

View File

@ -1,17 +1,14 @@
import unittest
# FIXME Our Windows build server does not support GStreamer yet
import sys import sys
if sys.platform == 'win32':
from tests import SkipTest
raise SkipTest
from mopidy import settings from mopidy import settings
from mopidy.backends.local import LocalBackend 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 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): class LocalLibraryControllerTest(LibraryControllerTest, unittest.TestCase):
backend_class = LocalBackend backend_class = LocalBackend

View File

@ -1,20 +1,17 @@
import unittest
# FIXME Our Windows build server does not support GStreamer yet
import sys import sys
if sys.platform == 'win32':
from tests import SkipTest
raise SkipTest
from mopidy import settings from mopidy import settings
from mopidy.backends.local import LocalBackend from mopidy.backends.local import LocalBackend
from mopidy.models import Track from mopidy.models import Track
from mopidy.utils.path import path_to_uri 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.base.playback import PlaybackControllerTest
from tests.backends.local import generate_song 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): class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase):
backend_class = LocalBackend backend_class = LocalBackend
tracks = [Track(uri=generate_song(i), length=4464) tracks = [Track(uri=generate_song(i), length=4464)

View File

@ -1,24 +1,19 @@
import unittest
import os import os
from tests import SkipTest
# FIXME Our Windows build server does not support GStreamer yet
import sys import sys
if sys.platform == 'win32':
raise SkipTest
from mopidy import settings from mopidy import settings
from mopidy.backends.local import LocalBackend from mopidy.backends.local import LocalBackend
from mopidy.mixers.dummy import DummyMixer
from mopidy.models import Playlist, Track from mopidy.models import Playlist, Track
from mopidy.utils.path import path_to_uri 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.stored_playlists import \ from tests.backends.base.stored_playlists import (
StoredPlaylistsControllerTest StoredPlaylistsControllerTest)
from tests.backends.local import generate_song from tests.backends.local import generate_song
@unittest.skipIf(sys.platform == 'win32',
'Our Windows build server does not support GStreamer yet')
class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest, class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest,
unittest.TestCase): unittest.TestCase):
@ -77,14 +72,18 @@ class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest,
self.assertEqual('test', self.stored.playlists[0].name) self.assertEqual('test', self.stored.playlists[0].name)
self.assertEqual(track.uri, self.stored.playlists[0].tracks[0].uri) self.assertEqual(track.uri, self.stored.playlists[0].tracks[0].uri)
@unittest.SkipTest
def test_santitising_of_playlist_filenames(self): def test_santitising_of_playlist_filenames(self):
raise SkipTest pass
@unittest.SkipTest
def test_playlist_folder_is_createad(self): def test_playlist_folder_is_createad(self):
raise SkipTest pass
@unittest.SkipTest
def test_create_sets_playlist_uri(self): def test_create_sets_playlist_uri(self):
raise SkipTest pass
@unittest.SkipTest
def test_save_sets_playlist_uri(self): def test_save_sets_playlist_uri(self):
raise SkipTest pass

View File

@ -2,13 +2,12 @@
import os import os
import tempfile import tempfile
import unittest
from mopidy.utils.path import path_to_uri from mopidy.utils.path import path_to_uri
from mopidy.backends.local.translator import parse_m3u, parse_mpd_tag_cache from mopidy.backends.local.translator import parse_m3u, parse_mpd_tag_cache
from mopidy.models import Track, Artist, Album 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') song1_path = path_to_data_dir('song1.mp3')
song2_path = path_to_data_dir('song2.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) song2_uri = path_to_uri(song2_path)
encoded_uri = path_to_uri(encoded_path) encoded_uri = path_to_uri(encoded_path)
# FIXME use mock instead of tempfile.NamedTemporaryFile
class M3UToUriTest(unittest.TestCase): class M3UToUriTest(unittest.TestCase):
def test_empty_file(self): def test_empty_file(self):
uris = parse_m3u(path_to_data_dir('empty.m3u')) uris = parse_m3u(path_to_data_dir('empty.m3u'))
@ -127,9 +129,10 @@ class MPDTagCacheToTracksTest(unittest.TestCase):
self.assertEqual(track, list(tracks)[0]) self.assertEqual(track, list(tracks)[0])
@unittest.SkipTest
def test_misencoded_cache(self): def test_misencoded_cache(self):
# FIXME not sure if this can happen # FIXME not sure if this can happen
raise SkipTest pass
def test_cache_with_blank_track_info(self): def test_cache_with_blank_track_info(self):
tracks = parse_mpd_tag_cache(path_to_data_dir('blank_tag_cache'), tracks = parse_mpd_tag_cache(path_to_data_dir('blank_tag_cache'),

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -1,11 +1,12 @@
import unittest
from mopidy.backends.dummy import DummyBackend from mopidy.backends.dummy import DummyBackend
from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.frontends.mpd.dispatcher import MpdDispatcher
from mopidy.frontends.mpd.exceptions import MpdAckError from mopidy.frontends.mpd.exceptions import MpdAckError
from mopidy.frontends.mpd.protocol import request_handlers, handle_request from mopidy.frontends.mpd.protocol import request_handlers, handle_request
from mopidy.mixers.dummy import DummyMixer from mopidy.mixers.dummy import DummyMixer
from tests import unittest
class MpdDispatcherTest(unittest.TestCase): class MpdDispatcherTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.backend = DummyBackend.start().proxy() self.backend = DummyBackend.start().proxy()

View File

@ -1,8 +1,9 @@
import unittest
from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdPermissionError, from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdPermissionError,
MpdUnknownCommand, MpdSystemError, MpdNotImplemented) MpdUnknownCommand, MpdSystemError, MpdNotImplemented)
from tests import unittest
class MpdExceptionsTest(unittest.TestCase): class MpdExceptionsTest(unittest.TestCase):
def test_key_error_wrapped_in_mpd_ack_error(self): def test_key_error_wrapped_in_mpd_ack_error(self):
try: try:

View File

@ -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')

View File

@ -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])

View File

@ -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')

View File

@ -1,63 +1,62 @@
import mock
import unittest
from mopidy import settings from mopidy import settings
from mopidy.frontends.mpd.dispatcher import MpdDispatcher
from mopidy.frontends.mpd.session import MpdSession
class AuthenticationTest(unittest.TestCase): from tests.frontends.mpd import protocol
def setUp(self):
self.session = mock.Mock(spec=MpdSession)
self.dispatcher = MpdDispatcher(session=self.session)
def tearDown(self):
settings.runtime.clear()
class AuthenticationTest(protocol.BaseTestCase):
def test_authentication_with_valid_password_is_accepted(self): def test_authentication_with_valid_password_is_accepted(self):
settings.MPD_SERVER_PASSWORD = u'topsecret' 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.assertTrue(self.dispatcher.authenticated)
self.assert_(u'OK' in response) self.assertInResponse(u'OK')
def test_authentication_with_invalid_password_is_not_accepted(self): def test_authentication_with_invalid_password_is_not_accepted(self):
settings.MPD_SERVER_PASSWORD = u'topsecret' 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.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): def test_authentication_with_anything_when_password_check_turned_off(self):
settings.MPD_SERVER_PASSWORD = None 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.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): def test_anything_when_not_authenticated_should_fail(self):
settings.MPD_SERVER_PASSWORD = u'topsecret' 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.assertFalse(self.dispatcher.authenticated)
self.assert_( self.assertEqualResponse(
u'ACK [4@0] {any} you don\'t have permission for "any"' in response) u'ACK [4@0] {any} you don\'t have permission for "any"')
def test_close_is_allowed_without_authentication(self): def test_close_is_allowed_without_authentication(self):
settings.MPD_SERVER_PASSWORD = u'topsecret' settings.MPD_SERVER_PASSWORD = u'topsecret'
response = self.dispatcher.handle_request(u'close')
self.sendRequest(u'close')
self.assertFalse(self.dispatcher.authenticated) self.assertFalse(self.dispatcher.authenticated)
self.assert_(u'OK' in response) self.assertInResponse(u'OK')
def test_commands_is_allowed_without_authentication(self): def test_commands_is_allowed_without_authentication(self):
settings.MPD_SERVER_PASSWORD = u'topsecret' settings.MPD_SERVER_PASSWORD = u'topsecret'
response = self.dispatcher.handle_request(u'commands')
self.sendRequest(u'commands')
self.assertFalse(self.dispatcher.authenticated) self.assertFalse(self.dispatcher.authenticated)
self.assert_(u'OK' in response) self.assertInResponse(u'OK')
def test_notcommands_is_allowed_without_authentication(self): def test_notcommands_is_allowed_without_authentication(self):
settings.MPD_SERVER_PASSWORD = u'topsecret' settings.MPD_SERVER_PASSWORD = u'topsecret'
response = self.dispatcher.handle_request(u'notcommands')
self.sendRequest(u'notcommands')
self.assertFalse(self.dispatcher.authenticated) self.assertFalse(self.dispatcher.authenticated)
self.assert_(u'OK' in response) self.assertInResponse(u'OK')
def test_ping_is_allowed_without_authentication(self): def test_ping_is_allowed_without_authentication(self):
settings.MPD_SERVER_PASSWORD = u'topsecret' settings.MPD_SERVER_PASSWORD = u'topsecret'
response = self.dispatcher.handle_request(u'ping')
self.sendRequest(u'ping')
self.assertFalse(self.dispatcher.authenticated) self.assertFalse(self.dispatcher.authenticated)
self.assert_(u'OK' in response) self.assertInResponse(u'OK')

View File

@ -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.

View File

@ -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')

View File

@ -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 from mopidy.models import Track
class CurrentPlaylistHandlerTest(unittest.TestCase): from tests.frontends.mpd import protocol
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 CurrentPlaylistHandlerTest(protocol.BaseTestCase):
def test_add(self): def test_add(self):
needle = Track(uri='dummy://foo') needle = Track(uri='dummy://foo')
self.backend.library.provider.dummy_library = [ self.backend.library.provider.dummy_library = [
@ -22,21 +11,21 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
self.backend.current_playlist.append( self.backend.current_playlist.append(
[Track(), Track(), Track(), Track(), Track()]) [Track(), Track(), Track(), Track(), Track()])
self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) 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.sendRequest(u'add "dummy://foo"')
self.assertEqual(result[0], u'OK')
self.assertEqual(len(self.backend.current_playlist.tracks.get()), 6) self.assertEqual(len(self.backend.current_playlist.tracks.get()), 6)
self.assertEqual(self.backend.current_playlist.tracks.get()[5], needle) 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): def test_add_with_uri_not_found_in_library_should_ack(self):
result = self.dispatcher.handle_request(u'add "dummy://foo"') self.sendRequest(u'add "dummy://foo"')
self.assertEqual(result[0], self.assertEqualResponse(
u'ACK [50@0] {add} directory or file not found') u'ACK [50@0] {add} directory or file not found')
def test_add_with_empty_uri_should_add_all_known_tracks_and_ok(self): 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) # 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): def test_addid_without_songpos(self):
needle = Track(uri='dummy://foo') needle = Track(uri='dummy://foo')
@ -45,16 +34,17 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
self.backend.current_playlist.append( self.backend.current_playlist.append(
[Track(), Track(), Track(), Track(), Track()]) [Track(), Track(), Track(), Track(), Track()])
self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) 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(len(self.backend.current_playlist.tracks.get()), 6)
self.assertEqual(self.backend.current_playlist.tracks.get()[5], needle) self.assertEqual(self.backend.current_playlist.tracks.get()[5], needle)
self.assert_(u'Id: %d' % self.assertInResponse(u'Id: %d' %
self.backend.current_playlist.cp_tracks.get()[5][0] in result) self.backend.current_playlist.cp_tracks.get()[5][0])
self.assert_(u'OK' in result) self.assertInResponse(u'OK')
def test_addid_with_empty_uri_acks(self): def test_addid_with_empty_uri_acks(self):
result = self.dispatcher.handle_request(u'addid ""') self.sendRequest(u'addid ""')
self.assertEqual(result[0], u'ACK [50@0] {addid} No such song') self.assertEqualResponse(u'ACK [50@0] {addid} No such song')
def test_addid_with_songpos(self): def test_addid_with_songpos(self):
needle = Track(uri='dummy://foo') needle = Track(uri='dummy://foo')
@ -63,12 +53,13 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
self.backend.current_playlist.append( self.backend.current_playlist.append(
[Track(), Track(), Track(), Track(), Track()]) [Track(), Track(), Track(), Track(), Track()])
self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) 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(len(self.backend.current_playlist.tracks.get()), 6)
self.assertEqual(self.backend.current_playlist.tracks.get()[3], needle) self.assertEqual(self.backend.current_playlist.tracks.get()[3], needle)
self.assert_(u'Id: %d' % self.assertInResponse(u'Id: %d' %
self.backend.current_playlist.cp_tracks.get()[3][0] in result) self.backend.current_playlist.cp_tracks.get()[3][0])
self.assert_(u'OK' in result) self.assertInResponse(u'OK')
def test_addid_with_songpos_out_of_bounds_should_ack(self): def test_addid_with_songpos_out_of_bounds_should_ack(self):
needle = Track(uri='dummy://foo') needle = Track(uri='dummy://foo')
@ -77,83 +68,93 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
self.backend.current_playlist.append( self.backend.current_playlist.append(
[Track(), Track(), Track(), Track(), Track()]) [Track(), Track(), Track(), Track(), Track()])
self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) 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): def test_addid_with_uri_not_found_in_library_should_ack(self):
result = self.dispatcher.handle_request(u'addid "dummy://foo"') self.sendRequest(u'addid "dummy://foo"')
self.assertEqual(result[0], u'ACK [50@0] {addid} No such song') self.assertEqualResponse(u'ACK [50@0] {addid} No such song')
def test_clear(self): def test_clear(self):
self.backend.current_playlist.append( self.backend.current_playlist.append(
[Track(), Track(), Track(), Track(), Track()]) [Track(), Track(), Track(), Track(), Track()])
self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) 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(len(self.backend.current_playlist.tracks.get()), 0)
self.assertEqual(self.backend.playback.current_track.get(), None) self.assertEqual(self.backend.playback.current_track.get(), None)
self.assert_(u'OK' in result) self.assertInResponse(u'OK')
def test_delete_songpos(self): def test_delete_songpos(self):
self.backend.current_playlist.append( self.backend.current_playlist.append(
[Track(), Track(), Track(), Track(), Track()]) [Track(), Track(), Track(), Track(), Track()])
self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) 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.backend.current_playlist.cp_tracks.get()[2][0])
self.assertEqual(len(self.backend.current_playlist.tracks.get()), 4) 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): def test_delete_songpos_out_of_bounds(self):
self.backend.current_playlist.append( self.backend.current_playlist.append(
[Track(), Track(), Track(), Track(), Track()]) [Track(), Track(), Track(), Track(), Track()])
self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) 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(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): def test_delete_open_range(self):
self.backend.current_playlist.append( self.backend.current_playlist.append(
[Track(), Track(), Track(), Track(), Track()]) [Track(), Track(), Track(), Track(), Track()])
self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) 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.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): def test_delete_closed_range(self):
self.backend.current_playlist.append( self.backend.current_playlist.append(
[Track(), Track(), Track(), Track(), Track()]) [Track(), Track(), Track(), Track(), Track()])
self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) 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.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): def test_delete_range_out_of_bounds(self):
self.backend.current_playlist.append( self.backend.current_playlist.append(
[Track(), Track(), Track(), Track(), Track()]) [Track(), Track(), Track(), Track(), Track()])
self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) 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(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): def test_deleteid(self):
self.backend.current_playlist.append([Track(), Track()]) self.backend.current_playlist.append([Track(), Track()])
self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) 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.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): def test_deleteid_does_not_exist(self):
self.backend.current_playlist.append([Track(), Track()]) self.backend.current_playlist.append([Track(), Track()])
self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) 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(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): def test_move_songpos(self):
self.backend.current_playlist.append([ self.backend.current_playlist.append([
Track(name='a'), Track(name='b'), Track(name='c'), Track(name='a'), Track(name='b'), Track(name='c'),
Track(name='d'), Track(name='e'), Track(name='f'), 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() tracks = self.backend.current_playlist.tracks.get()
self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[0].name, 'b')
self.assertEqual(tracks[1].name, 'a') self.assertEqual(tracks[1].name, 'a')
@ -161,14 +162,15 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[3].name, 'd')
self.assertEqual(tracks[4].name, 'e') self.assertEqual(tracks[4].name, 'e')
self.assertEqual(tracks[5].name, 'f') self.assertEqual(tracks[5].name, 'f')
self.assert_(u'OK' in result) self.assertInResponse(u'OK')
def test_move_open_range(self): def test_move_open_range(self):
self.backend.current_playlist.append([ self.backend.current_playlist.append([
Track(name='a'), Track(name='b'), Track(name='c'), Track(name='a'), Track(name='b'), Track(name='c'),
Track(name='d'), Track(name='e'), Track(name='f'), 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() tracks = self.backend.current_playlist.tracks.get()
self.assertEqual(tracks[0].name, 'c') self.assertEqual(tracks[0].name, 'c')
self.assertEqual(tracks[1].name, 'd') self.assertEqual(tracks[1].name, 'd')
@ -176,14 +178,15 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
self.assertEqual(tracks[3].name, 'f') self.assertEqual(tracks[3].name, 'f')
self.assertEqual(tracks[4].name, 'a') self.assertEqual(tracks[4].name, 'a')
self.assertEqual(tracks[5].name, 'b') self.assertEqual(tracks[5].name, 'b')
self.assert_(u'OK' in result) self.assertInResponse(u'OK')
def test_move_closed_range(self): def test_move_closed_range(self):
self.backend.current_playlist.append([ self.backend.current_playlist.append([
Track(name='a'), Track(name='b'), Track(name='c'), Track(name='a'), Track(name='b'), Track(name='c'),
Track(name='d'), Track(name='e'), Track(name='f'), 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() tracks = self.backend.current_playlist.tracks.get()
self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[0].name, 'b')
self.assertEqual(tracks[1].name, 'c') self.assertEqual(tracks[1].name, 'c')
@ -191,14 +194,15 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[3].name, 'd')
self.assertEqual(tracks[4].name, 'e') self.assertEqual(tracks[4].name, 'e')
self.assertEqual(tracks[5].name, 'f') self.assertEqual(tracks[5].name, 'f')
self.assert_(u'OK' in result) self.assertInResponse(u'OK')
def test_moveid(self): def test_moveid(self):
self.backend.current_playlist.append([ self.backend.current_playlist.append([
Track(name='a'), Track(name='b'), Track(name='c'), Track(name='a'), Track(name='b'), Track(name='c'),
Track(name='d'), Track(name='e'), Track(name='f'), 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() tracks = self.backend.current_playlist.tracks.get()
self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[0].name, 'a')
self.assertEqual(tracks[1].name, 'b') self.assertEqual(tracks[1].name, 'b')
@ -206,179 +210,200 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
self.assertEqual(tracks[3].name, 'c') self.assertEqual(tracks[3].name, 'c')
self.assertEqual(tracks[4].name, 'd') self.assertEqual(tracks[4].name, 'd')
self.assertEqual(tracks[5].name, 'f') self.assertEqual(tracks[5].name, 'f')
self.assert_(u'OK' in result) self.assertInResponse(u'OK')
def test_playlist_returns_same_as_playlistinfo(self): def test_playlist_returns_same_as_playlistinfo(self):
playlist_result = self.dispatcher.handle_request(u'playlist') playlist_response = self.sendRequest(u'playlist')
playlistinfo_result = self.dispatcher.handle_request(u'playlistinfo') playlistinfo_response = self.sendRequest(u'playlistinfo')
self.assertEqual(playlist_result, playlistinfo_result) self.assertEqual(playlist_response, playlistinfo_response)
def test_playlistfind(self): def test_playlistfind(self):
result = self.dispatcher.handle_request(u'playlistfind "tag" "needle"') self.sendRequest(u'playlistfind "tag" "needle"')
self.assert_(u'ACK [0@0] {} Not implemented' in result) self.assertEqualResponse(u'ACK [0@0] {} Not implemented')
def test_playlistfind_by_filename_not_in_current_playlist(self): def test_playlistfind_by_filename_not_in_current_playlist(self):
result = self.dispatcher.handle_request( self.sendRequest(u'playlistfind "filename" "file:///dev/null"')
u'playlistfind "filename" "file:///dev/null"') self.assertEqualResponse(u'OK')
self.assertEqual(len(result), 1)
self.assert_(u'OK' in result)
def test_playlistfind_by_filename_without_quotes(self): def test_playlistfind_by_filename_without_quotes(self):
result = self.dispatcher.handle_request( self.sendRequest(u'playlistfind filename "file:///dev/null"')
u'playlistfind filename "file:///dev/null"') self.assertEqualResponse(u'OK')
self.assertEqual(len(result), 1)
self.assert_(u'OK' in result)
def test_playlistfind_by_filename_in_current_playlist(self): def test_playlistfind_by_filename_in_current_playlist(self):
self.backend.current_playlist.append([ self.backend.current_playlist.append([
Track(uri='file:///exists')]) Track(uri='file:///exists')])
result = self.dispatcher.handle_request(
u'playlistfind filename "file:///exists"') self.sendRequest( u'playlistfind filename "file:///exists"')
self.assert_(u'file: file:///exists' in result) self.assertInResponse(u'file: file:///exists')
self.assert_(u'Id: 0' in result) self.assertInResponse(u'Id: 0')
self.assert_(u'Pos: 0' in result) self.assertInResponse(u'Pos: 0')
self.assert_(u'OK' in result) self.assertInResponse(u'OK')
def test_playlistid_without_songid(self): def test_playlistid_without_songid(self):
self.backend.current_playlist.append([Track(name='a'), Track(name='b')]) 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.sendRequest(u'playlistid')
self.assert_(u'Title: b' in result) self.assertInResponse(u'Title: a')
self.assert_(u'OK' in result) self.assertInResponse(u'Title: b')
self.assertInResponse(u'OK')
def test_playlistid_with_songid(self): def test_playlistid_with_songid(self):
self.backend.current_playlist.append([Track(name='a'), Track(name='b')]) 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.sendRequest(u'playlistid "1"')
self.assert_(u'Id: 0' not in result) self.assertNotInResponse(u'Title: a')
self.assert_(u'Title: b' in result) self.assertNotInResponse(u'Id: 0')
self.assert_(u'Id: 1' in result) self.assertInResponse(u'Title: b')
self.assert_(u'OK' in result) self.assertInResponse(u'Id: 1')
self.assertInResponse(u'OK')
def test_playlistid_with_not_existing_songid_fails(self): def test_playlistid_with_not_existing_songid_fails(self):
self.backend.current_playlist.append([Track(name='a'), Track(name='b')]) 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): def test_playlistinfo_without_songpos_or_range(self):
self.backend.current_playlist.append([ self.backend.current_playlist.append([
Track(name='a'), Track(name='b'), Track(name='c'), Track(name='a'), Track(name='b'), Track(name='c'),
Track(name='d'), Track(name='e'), Track(name='f'), Track(name='d'), Track(name='e'), Track(name='f'),
]) ])
result = self.dispatcher.handle_request(u'playlistinfo')
self.assert_(u'Title: a' in result) self.sendRequest(u'playlistinfo')
self.assert_(u'Title: b' in result) self.assertInResponse(u'Title: a')
self.assert_(u'Title: c' in result) self.assertInResponse(u'Pos: 0')
self.assert_(u'Title: d' in result) self.assertInResponse(u'Title: b')
self.assert_(u'Title: e' in result) self.assertInResponse(u'Pos: 1')
self.assert_(u'Title: f' in result) self.assertInResponse(u'Title: c')
self.assert_(u'OK' in result) 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): def test_playlistinfo_with_songpos(self):
self.backend.current_playlist.append([ self.backend.current_playlist.append([
Track(name='a'), Track(name='b'), Track(name='c'), Track(name='a'), Track(name='b'), Track(name='c'),
Track(name='d'), Track(name='e'), Track(name='f'), 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.sendRequest(u'playlistinfo "4"')
self.assert_(u'Title: b' not in result) self.assertNotInResponse(u'Title: a')
self.assert_(u'Title: c' not in result) self.assertNotInResponse(u'Pos: 0')
self.assert_(u'Title: d' not in result) self.assertNotInResponse(u'Title: b')
self.assert_(u'Title: e' in result) self.assertNotInResponse(u'Pos: 1')
self.assert_(u'Title: f' not in result) self.assertNotInResponse(u'Title: c')
self.assert_(u'OK' in result) 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): def test_playlistinfo_with_negative_songpos_same_as_playlistinfo(self):
result1 = self.dispatcher.handle_request(u'playlistinfo "-1"') response1 = self.sendRequest(u'playlistinfo "-1"')
result2 = self.dispatcher.handle_request(u'playlistinfo') response2 = self.sendRequest(u'playlistinfo')
self.assertEqual(result1, result2) self.assertEqual(response1, response2)
def test_playlistinfo_with_open_range(self): def test_playlistinfo_with_open_range(self):
self.backend.current_playlist.append([ self.backend.current_playlist.append([
Track(name='a'), Track(name='b'), Track(name='c'), Track(name='a'), Track(name='b'), Track(name='c'),
Track(name='d'), Track(name='e'), Track(name='f'), 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.sendRequest(u'playlistinfo "2:"')
self.assert_(u'Title: b' not in result) self.assertNotInResponse(u'Title: a')
self.assert_(u'Title: c' in result) self.assertNotInResponse(u'Pos: 0')
self.assert_(u'Title: d' in result) self.assertNotInResponse(u'Title: b')
self.assert_(u'Title: e' in result) self.assertNotInResponse(u'Pos: 1')
self.assert_(u'Title: f' in result) self.assertInResponse(u'Title: c')
self.assert_(u'OK' in result) 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): def test_playlistinfo_with_closed_range(self):
self.backend.current_playlist.append([ self.backend.current_playlist.append([
Track(name='a'), Track(name='b'), Track(name='c'), Track(name='a'), Track(name='b'), Track(name='c'),
Track(name='d'), Track(name='e'), Track(name='f'), 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.sendRequest(u'playlistinfo "2:4"')
self.assert_(u'Title: b' not in result) self.assertNotInResponse(u'Title: a')
self.assert_(u'Title: c' in result) self.assertNotInResponse(u'Title: b')
self.assert_(u'Title: d' in result) self.assertInResponse(u'Title: c')
self.assert_(u'Title: e' not in result) self.assertInResponse(u'Title: d')
self.assert_(u'Title: f' not in result) self.assertNotInResponse(u'Title: e')
self.assert_(u'OK' in result) self.assertNotInResponse(u'Title: f')
self.assertInResponse(u'OK')
def test_playlistinfo_with_too_high_start_of_range_returns_arg_error(self): def test_playlistinfo_with_too_high_start_of_range_returns_arg_error(self):
result = self.dispatcher.handle_request(u'playlistinfo "10:20"') self.sendRequest(u'playlistinfo "10:20"')
self.assert_(u'ACK [2@0] {playlistinfo} Bad song index' in result) self.assertEqualResponse(u'ACK [2@0] {playlistinfo} Bad song index')
def test_playlistinfo_with_too_high_end_of_range_returns_ok(self): def test_playlistinfo_with_too_high_end_of_range_returns_ok(self):
result = self.dispatcher.handle_request(u'playlistinfo "0:20"') self.sendRequest(u'playlistinfo "0:20"')
self.assert_(u'OK' in result) self.assertInResponse(u'OK')
def test_playlistsearch(self): def test_playlistsearch(self):
result = self.dispatcher.handle_request( self.sendRequest( u'playlistsearch "any" "needle"')
u'playlistsearch "any" "needle"') self.assertEqualResponse(u'ACK [0@0] {} Not implemented')
self.assert_(u'ACK [0@0] {} Not implemented' in result)
def test_playlistsearch_without_quotes(self): def test_playlistsearch_without_quotes(self):
result = self.dispatcher.handle_request(u'playlistsearch any "needle"') self.sendRequest(u'playlistsearch any "needle"')
self.assert_(u'ACK [0@0] {} Not implemented' in result) self.assertEqualResponse(u'ACK [0@0] {} Not implemented')
def test_plchanges(self): def test_plchanges(self):
self.backend.current_playlist.append( self.backend.current_playlist.append(
[Track(name='a'), Track(name='b'), Track(name='c')]) [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.sendRequest(u'plchanges "0"')
self.assert_(u'Title: b' in result) self.assertInResponse(u'Title: a')
self.assert_(u'Title: c' in result) self.assertInResponse(u'Title: b')
self.assert_(u'OK' in result) self.assertInResponse(u'Title: c')
self.assertInResponse(u'OK')
def test_plchanges_with_minus_one_returns_entire_playlist(self): def test_plchanges_with_minus_one_returns_entire_playlist(self):
self.backend.current_playlist.append( self.backend.current_playlist.append(
[Track(name='a'), Track(name='b'), Track(name='c')]) [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.sendRequest(u'plchanges "-1"')
self.assert_(u'Title: b' in result) self.assertInResponse(u'Title: a')
self.assert_(u'Title: c' in result) self.assertInResponse(u'Title: b')
self.assert_(u'OK' in result) self.assertInResponse(u'Title: c')
self.assertInResponse(u'OK')
def test_plchanges_without_quotes_works(self): def test_plchanges_without_quotes_works(self):
self.backend.current_playlist.append( self.backend.current_playlist.append(
[Track(name='a'), Track(name='b'), Track(name='c')]) [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.sendRequest(u'plchanges 0')
self.assert_(u'Title: b' in result) self.assertInResponse(u'Title: a')
self.assert_(u'Title: c' in result) self.assertInResponse(u'Title: b')
self.assert_(u'OK' in result) self.assertInResponse(u'Title: c')
self.assertInResponse(u'OK')
def test_plchangesposid(self): def test_plchangesposid(self):
self.backend.current_playlist.append([Track(), Track(), Track()]) 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() cp_tracks = self.backend.current_playlist.cp_tracks.get()
self.assert_(u'cpos: 0' in result) self.assertInResponse(u'cpos: 0')
self.assert_(u'Id: %d' % cp_tracks[0][0] self.assertInResponse(u'Id: %d' % cp_tracks[0][0])
in result) self.assertInResponse(u'cpos: 2')
self.assert_(u'cpos: 2' in result) self.assertInResponse(u'Id: %d' % cp_tracks[1][0])
self.assert_(u'Id: %d' % cp_tracks[1][0] self.assertInResponse(u'cpos: 2')
in result) self.assertInResponse(u'Id: %d' % cp_tracks[2][0])
self.assert_(u'cpos: 2' in result) self.assertInResponse(u'OK')
self.assert_(u'Id: %d' % cp_tracks[2][0]
in result)
self.assert_(u'OK' in result)
def test_shuffle_without_range(self): def test_shuffle_without_range(self):
self.backend.current_playlist.append([ self.backend.current_playlist.append([
@ -386,9 +411,10 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
Track(name='d'), Track(name='e'), Track(name='f'), Track(name='d'), Track(name='e'), Track(name='f'),
]) ])
version = self.backend.current_playlist.version.get() 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_(version < self.backend.current_playlist.version.get())
self.assert_(u'OK' in result) self.assertInResponse(u'OK')
def test_shuffle_with_open_range(self): def test_shuffle_with_open_range(self):
self.backend.current_playlist.append([ self.backend.current_playlist.append([
@ -396,14 +422,15 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
Track(name='d'), Track(name='e'), Track(name='f'), Track(name='d'), Track(name='e'), Track(name='f'),
]) ])
version = self.backend.current_playlist.version.get() 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()) self.assert_(version < self.backend.current_playlist.version.get())
tracks = self.backend.current_playlist.tracks.get() tracks = self.backend.current_playlist.tracks.get()
self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[0].name, 'a')
self.assertEqual(tracks[1].name, 'b') self.assertEqual(tracks[1].name, 'b')
self.assertEqual(tracks[2].name, 'c') self.assertEqual(tracks[2].name, 'c')
self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[3].name, 'd')
self.assert_(u'OK' in result) self.assertInResponse(u'OK')
def test_shuffle_with_closed_range(self): def test_shuffle_with_closed_range(self):
self.backend.current_playlist.append([ self.backend.current_playlist.append([
@ -411,21 +438,23 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
Track(name='d'), Track(name='e'), Track(name='f'), Track(name='d'), Track(name='e'), Track(name='f'),
]) ])
version = self.backend.current_playlist.version.get() 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()) self.assert_(version < self.backend.current_playlist.version.get())
tracks = self.backend.current_playlist.tracks.get() tracks = self.backend.current_playlist.tracks.get()
self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[0].name, 'a')
self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[3].name, 'd')
self.assertEqual(tracks[4].name, 'e') self.assertEqual(tracks[4].name, 'e')
self.assertEqual(tracks[5].name, 'f') self.assertEqual(tracks[5].name, 'f')
self.assert_(u'OK' in result) self.assertInResponse(u'OK')
def test_swap(self): def test_swap(self):
self.backend.current_playlist.append([ self.backend.current_playlist.append([
Track(name='a'), Track(name='b'), Track(name='c'), Track(name='a'), Track(name='b'), Track(name='c'),
Track(name='d'), Track(name='e'), Track(name='f'), 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() tracks = self.backend.current_playlist.tracks.get()
self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[0].name, 'a')
self.assertEqual(tracks[1].name, 'e') self.assertEqual(tracks[1].name, 'e')
@ -433,14 +462,15 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[3].name, 'd')
self.assertEqual(tracks[4].name, 'b') self.assertEqual(tracks[4].name, 'b')
self.assertEqual(tracks[5].name, 'f') self.assertEqual(tracks[5].name, 'f')
self.assert_(u'OK' in result) self.assertInResponse(u'OK')
def test_swapid(self): def test_swapid(self):
self.backend.current_playlist.append([ self.backend.current_playlist.append([
Track(name='a'), Track(name='b'), Track(name='c'), Track(name='a'), Track(name='b'), Track(name='c'),
Track(name='d'), Track(name='e'), Track(name='f'), 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() tracks = self.backend.current_playlist.tracks.get()
self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[0].name, 'a')
self.assertEqual(tracks[1].name, 'e') self.assertEqual(tracks[1].name, 'e')
@ -448,4 +478,4 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[3].name, 'd')
self.assertEqual(tracks[4].name, 'b') self.assertEqual(tracks[4].name, 'b')
self.assertEqual(tracks[5].name, 'f') self.assertEqual(tracks[5].name, 'f')
self.assert_(u'OK' in result) self.assertInResponse(u'OK')

Some files were not shown because too many files have changed in this diff Show More