Release v0.2.0
This commit is contained in:
commit
fa8d24add0
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,4 +8,5 @@ cover/
|
|||||||
coverage.xml
|
coverage.xml
|
||||||
dist/
|
dist/
|
||||||
docs/_build/
|
docs/_build/
|
||||||
|
mopidy.log
|
||||||
nosetests.xml
|
nosetests.xml
|
||||||
|
|||||||
@ -11,8 +11,9 @@ platforms, including Windows, Mac OS X, Linux, and iPhone and Android phones.
|
|||||||
To install Mopidy, check out
|
To install Mopidy, check out
|
||||||
`the installation docs <http://www.mopidy.com/docs/master/installation/>`_.
|
`the installation docs <http://www.mopidy.com/docs/master/installation/>`_.
|
||||||
|
|
||||||
* `Documentation <http://www.mopidy.com/docs/master/>`_
|
* `Documentation (latest release) <http://www.mopidy.com/docs/master/>`_
|
||||||
* `Documentation (development version) <http://www.mopidy.com/docs/develop/>`_
|
* `Documentation (development version) <http://www.mopidy.com/docs/develop/>`_
|
||||||
* `Source code <http://github.com/jodal/mopidy>`_
|
* `Source code <http://github.com/jodal/mopidy>`_
|
||||||
* `Issue tracker <http://github.com/jodal/mopidy/issues>`_
|
* `Issue tracker <http://github.com/jodal/mopidy/issues>`_
|
||||||
* IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
|
* IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
|
||||||
|
* `Download development snapshot <http://github.com/jodal/mopidy/tarball/develop#egg=mopidy-dev>`_
|
||||||
|
|||||||
@ -8,11 +8,18 @@ A frontend is responsible for exposing Mopidy for a type of clients.
|
|||||||
Frontend API
|
Frontend API
|
||||||
============
|
============
|
||||||
|
|
||||||
A stable frontend API is not available yet, as we've only implemented a single
|
.. warning::
|
||||||
frontend module.
|
|
||||||
|
A stable frontend API is not available yet, as we've only implemented a
|
||||||
|
couple of frontend modules.
|
||||||
|
|
||||||
|
.. automodule:: mopidy.frontends.base
|
||||||
|
:synopsis: Base class for frontends
|
||||||
|
:members:
|
||||||
|
|
||||||
|
|
||||||
Frontends
|
Frontends
|
||||||
=========
|
=========
|
||||||
|
|
||||||
|
* :mod:`mopidy.frontends.lastfm`
|
||||||
* :mod:`mopidy.frontends.mpd`
|
* :mod:`mopidy.frontends.mpd`
|
||||||
|
|||||||
7
docs/api/frontends/lastfm.rst
Normal file
7
docs/api/frontends/lastfm.rst
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
******************************
|
||||||
|
:mod:`mopidy.frontends.lastfm`
|
||||||
|
******************************
|
||||||
|
|
||||||
|
.. automodule:: mopidy.frontends.lastfm
|
||||||
|
:synopsis: Last.fm scrobbler frontend
|
||||||
|
:members:
|
||||||
@ -5,6 +5,74 @@ 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.
|
||||||
|
|
||||||
|
|
||||||
|
0.2.0 (2010-10-24)
|
||||||
|
==================
|
||||||
|
|
||||||
|
In Mopidy 0.2.0 we've added a `Last.fm <http://www.last.fm/>`_ scrobbling
|
||||||
|
support, which means that Mopidy now can submit meta data about the tracks you
|
||||||
|
play to your Last.fm profile. See :mod:`mopidy.frontends.lastfm` for
|
||||||
|
details on new dependencies and settings. If you use Mopidy's Last.fm support,
|
||||||
|
please join the `Mopidy group at Last.fm <http://www.last.fm/group/Mopidy>`_.
|
||||||
|
|
||||||
|
With the exception of the work on the Last.fm scrobbler, there has been a
|
||||||
|
couple of quiet months in the Mopidy camp. About the only thing going on, has
|
||||||
|
been stabilization work and bug fixing. All bugs reported on GitHub, plus some,
|
||||||
|
have been fixed in 0.2.0. Thus, we hope this will be a great release!
|
||||||
|
|
||||||
|
We've worked a bit on OS X support, but not all issues are completely solved
|
||||||
|
yet. :issue:`25` is the one that is currently blocking OS X support. Any help
|
||||||
|
solving it will be greatly appreciated!
|
||||||
|
|
||||||
|
Finally, please :ref:`update your pyspotify installation
|
||||||
|
<pyspotify_installation>` when upgrading to Mopidy 0.2.0. The latest pyspotify
|
||||||
|
got a fix for the segmentation fault that occurred when playing music and
|
||||||
|
searching at the same time, thanks to Valentin David.
|
||||||
|
|
||||||
|
**Important changes**
|
||||||
|
|
||||||
|
- Added a Last.fm scrobbler. See :mod:`mopidy.frontends.lastfm` for details.
|
||||||
|
|
||||||
|
**Changes**
|
||||||
|
|
||||||
|
- Logging and command line options:
|
||||||
|
|
||||||
|
- Simplify the default log format,
|
||||||
|
:attr:`mopidy.settings.CONSOLE_LOG_FORMAT`. From a user's point of view:
|
||||||
|
Less noise, more information.
|
||||||
|
- Rename the :option:`--dump` command line option to
|
||||||
|
:option:`--save-debug-log`.
|
||||||
|
- Rename setting :attr:`mopidy.settings.DUMP_LOG_FORMAT` to
|
||||||
|
:attr:`mopidy.settings.DEBUG_LOG_FORMAT` and use it for :option:`--verbose`
|
||||||
|
too.
|
||||||
|
- Rename setting :attr:`mopidy.settings.DUMP_LOG_FILENAME` to
|
||||||
|
:attr:`mopidy.settings.DEBUG_LOG_FILENAME`.
|
||||||
|
|
||||||
|
- MPD frontend:
|
||||||
|
|
||||||
|
- MPD command ``list`` now supports queries by artist, album name, and date,
|
||||||
|
as used by e.g. the Ario client. (Fixes: :issue:`20`)
|
||||||
|
- MPD command ``add ""`` and ``addid ""`` now behaves as expected. (Fixes
|
||||||
|
:issue:`16`)
|
||||||
|
- MPD command ``playid "-1"`` now correctly resumes playback if paused.
|
||||||
|
|
||||||
|
- Random mode:
|
||||||
|
|
||||||
|
- Fix wrong behavior on end of track and next after random mode has been
|
||||||
|
used. (Fixes: :issue:`18`)
|
||||||
|
- Fix infinite recursion loop crash on playback of non-playable tracks when
|
||||||
|
in random mode. (Fixes :issue:`17`)
|
||||||
|
- Fix assertion error that happened if one removed tracks from the current
|
||||||
|
playlist, while in random mode. (Fixes :issue:`22`)
|
||||||
|
|
||||||
|
- Switched from using subprocesses to threads. (Fixes: :issue:`14`)
|
||||||
|
- :mod:`mopidy.outputs.gstreamer`: Set ``caps`` on the ``appsrc`` bin before
|
||||||
|
use. This makes sound output work with GStreamer >= 0.10.29, which includes
|
||||||
|
the versions used in Ubuntu 10.10 and on OS X if using Homebrew. (Fixes:
|
||||||
|
:issue:`21`, :issue:`24`, contributes to :issue:`14`)
|
||||||
|
- Improved handling of uncaught exceptions in threads. The entire process
|
||||||
|
should now exit immediately.
|
||||||
|
|
||||||
|
|
||||||
0.1.0 (2010-08-23)
|
0.1.0 (2010-08-23)
|
||||||
==================
|
==================
|
||||||
|
|
||||||
|
|||||||
8
docs/clients/index.rst
Normal file
8
docs/clients/index.rst
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
*******
|
||||||
|
Clients
|
||||||
|
*******
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:glob:
|
||||||
|
|
||||||
|
**
|
||||||
98
docs/clients/mpd.rst
Normal file
98
docs/clients/mpd.rst
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
************************
|
||||||
|
MPD client compatability
|
||||||
|
************************
|
||||||
|
|
||||||
|
This is a list of MPD clients we either know works well with Mopidy, or that we
|
||||||
|
know won't work well. For a more exhaustive list of MPD clients, see
|
||||||
|
http://mpd.wikia.com/wiki/Clients.
|
||||||
|
|
||||||
|
|
||||||
|
Console clients
|
||||||
|
===============
|
||||||
|
|
||||||
|
mpc
|
||||||
|
---
|
||||||
|
|
||||||
|
A command line client. Version 0.14 had some issues with Mopidy (see
|
||||||
|
:issue:`5`), but 0.16 seems to work nicely.
|
||||||
|
|
||||||
|
ncmpc
|
||||||
|
-----
|
||||||
|
|
||||||
|
A console client. Uses the ``idle`` command heavily, which Mopidy doesn't
|
||||||
|
support yet. If you want a console client, use ncmpcpp instead.
|
||||||
|
|
||||||
|
ncmpcpp
|
||||||
|
-------
|
||||||
|
|
||||||
|
A console client that generally works well with Mopidy, and is regularly used
|
||||||
|
by Mopidy developers.
|
||||||
|
|
||||||
|
Search only works for ncmpcpp versions 0.5.1 and higher, and in two of the
|
||||||
|
three search modes:
|
||||||
|
|
||||||
|
- "Match if tag contains search phrase (regexes supported)" -- Does not work.
|
||||||
|
The client tries to fetch all known metadata and do the search client side.
|
||||||
|
- "Match if tag contains searched phrase (no regexes)" -- Works.
|
||||||
|
- "Match only if both values are the same" -- Works.
|
||||||
|
|
||||||
|
If you run Ubuntu 10.04 or older, you can fetch an updated version of ncmpcpp
|
||||||
|
from `Launchpad <https://launchpad.net/ubuntu/+source/ncmpcpp>`_.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Graphical clients
|
||||||
|
=================
|
||||||
|
|
||||||
|
GMPC
|
||||||
|
----
|
||||||
|
|
||||||
|
A GTK+ client which works well with Mopidy, and is regularly used by Mopidy
|
||||||
|
developers.
|
||||||
|
|
||||||
|
Sonata
|
||||||
|
------
|
||||||
|
|
||||||
|
A GTK+ client. Generally works well with Mopidy.
|
||||||
|
|
||||||
|
Search does not work, because they do most of the search on the client side.
|
||||||
|
See :issue:`1` for details.
|
||||||
|
|
||||||
|
|
||||||
|
Android clients
|
||||||
|
===============
|
||||||
|
|
||||||
|
BitMPC
|
||||||
|
------
|
||||||
|
|
||||||
|
Works well with Mopidy.
|
||||||
|
|
||||||
|
Droid MPD
|
||||||
|
---------
|
||||||
|
|
||||||
|
Works well with Mopidy.
|
||||||
|
|
||||||
|
MPDroid
|
||||||
|
-------
|
||||||
|
|
||||||
|
Works well with Mopidy, and is regularly used by Mopidy developers.
|
||||||
|
|
||||||
|
PMix
|
||||||
|
----
|
||||||
|
|
||||||
|
Works well with Mopidy.
|
||||||
|
|
||||||
|
ThreeMPD
|
||||||
|
--------
|
||||||
|
|
||||||
|
Does not work well with Mopidy, because we haven't implemented ``listallinfo``
|
||||||
|
yet.
|
||||||
|
|
||||||
|
|
||||||
|
iPhone/iPod Touch clients
|
||||||
|
=========================
|
||||||
|
|
||||||
|
MPod
|
||||||
|
----
|
||||||
|
|
||||||
|
Works well with Mopidy as far as we've heard from users.
|
||||||
@ -151,20 +151,25 @@ Then, to generate docs::
|
|||||||
Creating releases
|
Creating releases
|
||||||
=================
|
=================
|
||||||
|
|
||||||
1. Update changelog and commit it.
|
#. Update changelog and commit it.
|
||||||
|
|
||||||
2. Tag release::
|
#. Merge the release branch (``develop`` in the example) into master::
|
||||||
|
|
||||||
git tag -a -m "Release v0.1.0a0" v0.1.0a0
|
git checkout master
|
||||||
|
git merge --no-ff -m "Release v0.2.0" develop
|
||||||
|
|
||||||
3. Push to GitHub::
|
#. Tag the release::
|
||||||
|
|
||||||
|
git tag -a -m "Release v0.2.0" v0.2.0
|
||||||
|
|
||||||
|
#. Push to GitHub::
|
||||||
|
|
||||||
git push
|
git push
|
||||||
git push --tags
|
git push --tags
|
||||||
|
|
||||||
4. Build package and upload to PyPI::
|
#. Build package and upload to PyPI::
|
||||||
|
|
||||||
rm MANIFEST # Will be regenerated by setup.py
|
rm MANIFEST # Will be regenerated by setup.py
|
||||||
python setup.py sdist upload
|
python setup.py sdist upload
|
||||||
|
|
||||||
5. Spread the word.
|
#. Spread the word.
|
||||||
|
|||||||
@ -6,26 +6,34 @@ This is the current roadmap and collection of wild ideas for future Mopidy
|
|||||||
development. This is intended to be a living document and may change at any
|
development. This is intended to be a living document and may change at any
|
||||||
time.
|
time.
|
||||||
|
|
||||||
Version 0.1
|
We intend to have about one timeboxed release every month. Thus, the roadmap is
|
||||||
===========
|
oriented around "soon" and "later" instead of mapping each feature to a future
|
||||||
|
release.
|
||||||
- Core MPD server functionality working. Gracefully handle clients' use of
|
|
||||||
non-supported functionality.
|
|
||||||
- Read-only support for Spotify through :mod:`mopidy.backends.libspotify`.
|
|
||||||
- Initial support for local file playback through
|
|
||||||
:mod:`mopidy.backends.local`. The state of local file playback will not
|
|
||||||
block the release of 0.1.
|
|
||||||
|
|
||||||
|
|
||||||
Version 0.2 and 0.3
|
Possible targets for the next version
|
||||||
===================
|
=====================================
|
||||||
|
|
||||||
0.2 will be released when we reach one of the following two goals. 0.3 will be
|
- Reintroduce support for OS X. See :issue:`14` for details.
|
||||||
released when we reach the other goal.
|
|
||||||
|
|
||||||
- Write-support for Spotify. I.e. playlist management.
|
|
||||||
- Support for using multiple Mopidy backends simultaneously. Should make it
|
- Support for using multiple Mopidy backends simultaneously. Should make it
|
||||||
possible to have both Spotify tracks and local tracks in the same playlist.
|
possible to have both Spotify tracks and local tracks in the same playlist.
|
||||||
|
- MPD frontend:
|
||||||
|
|
||||||
|
- ``idle`` support.
|
||||||
|
|
||||||
|
- Spotify backend:
|
||||||
|
|
||||||
|
- Write-support for Spotify, i.e. playlist management.
|
||||||
|
- Virtual directories with e.g. starred tracks from Spotify.
|
||||||
|
- Support for 320 kbps audio.
|
||||||
|
|
||||||
|
- Local backend:
|
||||||
|
|
||||||
|
- Better library support.
|
||||||
|
- A script for creating a tag cache.
|
||||||
|
- An alternative to tag cache for caching metadata, i.e. Sqlite.
|
||||||
|
|
||||||
|
- **[DONE]** Last.fm scrobbling.
|
||||||
|
|
||||||
|
|
||||||
Stuff we want to do, but not right now, and maybe never
|
Stuff we want to do, but not right now, and maybe never
|
||||||
@ -45,15 +53,12 @@ Stuff we want to do, but not right now, and maybe never
|
|||||||
- Compatability:
|
- Compatability:
|
||||||
|
|
||||||
- Run frontend tests against a real MPD server to ensure we are in sync.
|
- Run frontend tests against a real MPD server to ensure we are in sync.
|
||||||
- Start working with MPD client maintainers to get rid of weird assumptions
|
|
||||||
like only searching for first two letters and doing the rest of the
|
|
||||||
filtering locally in the client (:issue:`1`), etc.
|
|
||||||
|
|
||||||
- Backends:
|
- Backends:
|
||||||
|
|
||||||
- `Last.fm <http://www.last.fm/api>`_
|
- `Last.fm <http://www.last.fm/api>`_
|
||||||
- `WIMP <http://twitter.com/wimp/status/8975885632>`_
|
- `WIMP <http://twitter.com/wimp/status/8975885632>`_
|
||||||
- DNLA/UPnP to Mopidy can play music from other DNLA MediaServers.
|
- DNLA/UPnP so Mopidy can play music from other DNLA MediaServers.
|
||||||
|
|
||||||
- Frontends:
|
- Frontends:
|
||||||
|
|
||||||
@ -63,7 +68,7 @@ Stuff we want to do, but not right now, and maybe never
|
|||||||
- REST/JSON web service with a jQuery client as example application. Maybe
|
- REST/JSON web service with a jQuery client as example application. Maybe
|
||||||
based upon `Tornado <http://github.com/facebook/tornado>`_ and `jQuery
|
based upon `Tornado <http://github.com/facebook/tornado>`_ and `jQuery
|
||||||
Mobile <http://jquerymobile.com/>`_.
|
Mobile <http://jquerymobile.com/>`_.
|
||||||
- DNLA/UPnP to Mopidy can be controlled from i.e. TVs.
|
- DNLA/UPnP so Mopidy can be controlled from i.e. TVs.
|
||||||
- `XMMS2 <http://www.xmms2.org/>`_
|
- `XMMS2 <http://www.xmms2.org/>`_
|
||||||
- LIRC frontend for controlling Mopidy with a remote.
|
- LIRC frontend for controlling Mopidy with a remote.
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,9 @@ User documentation
|
|||||||
:maxdepth: 3
|
:maxdepth: 3
|
||||||
|
|
||||||
installation/index
|
installation/index
|
||||||
|
settings
|
||||||
|
running
|
||||||
|
clients/index
|
||||||
changes
|
changes
|
||||||
authors
|
authors
|
||||||
licenses
|
licenses
|
||||||
|
|||||||
@ -54,6 +54,12 @@ Make sure you got the required dependencies installed.
|
|||||||
|
|
||||||
- No additional dependencies.
|
- No additional dependencies.
|
||||||
|
|
||||||
|
- Optional dependencies:
|
||||||
|
|
||||||
|
- :mod:`mopidy.frontends.lastfm`
|
||||||
|
|
||||||
|
- pylast >= 4.3.0
|
||||||
|
|
||||||
|
|
||||||
Install latest release
|
Install latest release
|
||||||
======================
|
======================
|
||||||
@ -70,6 +76,9 @@ To later upgrade to the latest release::
|
|||||||
|
|
||||||
If you for some reason can't use ``pip``, try ``easy_install``.
|
If you for some reason can't use ``pip``, try ``easy_install``.
|
||||||
|
|
||||||
|
Next, you need to set a couple of :doc:`settings </settings>`, and then you're
|
||||||
|
ready to :doc:`run Mopidy </running>`.
|
||||||
|
|
||||||
|
|
||||||
Install development version
|
Install development version
|
||||||
===========================
|
===========================
|
||||||
@ -92,58 +101,5 @@ To later update to the very latest version::
|
|||||||
For an introduction to ``git``, please visit `git-scm.com
|
For an introduction to ``git``, please visit `git-scm.com
|
||||||
<http://git-scm.com/>`_.
|
<http://git-scm.com/>`_.
|
||||||
|
|
||||||
|
Next, you need to set a couple of :doc:`settings </settings>`, and then you're
|
||||||
Settings
|
ready to :doc:`run Mopidy </running>`.
|
||||||
========
|
|
||||||
|
|
||||||
Mopidy reads settings from the file ``~/.mopidy/settings.py``, where ``~``
|
|
||||||
means your *home directory*. If your username is ``alice`` and you are running
|
|
||||||
Linux, the settings file should probably be at
|
|
||||||
``/home/alice/.mopidy/settings.py``.
|
|
||||||
|
|
||||||
You can either create this file yourself, or run the ``mopidy`` command, and it
|
|
||||||
will create an empty settings file for you.
|
|
||||||
|
|
||||||
Music from Spotify
|
|
||||||
------------------
|
|
||||||
|
|
||||||
If you are using the Spotify backend, which is the default, enter your Spotify
|
|
||||||
Premium account's username and password into the file, like this::
|
|
||||||
|
|
||||||
SPOTIFY_USERNAME = u'myusername'
|
|
||||||
SPOTIFY_PASSWORD = u'mysecret'
|
|
||||||
|
|
||||||
Music from local storage
|
|
||||||
------------------------
|
|
||||||
|
|
||||||
If you want use Mopidy to play music you have locally at your machine instead
|
|
||||||
of using Spotify, you need to change the backend from the default to
|
|
||||||
:mod:`mopidy.backends.local` by adding the following line to your settings
|
|
||||||
file::
|
|
||||||
|
|
||||||
BACKENDS = (u'mopidy.backends.local.LocalBackend',)
|
|
||||||
|
|
||||||
You may also want to change some of the ``LOCAL_*`` settings. See
|
|
||||||
:mod:`mopidy.settings`, for a full list of available settings.
|
|
||||||
|
|
||||||
Connecting from other machines on the network
|
|
||||||
---------------------------------------------
|
|
||||||
|
|
||||||
As a secure default, Mopidy only accepts connections from ``localhost``. If you
|
|
||||||
want to open it for connections from other machines on your network, see
|
|
||||||
the documentation for :attr:`mopidy.settings.MPD_SERVER_HOSTNAME`.
|
|
||||||
|
|
||||||
|
|
||||||
Running Mopidy
|
|
||||||
==============
|
|
||||||
|
|
||||||
To start Mopidy, simply open a terminal and run::
|
|
||||||
|
|
||||||
mopidy
|
|
||||||
|
|
||||||
When Mopidy says ``MPD server running at [127.0.0.1]:6600`` it's ready to
|
|
||||||
accept connections by any MPD client. You can find tons of MPD clients at
|
|
||||||
http://mpd.wikia.com/wiki/Clients. We use GMPC and ncmpcpp during development.
|
|
||||||
The first is a GUI client, and the second is a terminal client.
|
|
||||||
|
|
||||||
To stop Mopidy, press ``CTRL+C``.
|
|
||||||
|
|||||||
13
docs/running.rst
Normal file
13
docs/running.rst
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
**************
|
||||||
|
Running Mopidy
|
||||||
|
**************
|
||||||
|
|
||||||
|
To start Mopidy, simply open a terminal and run::
|
||||||
|
|
||||||
|
mopidy
|
||||||
|
|
||||||
|
When Mopidy says ``MPD server running at [127.0.0.1]:6600`` it's ready to
|
||||||
|
accept connections by any MPD client. Check out our non-exhaustive
|
||||||
|
:doc:`/clients/mpd` list to find recommended clients.
|
||||||
|
|
||||||
|
To stop Mopidy, press ``CTRL+C``.
|
||||||
55
docs/settings.rst
Normal file
55
docs/settings.rst
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
********
|
||||||
|
Settings
|
||||||
|
********
|
||||||
|
|
||||||
|
Mopidy reads settings from the file ``~/.mopidy/settings.py``, where ``~``
|
||||||
|
means your *home directory*. If your username is ``alice`` and you are running
|
||||||
|
Linux, the settings file should probably be at
|
||||||
|
``/home/alice/.mopidy/settings.py``.
|
||||||
|
|
||||||
|
You can either create this file yourself, or run the ``mopidy`` command, and it
|
||||||
|
will create an empty settings file for you.
|
||||||
|
|
||||||
|
|
||||||
|
Music from Spotify
|
||||||
|
==================
|
||||||
|
|
||||||
|
If you are using the Spotify backend, which is the default, enter your Spotify
|
||||||
|
Premium account's username and password into the file, like this::
|
||||||
|
|
||||||
|
SPOTIFY_USERNAME = u'myusername'
|
||||||
|
SPOTIFY_PASSWORD = u'mysecret'
|
||||||
|
|
||||||
|
|
||||||
|
Music from local storage
|
||||||
|
========================
|
||||||
|
|
||||||
|
If you want use Mopidy to play music you have locally at your machine instead
|
||||||
|
of using Spotify, you need to change the backend from the default to
|
||||||
|
:mod:`mopidy.backends.local` by adding the following line to your settings
|
||||||
|
file::
|
||||||
|
|
||||||
|
BACKENDS = (u'mopidy.backends.local.LocalBackend',)
|
||||||
|
|
||||||
|
You may also want to change some of the ``LOCAL_*`` settings. See
|
||||||
|
:mod:`mopidy.settings`, for a full list of available settings.
|
||||||
|
|
||||||
|
|
||||||
|
Connecting from other machines on the network
|
||||||
|
=============================================
|
||||||
|
|
||||||
|
As a secure default, Mopidy only accepts connections from ``localhost``. If you
|
||||||
|
want to open it for connections from other machines on your network, see
|
||||||
|
the documentation for :attr:`mopidy.settings.MPD_SERVER_HOSTNAME`.
|
||||||
|
|
||||||
|
|
||||||
|
Scrobbling tracks to Last.fm
|
||||||
|
============================
|
||||||
|
|
||||||
|
If you want to submit the tracks you are playing to your `Last.fm
|
||||||
|
<http://www.last.fm/>`_ profile, make sure you've installed the dependencies
|
||||||
|
found at :mod:`mopidy.frontends.lastfm` and add the following to your settings
|
||||||
|
file::
|
||||||
|
|
||||||
|
LASTFM_USERNAME = u'myusername'
|
||||||
|
LASTFM_PASSWORD = u'mysecret'
|
||||||
@ -3,7 +3,7 @@ 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')
|
||||||
|
|
||||||
def get_version():
|
def get_version():
|
||||||
return u'0.1.0'
|
return u'0.2.0'
|
||||||
|
|
||||||
class MopidyException(Exception):
|
class MopidyException(Exception):
|
||||||
def __init__(self, message, *args, **kwargs):
|
def __init__(self, message, *args, **kwargs):
|
||||||
@ -22,6 +22,9 @@ class MopidyException(Exception):
|
|||||||
class SettingsError(MopidyException):
|
class SettingsError(MopidyException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
class OptionalDependencyError(MopidyException):
|
||||||
|
pass
|
||||||
|
|
||||||
from mopidy import settings as default_settings_module
|
from mopidy import settings as default_settings_module
|
||||||
from mopidy.utils.settings import SettingsProxy
|
from mopidy.utils.settings import SettingsProxy
|
||||||
settings = SettingsProxy(default_settings_module)
|
settings = SettingsProxy(default_settings_module)
|
||||||
|
|||||||
@ -23,17 +23,17 @@ class BaseBackend(object):
|
|||||||
:param core_queue: a queue for sending messages to
|
:param core_queue: a queue for sending messages to
|
||||||
:class:`mopidy.process.CoreProcess`
|
:class:`mopidy.process.CoreProcess`
|
||||||
:type core_queue: :class:`multiprocessing.Queue`
|
:type core_queue: :class:`multiprocessing.Queue`
|
||||||
:param output_queue: a queue for sending messages to the output process
|
:param output: the audio output
|
||||||
:type output_queue: :class:`multiprocessing.Queue`
|
:type output: :class:`mopidy.outputs.gstreamer.GStreamerOutput` or similar
|
||||||
:param mixer_class: either a mixer class, or :class:`None` to use the mixer
|
:param mixer_class: either a mixer class, or :class:`None` to use the mixer
|
||||||
defined in settings
|
defined in settings
|
||||||
:type mixer_class: a subclass of :class:`mopidy.mixers.BaseMixer` or
|
:type mixer_class: a subclass of :class:`mopidy.mixers.BaseMixer` or
|
||||||
:class:`None`
|
:class:`None`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, core_queue=None, output_queue=None, mixer_class=None):
|
def __init__(self, core_queue=None, output=None, mixer_class=None):
|
||||||
self.core_queue = core_queue
|
self.core_queue = core_queue
|
||||||
self.output_queue = output_queue
|
self.output = output
|
||||||
if mixer_class is None:
|
if mixer_class is None:
|
||||||
mixer_class = get_class(settings.MIXER)
|
mixer_class = get_class(settings.MIXER)
|
||||||
self.mixer = mixer_class(self)
|
self.mixer = mixer_class(self)
|
||||||
|
|||||||
@ -12,13 +12,10 @@ class BaseCurrentPlaylistController(object):
|
|||||||
:type backend: :class:`BaseBackend`
|
:type backend: :class:`BaseBackend`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
#: The current playlist version. Integer which is increased every time the
|
|
||||||
#: current playlist is changed. Is not reset before Mopidy is restarted.
|
|
||||||
version = 0
|
|
||||||
|
|
||||||
def __init__(self, backend):
|
def __init__(self, backend):
|
||||||
self.backend = backend
|
self.backend = backend
|
||||||
self._cp_tracks = []
|
self._cp_tracks = []
|
||||||
|
self._version = 0
|
||||||
|
|
||||||
def destroy(self):
|
def destroy(self):
|
||||||
"""Cleanup after component."""
|
"""Cleanup after component."""
|
||||||
@ -42,6 +39,19 @@ class BaseCurrentPlaylistController(object):
|
|||||||
"""
|
"""
|
||||||
return [ct[1] for ct in self._cp_tracks]
|
return [ct[1] for ct in self._cp_tracks]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def version(self):
|
||||||
|
"""
|
||||||
|
The current playlist version. Integer which is increased every time the
|
||||||
|
current playlist is changed. Is not reset before Mopidy is restarted.
|
||||||
|
"""
|
||||||
|
return self._version
|
||||||
|
|
||||||
|
@version.setter
|
||||||
|
def version(self, version):
|
||||||
|
self._version = version
|
||||||
|
self.backend.playback.on_current_playlist_change()
|
||||||
|
|
||||||
def add(self, track, at_position=None):
|
def add(self, track, at_position=None):
|
||||||
"""
|
"""
|
||||||
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
|
||||||
@ -71,16 +81,13 @@ class BaseCurrentPlaylistController(object):
|
|||||||
:param tracks: tracks to append
|
:param tracks: tracks to append
|
||||||
:type tracks: list of :class:`mopidy.models.Track`
|
:type tracks: list of :class:`mopidy.models.Track`
|
||||||
"""
|
"""
|
||||||
self.version += 1
|
|
||||||
for track in tracks:
|
for track in tracks:
|
||||||
self.add(track)
|
self.add(track)
|
||||||
self.backend.playback.on_current_playlist_change()
|
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
"""Clear the current playlist."""
|
"""Clear the current playlist."""
|
||||||
self._cp_tracks = []
|
self._cp_tracks = []
|
||||||
self.version += 1
|
self.version += 1
|
||||||
self.backend.playback.on_current_playlist_change()
|
|
||||||
|
|
||||||
def get(self, **criteria):
|
def get(self, **criteria):
|
||||||
"""
|
"""
|
||||||
@ -146,7 +153,6 @@ class BaseCurrentPlaylistController(object):
|
|||||||
to_position += 1
|
to_position += 1
|
||||||
self._cp_tracks = new_cp_tracks
|
self._cp_tracks = new_cp_tracks
|
||||||
self.version += 1
|
self.version += 1
|
||||||
self.backend.playback.on_current_playlist_change()
|
|
||||||
|
|
||||||
def remove(self, **criteria):
|
def remove(self, **criteria):
|
||||||
"""
|
"""
|
||||||
@ -191,7 +197,6 @@ class BaseCurrentPlaylistController(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
|
||||||
self.backend.playback.on_current_playlist_change()
|
|
||||||
|
|
||||||
def mpd_format(self, *args, **kwargs):
|
def mpd_format(self, *args, **kwargs):
|
||||||
"""Not a part of the generic backend API."""
|
"""Not a part of the generic backend API."""
|
||||||
|
|||||||
@ -142,7 +142,7 @@ class BasePlaybackController(object):
|
|||||||
random.shuffle(self._shuffled)
|
random.shuffle(self._shuffled)
|
||||||
self._first_shuffle = False
|
self._first_shuffle = False
|
||||||
|
|
||||||
if self._shuffled:
|
if self.random and self._shuffled:
|
||||||
return self._shuffled[0]
|
return self._shuffled[0]
|
||||||
|
|
||||||
if self.current_cp_track is None:
|
if self.current_cp_track is None:
|
||||||
@ -195,7 +195,7 @@ class BasePlaybackController(object):
|
|||||||
random.shuffle(self._shuffled)
|
random.shuffle(self._shuffled)
|
||||||
self._first_shuffle = False
|
self._first_shuffle = False
|
||||||
|
|
||||||
if self._shuffled:
|
if self.random and self._shuffled:
|
||||||
return self._shuffled[0]
|
return self._shuffled[0]
|
||||||
|
|
||||||
if self.current_cp_track is None:
|
if self.current_cp_track is None:
|
||||||
@ -311,14 +311,12 @@ class BasePlaybackController(object):
|
|||||||
return
|
return
|
||||||
|
|
||||||
original_cp_track = self.current_cp_track
|
original_cp_track = self.current_cp_track
|
||||||
if self.cp_track_at_eot:
|
|
||||||
self.play(self.cp_track_at_eot)
|
|
||||||
|
|
||||||
if self.random and self.current_cp_track in self._shuffled:
|
if self.cp_track_at_eot:
|
||||||
self._shuffled.remove(self.current_cp_track)
|
self._trigger_stopped_playing_event()
|
||||||
|
self.play(self.cp_track_at_eot)
|
||||||
else:
|
else:
|
||||||
self.stop()
|
self.stop(clear_current_track=True)
|
||||||
self.current_cp_track = None
|
|
||||||
|
|
||||||
if self.consume:
|
if self.consume:
|
||||||
self.backend.current_playlist.remove(cpid=original_cp_track[0])
|
self.backend.current_playlist.remove(cpid=original_cp_track[0])
|
||||||
@ -332,13 +330,10 @@ class BasePlaybackController(object):
|
|||||||
self._first_shuffle = True
|
self._first_shuffle = True
|
||||||
self._shuffled = []
|
self._shuffled = []
|
||||||
|
|
||||||
if not self.backend.current_playlist.cp_tracks:
|
if (not self.backend.current_playlist.cp_tracks or
|
||||||
self.stop()
|
self.current_cp_track not in
|
||||||
self.current_cp_track = None
|
|
||||||
elif (self.current_cp_track not in
|
|
||||||
self.backend.current_playlist.cp_tracks):
|
self.backend.current_playlist.cp_tracks):
|
||||||
self.current_cp_track = None
|
self.stop(clear_current_track=True)
|
||||||
self.stop()
|
|
||||||
|
|
||||||
def next(self):
|
def next(self):
|
||||||
"""Play the next track."""
|
"""Play the next track."""
|
||||||
@ -346,13 +341,10 @@ class BasePlaybackController(object):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if self.cp_track_at_next:
|
if self.cp_track_at_next:
|
||||||
|
self._trigger_stopped_playing_event()
|
||||||
self.play(self.cp_track_at_next)
|
self.play(self.cp_track_at_next)
|
||||||
else:
|
else:
|
||||||
self.stop()
|
self.stop(clear_current_track=True)
|
||||||
self.current_cp_track = None
|
|
||||||
|
|
||||||
if self.random and self.current_cp_track in self._shuffled:
|
|
||||||
self._shuffled.remove(self.current_cp_track)
|
|
||||||
|
|
||||||
def pause(self):
|
def pause(self):
|
||||||
"""Pause playback."""
|
"""Pause playback."""
|
||||||
@ -383,15 +375,21 @@ class BasePlaybackController(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 not self.current_cp_track:
|
|
||||||
|
if cp_track is None and self.current_cp_track is None:
|
||||||
cp_track = self.cp_track_at_next
|
cp_track = self.cp_track_at_next
|
||||||
|
|
||||||
if self.state == self.PAUSED and cp_track is None:
|
if cp_track is None and self.state == self.PAUSED:
|
||||||
self.resume()
|
self.resume()
|
||||||
elif 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._play(cp_track[1]):
|
if not self._play(cp_track[1]):
|
||||||
|
# Track is not playable
|
||||||
|
if self.random and self._shuffled:
|
||||||
|
self._shuffled.remove(cp_track)
|
||||||
if on_error_step == 1:
|
if on_error_step == 1:
|
||||||
self.next()
|
self.next()
|
||||||
elif on_error_step == -1:
|
elif on_error_step == -1:
|
||||||
@ -400,6 +398,8 @@ class BasePlaybackController(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()
|
||||||
|
|
||||||
def _play(self, track):
|
def _play(self, track):
|
||||||
"""
|
"""
|
||||||
To be overridden by subclass. Implement your backend's play
|
To be overridden by subclass. Implement your backend's play
|
||||||
@ -418,6 +418,7 @@ class BasePlaybackController(object):
|
|||||||
return
|
return
|
||||||
if self.state == self.STOPPED:
|
if self.state == self.STOPPED:
|
||||||
return
|
return
|
||||||
|
self._trigger_stopped_playing_event()
|
||||||
self.play(self.cp_track_at_previous, on_error_step=-1)
|
self.play(self.cp_track_at_previous, on_error_step=-1)
|
||||||
|
|
||||||
def resume(self):
|
def resume(self):
|
||||||
@ -442,8 +443,9 @@ class BasePlaybackController(object):
|
|||||||
:type time_position: int
|
:type time_position: int
|
||||||
:rtype: :class:`True` if successful, else :class:`False`
|
:rtype: :class:`True` if successful, else :class:`False`
|
||||||
"""
|
"""
|
||||||
# FIXME I think return value is only really useful for internal
|
if not self.backend.current_playlist.tracks:
|
||||||
# testing, as such it should probably not be exposed in API.
|
return False
|
||||||
|
|
||||||
if self.state == self.STOPPED:
|
if self.state == self.STOPPED:
|
||||||
self.play()
|
self.play()
|
||||||
elif self.state == self.PAUSED:
|
elif self.state == self.PAUSED:
|
||||||
@ -451,9 +453,9 @@ class BasePlaybackController(object):
|
|||||||
|
|
||||||
if time_position < 0:
|
if time_position < 0:
|
||||||
time_position = 0
|
time_position = 0
|
||||||
elif self.current_track and time_position > self.current_track.length:
|
elif time_position > self.current_track.length:
|
||||||
self.next()
|
self.next()
|
||||||
return
|
return True
|
||||||
|
|
||||||
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
|
||||||
@ -471,10 +473,21 @@ class BasePlaybackController(object):
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def stop(self):
|
def stop(self, clear_current_track=False):
|
||||||
"""Stop playing."""
|
"""
|
||||||
if self.state != self.STOPPED and self._stop():
|
Stop playing.
|
||||||
|
|
||||||
|
:param clear_current_track: whether to clear the current track _after_
|
||||||
|
stopping
|
||||||
|
:type clear_current_track: boolean
|
||||||
|
"""
|
||||||
|
if self.state == self.STOPPED:
|
||||||
|
return
|
||||||
|
self._trigger_stopped_playing_event()
|
||||||
|
if self._stop():
|
||||||
self.state = self.STOPPED
|
self.state = self.STOPPED
|
||||||
|
if clear_current_track:
|
||||||
|
self.current_cp_track = None
|
||||||
|
|
||||||
def _stop(self):
|
def _stop(self):
|
||||||
"""
|
"""
|
||||||
@ -484,3 +497,33 @@ class BasePlaybackController(object):
|
|||||||
:rtype: :class:`True` if successful, else :class:`False`
|
:rtype: :class:`True` if successful, else :class:`False`
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def _trigger_started_playing_event(self):
|
||||||
|
"""
|
||||||
|
Notifies frontends that a track has started playing.
|
||||||
|
|
||||||
|
For internal use only. Should be called by the backend directly after a
|
||||||
|
track has started playing.
|
||||||
|
"""
|
||||||
|
if self.current_track is not None:
|
||||||
|
self.backend.core_queue.put({
|
||||||
|
'to': 'frontend',
|
||||||
|
'command': 'started_playing',
|
||||||
|
'track': self.current_track,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _trigger_stopped_playing_event(self):
|
||||||
|
"""
|
||||||
|
Notifies frontends that a track has stopped playing.
|
||||||
|
|
||||||
|
For internal use only. Should be called by the backend before a track
|
||||||
|
is stopped playing, e.g. at the next, previous, and stop actions and at
|
||||||
|
end-of-track.
|
||||||
|
"""
|
||||||
|
if self.current_track is not None:
|
||||||
|
self.backend.core_queue.put({
|
||||||
|
'to': 'frontend',
|
||||||
|
'command': 'stopped_playing',
|
||||||
|
'track': self.current_track,
|
||||||
|
'stop_position': self.time_position,
|
||||||
|
})
|
||||||
|
|||||||
@ -44,26 +44,35 @@ class DummyLibraryController(BaseLibraryController):
|
|||||||
|
|
||||||
class DummyPlaybackController(BasePlaybackController):
|
class DummyPlaybackController(BasePlaybackController):
|
||||||
def _next(self, track):
|
def _next(self, track):
|
||||||
return True
|
"""Pass None as track to force failure"""
|
||||||
|
return track is not None
|
||||||
|
|
||||||
def _pause(self):
|
def _pause(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _play(self, track):
|
def _play(self, track):
|
||||||
return True
|
"""Pass None as track to force failure"""
|
||||||
|
return track is not None
|
||||||
|
|
||||||
def _previous(self, track):
|
def _previous(self, track):
|
||||||
return True
|
"""Pass None as track to force failure"""
|
||||||
|
return track is not None
|
||||||
|
|
||||||
def _resume(self):
|
def _resume(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _seek(self, time_position):
|
def _seek(self, time_position):
|
||||||
pass
|
return True
|
||||||
|
|
||||||
def _stop(self):
|
def _stop(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _trigger_started_playing_event(self):
|
||||||
|
pass # noop
|
||||||
|
|
||||||
|
def _trigger_stopped_playing_event(self):
|
||||||
|
pass # noop
|
||||||
|
|
||||||
|
|
||||||
class DummyStoredPlaylistsController(BaseStoredPlaylistsController):
|
class DummyStoredPlaylistsController(BaseStoredPlaylistsController):
|
||||||
_playlists = []
|
_playlists = []
|
||||||
|
|||||||
@ -51,10 +51,10 @@ class LibspotifyBackend(BaseBackend):
|
|||||||
from .session_manager import LibspotifySessionManager
|
from .session_manager import LibspotifySessionManager
|
||||||
|
|
||||||
logger.info(u'Mopidy uses SPOTIFY(R) CORE')
|
logger.info(u'Mopidy uses SPOTIFY(R) CORE')
|
||||||
logger.info(u'Connecting to Spotify')
|
logger.debug(u'Connecting to Spotify')
|
||||||
spotify = LibspotifySessionManager(
|
spotify = LibspotifySessionManager(
|
||||||
settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD,
|
settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD,
|
||||||
core_queue=self.core_queue,
|
core_queue=self.core_queue,
|
||||||
output_queue=self.output_queue)
|
output=self.output)
|
||||||
spotify.start()
|
spotify.start()
|
||||||
return spotify
|
return spotify
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
|
|
||||||
from spotify import Link
|
from spotify import Link, SpotifyError
|
||||||
|
|
||||||
from mopidy.backends.base import BaseLibraryController
|
from mopidy.backends.base import BaseLibraryController
|
||||||
from mopidy.backends.libspotify import ENCODING
|
from mopidy.backends.libspotify import ENCODING
|
||||||
from mopidy.backends.libspotify.translator import LibspotifyTranslator
|
from mopidy.backends.libspotify.translator import LibspotifyTranslator
|
||||||
|
from mopidy.models import Playlist
|
||||||
|
|
||||||
logger = logging.getLogger('mopidy.backends.libspotify.library')
|
logger = logging.getLogger('mopidy.backends.libspotify.library')
|
||||||
|
|
||||||
@ -14,24 +15,41 @@ class LibspotifyLibraryController(BaseLibraryController):
|
|||||||
return self.search(**query)
|
return self.search(**query)
|
||||||
|
|
||||||
def lookup(self, uri):
|
def lookup(self, uri):
|
||||||
spotify_track = Link.from_string(uri).as_track()
|
try:
|
||||||
# TODO Block until metadata_updated callback is called. Before that the
|
spotify_track = Link.from_string(uri).as_track()
|
||||||
# track will be unloaded, unless it's already in the stored playlists.
|
# TODO Block until metadata_updated callback is called. Before that
|
||||||
return LibspotifyTranslator.to_mopidy_track(spotify_track)
|
# the track will be unloaded, unless it's already in the stored
|
||||||
|
# playlists.
|
||||||
|
return LibspotifyTranslator.to_mopidy_track(spotify_track)
|
||||||
|
except SpotifyError as e:
|
||||||
|
logger.warning(u'Failed to lookup: %s', uri, e)
|
||||||
|
return None
|
||||||
|
|
||||||
def refresh(self, uri=None):
|
def refresh(self, uri=None):
|
||||||
pass # TODO
|
pass # TODO
|
||||||
|
|
||||||
def search(self, **query):
|
def search(self, **query):
|
||||||
|
if not query:
|
||||||
|
# Since we can't search for the entire Spotify library, we return
|
||||||
|
# all tracks in the stored playlists when the query is empty.
|
||||||
|
tracks = []
|
||||||
|
for playlist in self.backend.stored_playlists.playlists:
|
||||||
|
tracks += playlist.tracks
|
||||||
|
return Playlist(tracks=tracks)
|
||||||
spotify_query = []
|
spotify_query = []
|
||||||
for (field, values) in query.iteritems():
|
for (field, values) in query.iteritems():
|
||||||
|
if field == u'track':
|
||||||
|
field = u'title'
|
||||||
|
if field == u'date':
|
||||||
|
field = u'year'
|
||||||
if not hasattr(values, '__iter__'):
|
if not hasattr(values, '__iter__'):
|
||||||
values = [values]
|
values = [values]
|
||||||
for value in values:
|
for value in values:
|
||||||
if field == u'track':
|
|
||||||
field = u'title'
|
|
||||||
if field == u'any':
|
if field == u'any':
|
||||||
spotify_query.append(value)
|
spotify_query.append(value)
|
||||||
|
elif field == u'year':
|
||||||
|
value = int(value.split('-')[0]) # Extract year
|
||||||
|
spotify_query.append(u'%s:%d' % (field, value))
|
||||||
else:
|
else:
|
||||||
spotify_query.append(u'%s:"%s"' % (field, value))
|
spotify_query.append(u'%s:"%s"' % (field, value))
|
||||||
spotify_query = u' '.join(spotify_query)
|
spotify_query = u' '.join(spotify_query)
|
||||||
|
|||||||
@ -1,30 +1,17 @@
|
|||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
|
||||||
|
|
||||||
from spotify import Link, SpotifyError
|
from spotify import Link, SpotifyError
|
||||||
|
|
||||||
from mopidy.backends.base import BasePlaybackController
|
from mopidy.backends.base import BasePlaybackController
|
||||||
from mopidy.utils.process import pickle_connection
|
|
||||||
|
|
||||||
logger = logging.getLogger('mopidy.backends.libspotify.playback')
|
logger = logging.getLogger('mopidy.backends.libspotify.playback')
|
||||||
|
|
||||||
class LibspotifyPlaybackController(BasePlaybackController):
|
class LibspotifyPlaybackController(BasePlaybackController):
|
||||||
def _set_output_state(self, state_name):
|
|
||||||
logger.debug(u'Setting output state to %s ...', state_name)
|
|
||||||
(my_end, other_end) = multiprocessing.Pipe()
|
|
||||||
self.backend.output_queue.put({
|
|
||||||
'command': 'set_state',
|
|
||||||
'state': state_name,
|
|
||||||
'reply_to': pickle_connection(other_end),
|
|
||||||
})
|
|
||||||
my_end.poll(None)
|
|
||||||
return my_end.recv()
|
|
||||||
|
|
||||||
def _pause(self):
|
def _pause(self):
|
||||||
return self._set_output_state('PAUSED')
|
return self.backend.output.set_state('PAUSED')
|
||||||
|
|
||||||
def _play(self, track):
|
def _play(self, track):
|
||||||
self._set_output_state('READY')
|
self.backend.output.set_state('READY')
|
||||||
if self.state == self.PLAYING:
|
if self.state == self.PLAYING:
|
||||||
self.backend.spotify.session.play(0)
|
self.backend.spotify.session.play(0)
|
||||||
if track.uri is None:
|
if track.uri is None:
|
||||||
@ -33,7 +20,7 @@ class LibspotifyPlaybackController(BasePlaybackController):
|
|||||||
self.backend.spotify.session.load(
|
self.backend.spotify.session.load(
|
||||||
Link.from_string(track.uri).as_track())
|
Link.from_string(track.uri).as_track())
|
||||||
self.backend.spotify.session.play(1)
|
self.backend.spotify.session.play(1)
|
||||||
self._set_output_state('PLAYING')
|
self.backend.output.set_state('PLAYING')
|
||||||
return True
|
return True
|
||||||
except SpotifyError as e:
|
except SpotifyError as e:
|
||||||
logger.warning('Play %s failed: %s', track.uri, e)
|
logger.warning('Play %s failed: %s', track.uri, e)
|
||||||
@ -43,12 +30,12 @@ class LibspotifyPlaybackController(BasePlaybackController):
|
|||||||
return self._seek(self.time_position)
|
return self._seek(self.time_position)
|
||||||
|
|
||||||
def _seek(self, time_position):
|
def _seek(self, time_position):
|
||||||
self._set_output_state('READY')
|
self.backend.output.set_state('READY')
|
||||||
self.backend.spotify.session.seek(time_position)
|
self.backend.spotify.session.seek(time_position)
|
||||||
self._set_output_state('PLAYING')
|
self.backend.output.set_state('PLAYING')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _stop(self):
|
def _stop(self):
|
||||||
result = self._set_output_state('READY')
|
result = self.backend.output.set_state('READY')
|
||||||
self.backend.spotify.session.play(0)
|
self.backend.spotify.session.play(0)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@ -5,44 +5,42 @@ import threading
|
|||||||
from spotify.manager import SpotifySessionManager
|
from spotify.manager import SpotifySessionManager
|
||||||
|
|
||||||
from mopidy import get_version, settings
|
from mopidy import get_version, settings
|
||||||
from mopidy.models import Playlist
|
|
||||||
from mopidy.backends.libspotify.translator import LibspotifyTranslator
|
from mopidy.backends.libspotify.translator import LibspotifyTranslator
|
||||||
|
from mopidy.models import Playlist
|
||||||
|
from mopidy.utils.process import BaseThread
|
||||||
|
|
||||||
logger = logging.getLogger('mopidy.backends.libspotify.session_manager')
|
logger = logging.getLogger('mopidy.backends.libspotify.session_manager')
|
||||||
|
|
||||||
class LibspotifySessionManager(SpotifySessionManager, threading.Thread):
|
class LibspotifySessionManager(SpotifySessionManager, BaseThread):
|
||||||
cache_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE)
|
cache_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE)
|
||||||
settings_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE)
|
settings_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE)
|
||||||
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()
|
||||||
|
|
||||||
def __init__(self, username, password, core_queue, output_queue):
|
def __init__(self, username, password, core_queue, output):
|
||||||
SpotifySessionManager.__init__(self, username, password)
|
SpotifySessionManager.__init__(self, username, password)
|
||||||
threading.Thread.__init__(self, name='LibspotifySessionManagerThread')
|
BaseThread.__init__(self, core_queue)
|
||||||
# Run as a daemon thread, so Mopidy won't wait for this thread to exit
|
self.name = 'LibspotifySMThread'
|
||||||
# before Mopidy exits.
|
self.output = output
|
||||||
self.daemon = True
|
|
||||||
self.core_queue = core_queue
|
|
||||||
self.output_queue = output_queue
|
|
||||||
self.connected = threading.Event()
|
self.connected = threading.Event()
|
||||||
self.session = None
|
self.session = None
|
||||||
|
|
||||||
def run(self):
|
def run_inside_try(self):
|
||||||
self.connect()
|
self.connect()
|
||||||
|
|
||||||
def logged_in(self, session, error):
|
def logged_in(self, session, error):
|
||||||
"""Callback used by pyspotify"""
|
"""Callback used by pyspotify"""
|
||||||
logger.info('Logged in')
|
logger.info(u'Connected to Spotify')
|
||||||
self.session = session
|
self.session = session
|
||||||
self.connected.set()
|
self.connected.set()
|
||||||
|
|
||||||
def logged_out(self, session):
|
def logged_out(self, session):
|
||||||
"""Callback used by pyspotify"""
|
"""Callback used by pyspotify"""
|
||||||
logger.info('Logged out')
|
logger.info(u'Disconnected from Spotify')
|
||||||
|
|
||||||
def metadata_updated(self, session):
|
def metadata_updated(self, session):
|
||||||
"""Callback used by pyspotify"""
|
"""Callback used by pyspotify"""
|
||||||
logger.debug('Metadata updated, refreshing stored playlists')
|
logger.debug(u'Metadata updated, refreshing stored playlists')
|
||||||
playlists = []
|
playlists = []
|
||||||
for spotify_playlist in session.playlist_container():
|
for spotify_playlist in session.playlist_container():
|
||||||
playlists.append(
|
playlists.append(
|
||||||
@ -54,52 +52,51 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread):
|
|||||||
|
|
||||||
def connection_error(self, session, error):
|
def connection_error(self, session, error):
|
||||||
"""Callback used by pyspotify"""
|
"""Callback used by pyspotify"""
|
||||||
logger.error('Connection error: %s', error)
|
logger.error(u'Connection error: %s', error)
|
||||||
|
|
||||||
def message_to_user(self, session, message):
|
def message_to_user(self, session, message):
|
||||||
"""Callback used by pyspotify"""
|
"""Callback used by pyspotify"""
|
||||||
logger.info(message.strip())
|
logger.debug(u'User message: %s', message.strip())
|
||||||
|
|
||||||
def notify_main_thread(self, session):
|
def notify_main_thread(self, session):
|
||||||
"""Callback used by pyspotify"""
|
"""Callback used by pyspotify"""
|
||||||
logger.debug('Notify main thread')
|
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"""
|
||||||
# TODO Base caps_string on arguments
|
assert sample_type == 0, u'Expects 16-bit signed integer samples'
|
||||||
caps_string = """
|
capabilites = """
|
||||||
audio/x-raw-int,
|
audio/x-raw-int,
|
||||||
endianness=(int)1234,
|
endianness=(int)1234,
|
||||||
channels=(int)2,
|
channels=(int)%(channels)d,
|
||||||
width=(int)16,
|
width=(int)16,
|
||||||
depth=(int)16,
|
depth=(int)16,
|
||||||
signed=True,
|
signed=(boolean)true,
|
||||||
rate=(int)44100
|
rate=(int)%(sample_rate)d
|
||||||
"""
|
""" % {
|
||||||
self.output_queue.put({
|
'sample_rate': sample_rate,
|
||||||
'command': 'deliver_data',
|
'channels': channels,
|
||||||
'caps': caps_string,
|
}
|
||||||
'data': bytes(frames),
|
self.output.deliver_data(capabilites, bytes(frames))
|
||||||
})
|
|
||||||
|
|
||||||
def play_token_lost(self, session):
|
def play_token_lost(self, session):
|
||||||
"""Callback used by pyspotify"""
|
"""Callback used by pyspotify"""
|
||||||
logger.debug('Play token lost')
|
logger.debug(u'Play token lost')
|
||||||
self.core_queue.put({'command': 'stop_playback'})
|
self.core_queue.put({'command': 'stop_playback'})
|
||||||
|
|
||||||
def log_message(self, session, data):
|
def log_message(self, session, data):
|
||||||
"""Callback used by pyspotify"""
|
"""Callback used by pyspotify"""
|
||||||
logger.debug(data.strip())
|
logger.debug(u'System message: %s' % data.strip())
|
||||||
|
|
||||||
def end_of_track(self, session):
|
def end_of_track(self, session):
|
||||||
"""Callback used by pyspotify"""
|
"""Callback used by pyspotify"""
|
||||||
logger.debug('End of data stream.')
|
logger.debug(u'End of data stream reached')
|
||||||
self.output_queue.put({'command': 'end_of_data_stream'})
|
self.output.end_of_data_stream()
|
||||||
|
|
||||||
def search(self, query, connection):
|
def search(self, query, connection):
|
||||||
"""Search method used by Mopidy backend"""
|
"""Search method used by Mopidy backend"""
|
||||||
def callback(results, userdata):
|
def callback(results, userdata=None):
|
||||||
# TODO Include results from results.albums(), etc. too
|
# TODO Include results from results.albums(), etc. too
|
||||||
playlist = Playlist(tracks=[
|
playlist = Playlist(tracks=[
|
||||||
LibspotifyTranslator.to_mopidy_track(t)
|
LibspotifyTranslator.to_mopidy_track(t)
|
||||||
|
|||||||
@ -41,38 +41,24 @@ class LocalPlaybackController(BasePlaybackController):
|
|||||||
super(LocalPlaybackController, self).__init__(backend)
|
super(LocalPlaybackController, self).__init__(backend)
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
def _send_recv(self, message):
|
|
||||||
(my_end, other_end) = multiprocessing.Pipe()
|
|
||||||
message.update({'reply_to': pickle_connection(other_end)})
|
|
||||||
self.backend.output_queue.put(message)
|
|
||||||
my_end.poll(None)
|
|
||||||
return my_end.recv()
|
|
||||||
|
|
||||||
def _send(self, message):
|
|
||||||
self.backend.output_queue.put(message)
|
|
||||||
|
|
||||||
def _set_state(self, state):
|
|
||||||
return self._send_recv({'command': 'set_state', 'state': state})
|
|
||||||
|
|
||||||
def _play(self, track):
|
def _play(self, track):
|
||||||
return self._send_recv({'command': 'play_uri', 'uri': track.uri})
|
return self.backend.output.play_uri(track.uri)
|
||||||
|
|
||||||
def _stop(self):
|
def _stop(self):
|
||||||
return self._set_state('READY')
|
return self.backend.output.set_state('READY')
|
||||||
|
|
||||||
def _pause(self):
|
def _pause(self):
|
||||||
return self._set_state('PAUSED')
|
return self.backend.output.set_state('PAUSED')
|
||||||
|
|
||||||
def _resume(self):
|
def _resume(self):
|
||||||
return self._set_state('PLAYING')
|
return self.backend.output.set_state('PLAYING')
|
||||||
|
|
||||||
def _seek(self, time_position):
|
def _seek(self, time_position):
|
||||||
return self._send_recv({'command': 'set_position',
|
return self.backend.output.set_position(time_position)
|
||||||
'position': time_position})
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def time_position(self):
|
def time_position(self):
|
||||||
return self._send_recv({'command': 'get_position'})
|
return self.backend.output.get_position()
|
||||||
|
|
||||||
|
|
||||||
class LocalStoredPlaylistsController(BaseStoredPlaylistsController):
|
class LocalStoredPlaylistsController(BaseStoredPlaylistsController):
|
||||||
|
|||||||
@ -1,24 +1,26 @@
|
|||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import optparse
|
import optparse
|
||||||
|
import sys
|
||||||
|
|
||||||
from mopidy import get_version, settings
|
from mopidy import get_version, settings, OptionalDependencyError
|
||||||
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 BaseProcess, unpickle_connection
|
from mopidy.utils.process import BaseThread
|
||||||
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')
|
||||||
|
|
||||||
class CoreProcess(BaseProcess):
|
class CoreProcess(BaseThread):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super(CoreProcess, self).__init__(name='CoreProcess')
|
|
||||||
self.core_queue = multiprocessing.Queue()
|
self.core_queue = multiprocessing.Queue()
|
||||||
|
super(CoreProcess, self).__init__(self.core_queue)
|
||||||
|
self.name = 'CoreProcess'
|
||||||
self.options = self.parse_options()
|
self.options = self.parse_options()
|
||||||
self.output_queue = None
|
self.output = None
|
||||||
self.backend = None
|
self.backend = None
|
||||||
self.frontend = None
|
self.frontends = []
|
||||||
|
|
||||||
def parse_options(self):
|
def parse_options(self):
|
||||||
parser = optparse.OptionParser(version='Mopidy %s' % get_version())
|
parser = optparse.OptionParser(version='Mopidy %s' % get_version())
|
||||||
@ -28,16 +30,15 @@ class CoreProcess(BaseProcess):
|
|||||||
parser.add_option('-v', '--verbose',
|
parser.add_option('-v', '--verbose',
|
||||||
action='store_const', const=2, dest='verbosity_level',
|
action='store_const', const=2, dest='verbosity_level',
|
||||||
help='more output (debug level)')
|
help='more output (debug level)')
|
||||||
parser.add_option('--dump',
|
parser.add_option('--save-debug-log',
|
||||||
action='store_true', dest='dump',
|
action='store_true', dest='save_debug_log',
|
||||||
help='dump debug log to file')
|
help='save debug log to "./mopidy.log"')
|
||||||
parser.add_option('--list-settings',
|
parser.add_option('--list-settings',
|
||||||
action='callback', callback=list_settings_optparse_callback,
|
action='callback', callback=list_settings_optparse_callback,
|
||||||
help='list current settings')
|
help='list current settings')
|
||||||
return parser.parse_args()[0]
|
return parser.parse_args()[0]
|
||||||
|
|
||||||
def run_inside_try(self):
|
def run_inside_try(self):
|
||||||
logger.info(u'-- Starting Mopidy --')
|
|
||||||
self.setup()
|
self.setup()
|
||||||
while True:
|
while True:
|
||||||
message = self.core_queue.get()
|
message = self.core_queue.get()
|
||||||
@ -46,12 +47,14 @@ class CoreProcess(BaseProcess):
|
|||||||
def setup(self):
|
def setup(self):
|
||||||
self.setup_logging()
|
self.setup_logging()
|
||||||
self.setup_settings()
|
self.setup_settings()
|
||||||
self.output_queue = self.setup_output(self.core_queue)
|
self.output = self.setup_output(self.core_queue)
|
||||||
self.backend = self.setup_backend(self.core_queue, self.output_queue)
|
self.backend = self.setup_backend(self.core_queue, self.output)
|
||||||
self.frontend = self.setup_frontend(self.core_queue, self.backend)
|
self.frontends = self.setup_frontends(self.core_queue, self.backend)
|
||||||
|
|
||||||
def setup_logging(self):
|
def setup_logging(self):
|
||||||
setup_logging(self.options.verbosity_level, self.options.dump)
|
setup_logging(self.options.verbosity_level,
|
||||||
|
self.options.save_debug_log)
|
||||||
|
logger.info(u'-- Starting Mopidy --')
|
||||||
|
|
||||||
def setup_settings(self):
|
def setup_settings(self):
|
||||||
get_or_create_folder('~/.mopidy/')
|
get_or_create_folder('~/.mopidy/')
|
||||||
@ -59,27 +62,32 @@ class CoreProcess(BaseProcess):
|
|||||||
settings.validate()
|
settings.validate()
|
||||||
|
|
||||||
def setup_output(self, core_queue):
|
def setup_output(self, core_queue):
|
||||||
output_queue = multiprocessing.Queue()
|
output = get_class(settings.OUTPUT)(core_queue)
|
||||||
get_class(settings.OUTPUT)(core_queue, output_queue)
|
output.start()
|
||||||
return output_queue
|
return output
|
||||||
|
|
||||||
def setup_backend(self, core_queue, output_queue):
|
def setup_backend(self, core_queue, output):
|
||||||
return get_class(settings.BACKENDS[0])(core_queue, output_queue)
|
return get_class(settings.BACKENDS[0])(core_queue, output)
|
||||||
|
|
||||||
def setup_frontend(self, core_queue, backend):
|
def setup_frontends(self, core_queue, backend):
|
||||||
frontend = get_class(settings.FRONTENDS[0])()
|
frontends = []
|
||||||
frontend.start_server(core_queue)
|
for frontend_class_name in settings.FRONTENDS:
|
||||||
frontend.create_dispatcher(backend)
|
try:
|
||||||
return frontend
|
frontend = get_class(frontend_class_name)(core_queue, backend)
|
||||||
|
frontend.start()
|
||||||
|
frontends.append(frontend)
|
||||||
|
except OptionalDependencyError as e:
|
||||||
|
logger.info(u'Disabled: %s (%s)', frontend_class_name, e)
|
||||||
|
return frontends
|
||||||
|
|
||||||
def process_message(self, message):
|
def process_message(self, message):
|
||||||
if message.get('to') == 'output':
|
if message.get('to') == 'core':
|
||||||
self.output_queue.put(message)
|
self.process_message_to_core(message)
|
||||||
elif message['command'] == 'mpd_request':
|
elif message.get('to') == 'output':
|
||||||
response = self.frontend.dispatcher.handle_request(
|
self.output.process_message(message)
|
||||||
message['request'])
|
elif message.get('to') == 'frontend':
|
||||||
connection = unpickle_connection(message['reply_to'])
|
for frontend in self.frontends:
|
||||||
connection.send(response)
|
frontend.process_message(message)
|
||||||
elif message['command'] == 'end_of_track':
|
elif message['command'] == 'end_of_track':
|
||||||
self.backend.playback.on_end_of_track()
|
self.backend.playback.on_end_of_track()
|
||||||
elif message['command'] == 'stop_playback':
|
elif message['command'] == 'stop_playback':
|
||||||
@ -88,3 +96,12 @@ class CoreProcess(BaseProcess):
|
|||||||
self.backend.stored_playlists.playlists = message['playlists']
|
self.backend.stored_playlists.playlists = message['playlists']
|
||||||
else:
|
else:
|
||||||
logger.warning(u'Cannot handle message: %s', message)
|
logger.warning(u'Cannot handle message: %s', message)
|
||||||
|
|
||||||
|
def process_message_to_core(self, message):
|
||||||
|
assert message['to'] == 'core', u'Message recipient must be "core".'
|
||||||
|
if message['command'] == 'exit':
|
||||||
|
if message['reason'] is not None:
|
||||||
|
logger.info(u'Exiting (%s)', message['reason'])
|
||||||
|
sys.exit(message['status'])
|
||||||
|
else:
|
||||||
|
logger.warning(u'Cannot handle message: %s', message)
|
||||||
|
|||||||
30
mopidy/frontends/base.py
Normal file
30
mopidy/frontends/base.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
class BaseFrontend(object):
|
||||||
|
"""
|
||||||
|
Base class for frontends.
|
||||||
|
|
||||||
|
:param core_queue: queue for messaging the core
|
||||||
|
:type core_queue: :class:`multiprocessing.Queue`
|
||||||
|
:param backend: the backend
|
||||||
|
:type backend: :class:`mopidy.backends.base.BaseBackend`
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, core_queue, backend):
|
||||||
|
self.core_queue = core_queue
|
||||||
|
self.backend = backend
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Start the frontend."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def destroy(self):
|
||||||
|
"""Destroy the frontend."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def process_message(self, message):
|
||||||
|
"""
|
||||||
|
Process messages for the frontend.
|
||||||
|
|
||||||
|
:param message: the message
|
||||||
|
:type message: dict
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
139
mopidy/frontends/lastfm.py
Normal file
139
mopidy/frontends/lastfm.py
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import logging
|
||||||
|
import multiprocessing
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pylast
|
||||||
|
except ImportError as e:
|
||||||
|
from mopidy import OptionalDependencyError
|
||||||
|
raise OptionalDependencyError(e)
|
||||||
|
|
||||||
|
from mopidy import get_version, settings, SettingsError
|
||||||
|
from mopidy.frontends.base import BaseFrontend
|
||||||
|
from mopidy.utils.process import BaseThread
|
||||||
|
|
||||||
|
logger = logging.getLogger('mopidy.frontends.lastfm')
|
||||||
|
|
||||||
|
CLIENT_ID = u'mop'
|
||||||
|
CLIENT_VERSION = get_version()
|
||||||
|
|
||||||
|
# pylast raises UnicodeEncodeError on conversion from unicode objects to
|
||||||
|
# ascii-encoded bytestrings, so we explicitly encode as utf-8 before passing
|
||||||
|
# strings to pylast.
|
||||||
|
ENCODING = u'utf-8'
|
||||||
|
|
||||||
|
class LastfmFrontend(BaseFrontend):
|
||||||
|
"""
|
||||||
|
Frontend which scrobbles the music you play to your `Last.fm
|
||||||
|
<http://www.last.fm>`_ profile.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This frontend requires a free user account at Last.fm.
|
||||||
|
|
||||||
|
**Dependencies:**
|
||||||
|
|
||||||
|
- `pylast <http://code.google.com/p/pylast/>`_ >= 0.4.30
|
||||||
|
|
||||||
|
**Settings:**
|
||||||
|
|
||||||
|
- :attr:`mopidy.settings.LASTFM_USERNAME`
|
||||||
|
- :attr:`mopidy.settings.LASTFM_PASSWORD`
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(LastfmFrontend, self).__init__(*args, **kwargs)
|
||||||
|
(self.connection, other_end) = multiprocessing.Pipe()
|
||||||
|
self.thread = LastfmFrontendThread(self.core_queue, other_end)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
def destroy(self):
|
||||||
|
self.thread.destroy()
|
||||||
|
|
||||||
|
def process_message(self, message):
|
||||||
|
self.connection.send(message)
|
||||||
|
|
||||||
|
|
||||||
|
class LastfmFrontendThread(BaseThread):
|
||||||
|
def __init__(self, core_queue, connection):
|
||||||
|
super(LastfmFrontendThread, self).__init__(core_queue)
|
||||||
|
self.name = u'LastfmFrontendThread'
|
||||||
|
self.connection = connection
|
||||||
|
self.lastfm = None
|
||||||
|
self.scrobbler = None
|
||||||
|
self.last_start_time = None
|
||||||
|
|
||||||
|
def run_inside_try(self):
|
||||||
|
self.setup()
|
||||||
|
while True:
|
||||||
|
self.connection.poll(None)
|
||||||
|
message = self.connection.recv()
|
||||||
|
self.process_message(message)
|
||||||
|
|
||||||
|
def setup(self):
|
||||||
|
try:
|
||||||
|
username = settings.LASTFM_USERNAME
|
||||||
|
password_hash = pylast.md5(settings.LASTFM_PASSWORD)
|
||||||
|
self.lastfm = pylast.get_lastfm_network(
|
||||||
|
username=username, password_hash=password_hash)
|
||||||
|
self.scrobbler = self.lastfm.get_scrobbler(
|
||||||
|
CLIENT_ID, CLIENT_VERSION)
|
||||||
|
logger.info(u'Connected to Last.fm')
|
||||||
|
except SettingsError as e:
|
||||||
|
logger.info(u'Last.fm scrobbler not started')
|
||||||
|
logger.debug(u'Last.fm settings error: %s', e)
|
||||||
|
except (pylast.WSError, socket.error) as e:
|
||||||
|
logger.error(u'Last.fm connection error: %s', e)
|
||||||
|
|
||||||
|
def process_message(self, message):
|
||||||
|
if message['command'] == 'started_playing':
|
||||||
|
self.started_playing(message['track'])
|
||||||
|
elif message['command'] == 'stopped_playing':
|
||||||
|
self.stopped_playing(message['track'], message['stop_position'])
|
||||||
|
else:
|
||||||
|
pass # Ignore commands for other frontends
|
||||||
|
|
||||||
|
def started_playing(self, track):
|
||||||
|
artists = ', '.join([a.name for a in track.artists])
|
||||||
|
duration = track.length // 1000
|
||||||
|
self.last_start_time = int(time.time())
|
||||||
|
logger.debug(u'Now playing track: %s - %s', artists, track.name)
|
||||||
|
try:
|
||||||
|
self.scrobbler.report_now_playing(
|
||||||
|
artists.encode(ENCODING),
|
||||||
|
track.name.encode(ENCODING),
|
||||||
|
album=track.album.name.encode(ENCODING),
|
||||||
|
duration=duration,
|
||||||
|
track_number=track.track_no)
|
||||||
|
except (pylast.ScrobblingError, socket.error) as e:
|
||||||
|
logger.warning(u'Last.fm now playing error: %s', e)
|
||||||
|
|
||||||
|
def stopped_playing(self, track, stop_position):
|
||||||
|
artists = ', '.join([a.name for a in track.artists])
|
||||||
|
duration = track.length // 1000
|
||||||
|
stop_position = stop_position // 1000
|
||||||
|
if duration < 30:
|
||||||
|
logger.debug(u'Track too short to scrobble. (30s)')
|
||||||
|
return
|
||||||
|
if stop_position < duration // 2 and stop_position < 240:
|
||||||
|
logger.debug(
|
||||||
|
u'Track not played long enough to scrobble. (50% or 240s)')
|
||||||
|
return
|
||||||
|
if self.last_start_time is None:
|
||||||
|
self.last_start_time = int(time.time()) - duration
|
||||||
|
logger.debug(u'Scrobbling track: %s - %s', artists, track.name)
|
||||||
|
try:
|
||||||
|
self.scrobbler.scrobble(
|
||||||
|
artists.encode(ENCODING),
|
||||||
|
track.name.encode(ENCODING),
|
||||||
|
time_started=self.last_start_time,
|
||||||
|
source=pylast.SCROBBLE_SOURCE_USER,
|
||||||
|
mode=pylast.SCROBBLE_MODE_PLAYED,
|
||||||
|
duration=duration,
|
||||||
|
album=track.album.name.encode(ENCODING),
|
||||||
|
track_number=track.track_no)
|
||||||
|
except (pylast.ScrobblingError, socket.error) as e:
|
||||||
|
logger.warning(u'Last.fm scrobbling error: %s', e)
|
||||||
@ -1,7 +1,13 @@
|
|||||||
from mopidy.frontends.mpd.dispatcher import MpdDispatcher
|
import logging
|
||||||
from mopidy.frontends.mpd.process import MpdProcess
|
|
||||||
|
|
||||||
class MpdFrontend(object):
|
from mopidy.frontends.base import BaseFrontend
|
||||||
|
from mopidy.frontends.mpd.dispatcher import MpdDispatcher
|
||||||
|
from mopidy.frontends.mpd.thread import MpdThread
|
||||||
|
from mopidy.utils.process import unpickle_connection
|
||||||
|
|
||||||
|
logger = logging.getLogger('mopidy.frontends.mpd')
|
||||||
|
|
||||||
|
class MpdFrontend(BaseFrontend):
|
||||||
"""
|
"""
|
||||||
The MPD frontend.
|
The MPD frontend.
|
||||||
|
|
||||||
@ -11,27 +17,32 @@ class MpdFrontend(object):
|
|||||||
- :attr:`mopidy.settings.MPD_SERVER_PORT`
|
- :attr:`mopidy.settings.MPD_SERVER_PORT`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, *args, **kwargs):
|
||||||
self.process = None
|
super(MpdFrontend, self).__init__(*args, **kwargs)
|
||||||
self.dispatcher = None
|
self.thread = None
|
||||||
|
self.dispatcher = MpdDispatcher(self.backend)
|
||||||
|
|
||||||
def start_server(self, core_queue):
|
def start(self):
|
||||||
"""
|
"""Starts the MPD server."""
|
||||||
Starts the MPD server.
|
self.thread = MpdThread(self.core_queue)
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
:param core_queue: the core queue
|
def destroy(self):
|
||||||
:type core_queue: :class:`multiprocessing.Queue`
|
"""Destroys the MPD server."""
|
||||||
"""
|
self.thread.destroy()
|
||||||
self.process = MpdProcess(core_queue)
|
|
||||||
self.process.start()
|
|
||||||
|
|
||||||
def create_dispatcher(self, backend):
|
def process_message(self, message):
|
||||||
"""
|
"""
|
||||||
Creates a dispatcher for MPD requests.
|
Processes messages with the MPD frontend as destination.
|
||||||
|
|
||||||
:param backend: the backend
|
:param message: the message
|
||||||
:type backend: :class:`mopidy.backends.base.BaseBackend`
|
:type message: dict
|
||||||
:rtype: :class:`mopidy.frontends.mpd.dispatcher.MpdDispatcher`
|
|
||||||
"""
|
"""
|
||||||
self.dispatcher = MpdDispatcher(backend)
|
assert message['to'] == 'frontend', \
|
||||||
return self.dispatcher
|
u'Message recipient must be "frontend".'
|
||||||
|
if message['command'] == 'mpd_request':
|
||||||
|
response = self.dispatcher.handle_request(message['request'])
|
||||||
|
connection = unpickle_connection(message['reply_to'])
|
||||||
|
connection.send(response)
|
||||||
|
else:
|
||||||
|
pass # Ignore messages for other frontends
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
import asyncore
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from mopidy.frontends.mpd.server import MpdServer
|
|
||||||
from mopidy.utils.process import BaseProcess
|
|
||||||
|
|
||||||
logger = logging.getLogger('mopidy.frontends.mpd.process')
|
|
||||||
|
|
||||||
class MpdProcess(BaseProcess):
|
|
||||||
def __init__(self, core_queue):
|
|
||||||
super(MpdProcess, self).__init__(name='MpdProcess')
|
|
||||||
self.core_queue = core_queue
|
|
||||||
|
|
||||||
def run_inside_try(self):
|
|
||||||
logger.debug(u'Starting MPD server process')
|
|
||||||
server = MpdServer(self.core_queue)
|
|
||||||
server.start()
|
|
||||||
asyncore.loop()
|
|
||||||
@ -11,14 +11,19 @@ def add(frontend, uri):
|
|||||||
|
|
||||||
Adds the file ``URI`` to the playlist (directories add recursively).
|
Adds the file ``URI`` to the playlist (directories add recursively).
|
||||||
``URI`` can also be a single file.
|
``URI`` can also be a single file.
|
||||||
|
|
||||||
|
*Clarifications:*
|
||||||
|
|
||||||
|
- ``add ""`` should add all tracks in the library to the current playlist.
|
||||||
"""
|
"""
|
||||||
|
if not uri:
|
||||||
|
return
|
||||||
for handler_prefix in frontend.backend.uri_handlers:
|
for handler_prefix in frontend.backend.uri_handlers:
|
||||||
if uri.startswith(handler_prefix):
|
if uri.startswith(handler_prefix):
|
||||||
track = frontend.backend.library.lookup(uri)
|
track = frontend.backend.library.lookup(uri)
|
||||||
if track is not None:
|
if track is not None:
|
||||||
frontend.backend.current_playlist.add(track)
|
frontend.backend.current_playlist.add(track)
|
||||||
return
|
return
|
||||||
|
|
||||||
raise MpdNoExistError(
|
raise MpdNoExistError(
|
||||||
u'directory or file not found', command=u'add')
|
u'directory or file not found', command=u'add')
|
||||||
|
|
||||||
@ -36,7 +41,13 @@ def addid(frontend, uri, songpos=None):
|
|||||||
addid "foo.mp3"
|
addid "foo.mp3"
|
||||||
Id: 999
|
Id: 999
|
||||||
OK
|
OK
|
||||||
|
|
||||||
|
*Clarifications:*
|
||||||
|
|
||||||
|
- ``addid ""`` should return an error.
|
||||||
"""
|
"""
|
||||||
|
if not uri:
|
||||||
|
raise MpdNoExistError(u'No such song', command=u'addid')
|
||||||
if songpos is not None:
|
if songpos is not None:
|
||||||
songpos = int(songpos)
|
songpos = int(songpos)
|
||||||
track = frontend.backend.library.lookup(uri)
|
track = frontend.backend.library.lookup(uri)
|
||||||
@ -44,7 +55,8 @@ def addid(frontend, uri, songpos=None):
|
|||||||
raise MpdNoExistError(u'No such song', command=u'addid')
|
raise MpdNoExistError(u'No such song', command=u'addid')
|
||||||
if songpos and songpos > len(frontend.backend.current_playlist.tracks):
|
if songpos and songpos > len(frontend.backend.current_playlist.tracks):
|
||||||
raise MpdArgError(u'Bad song index', command=u'addid')
|
raise MpdArgError(u'Bad song index', command=u'addid')
|
||||||
cp_track = frontend.backend.current_playlist.add(track, at_position=songpos)
|
cp_track = frontend.backend.current_playlist.add(track,
|
||||||
|
at_position=songpos)
|
||||||
return ('Id', cp_track[0])
|
return ('Id', cp_track[0])
|
||||||
|
|
||||||
@handle_pattern(r'^delete "(?P<start>\d+):(?P<end>\d+)*"$')
|
@handle_pattern(r'^delete "(?P<start>\d+):(?P<end>\d+)*"$')
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
|
|
||||||
from mopidy.frontends.mpd.protocol import handle_pattern, stored_playlists
|
from mopidy.frontends.mpd.protocol import handle_pattern, stored_playlists
|
||||||
from mopidy.frontends.mpd.exceptions import MpdNotImplemented
|
from mopidy.frontends.mpd.exceptions import MpdArgError, MpdNotImplemented
|
||||||
|
|
||||||
def _build_query(mpd_query):
|
def _build_query(mpd_query):
|
||||||
"""
|
"""
|
||||||
@ -81,13 +82,9 @@ def findadd(frontend, query):
|
|||||||
# TODO Add result to current playlist
|
# TODO Add result to current playlist
|
||||||
#result = frontend.find(query)
|
#result = frontend.find(query)
|
||||||
|
|
||||||
@handle_pattern(r'^list (?P<field>[Aa]rtist)$')
|
@handle_pattern(r'^list "?(?P<field>([Aa]rtist|[Aa]lbum|[Dd]ate|[Gg]enre))"?'
|
||||||
@handle_pattern(r'^list "(?P<field>[Aa]rtist)"$')
|
'( (?P<mpd_query>.*))?$')
|
||||||
@handle_pattern(r'^list (?P<field>album( artist)?)'
|
def list_(frontend, field, mpd_query=None):
|
||||||
'( "(?P<artist>[^"]+)")*$')
|
|
||||||
@handle_pattern(r'^list "(?P<field>album(" "artist)?)"'
|
|
||||||
'( "(?P<artist>[^"]+)")*$')
|
|
||||||
def list_(frontend, field, artist=None):
|
|
||||||
"""
|
"""
|
||||||
*musicpd.org, music database section:*
|
*musicpd.org, music database section:*
|
||||||
|
|
||||||
@ -101,22 +98,70 @@ def list_(frontend, field, artist=None):
|
|||||||
|
|
||||||
This filters the result list by an artist.
|
This filters the result list by an artist.
|
||||||
|
|
||||||
|
*Clarifications:*
|
||||||
|
|
||||||
|
The musicpd.org documentation for ``list`` is far from complete. The
|
||||||
|
command also supports the following variant:
|
||||||
|
|
||||||
|
``list {TYPE} {QUERY}``
|
||||||
|
|
||||||
|
Where ``QUERY`` applies to all ``TYPE``. ``QUERY`` is one or more pairs
|
||||||
|
of a field name and a value. If the ``QUERY`` consists of more than one
|
||||||
|
pair, the pairs are AND-ed together to find the result. Examples of
|
||||||
|
valid queries and what they should return:
|
||||||
|
|
||||||
|
``list "artist" "artist" "ABBA"``
|
||||||
|
List artists where the artist name is "ABBA". Response::
|
||||||
|
|
||||||
|
Artist: ABBA
|
||||||
|
OK
|
||||||
|
|
||||||
|
``list "album" "artist" "ABBA"``
|
||||||
|
Lists albums where the artist name is "ABBA". Response::
|
||||||
|
|
||||||
|
Album: More ABBA Gold: More ABBA Hits
|
||||||
|
Album: Absolute More Christmas
|
||||||
|
Album: Gold: Greatest Hits
|
||||||
|
OK
|
||||||
|
|
||||||
|
``list "artist" "album" "Gold: Greatest Hits"``
|
||||||
|
Lists artists where the album name is "Gold: Greatest Hits".
|
||||||
|
Response::
|
||||||
|
|
||||||
|
Artist: ABBA
|
||||||
|
OK
|
||||||
|
|
||||||
|
``list "artist" "artist" "ABBA" "artist" "TLC"``
|
||||||
|
Lists artists where the artist name is "ABBA" *and* "TLC". Should
|
||||||
|
never match anything. Response::
|
||||||
|
|
||||||
|
OK
|
||||||
|
|
||||||
|
``list "date" "artist" "ABBA"``
|
||||||
|
Lists dates where artist name is "ABBA". Response::
|
||||||
|
|
||||||
|
Date:
|
||||||
|
Date: 1992
|
||||||
|
Date: 1993
|
||||||
|
OK
|
||||||
|
|
||||||
|
``list "date" "artist" "ABBA" "album" "Gold: Greatest Hits"``
|
||||||
|
Lists dates where artist name is "ABBA" and album name is "Gold:
|
||||||
|
Greatest Hits". Response::
|
||||||
|
|
||||||
|
Date: 1992
|
||||||
|
OK
|
||||||
|
|
||||||
|
``list "genre" "artist" "The Rolling Stones"``
|
||||||
|
Lists genres where artist name is "The Rolling Stones". Response::
|
||||||
|
|
||||||
|
Genre:
|
||||||
|
Genre: Rock
|
||||||
|
OK
|
||||||
|
|
||||||
*GMPC:*
|
*GMPC:*
|
||||||
|
|
||||||
- does not add quotes around the field argument.
|
- does not add quotes around the field argument.
|
||||||
- asks for "list artist" to get available artists and will not query
|
|
||||||
for artist/album information if this is not retrived
|
|
||||||
- asks for multiple fields, i.e.::
|
|
||||||
|
|
||||||
list album artist "an artist name"
|
|
||||||
|
|
||||||
returns the albums available for the asked artist::
|
|
||||||
|
|
||||||
list album artist "Tiesto"
|
|
||||||
Album: Radio Trance Vol 4-Promo-CD
|
|
||||||
Album: Ur A Tear in the Open CDR
|
|
||||||
Album: Simple Trance 2004 Step One
|
|
||||||
Album: In Concert 05-10-2003
|
|
||||||
|
|
||||||
*ncmpc:*
|
*ncmpc:*
|
||||||
|
|
||||||
@ -124,31 +169,70 @@ def list_(frontend, field, artist=None):
|
|||||||
- capitalizes the field argument.
|
- capitalizes the field argument.
|
||||||
"""
|
"""
|
||||||
field = field.lower()
|
field = field.lower()
|
||||||
|
query = _list_build_query(field, mpd_query)
|
||||||
if field == u'artist':
|
if field == u'artist':
|
||||||
return _list_artist(frontend)
|
return _list_artist(frontend, query)
|
||||||
elif field == u'album artist':
|
elif field == u'album':
|
||||||
return _list_album_artist(frontend, artist)
|
return _list_album(frontend, query)
|
||||||
# TODO More to implement
|
elif field == u'date':
|
||||||
|
return _list_date(frontend, query)
|
||||||
|
elif field == u'genre':
|
||||||
|
pass # TODO We don't have genre in our internal data structures yet
|
||||||
|
|
||||||
def _list_artist(frontend):
|
def _list_build_query(field, mpd_query):
|
||||||
"""
|
"""Converts a ``list`` query to a Mopidy query."""
|
||||||
Since we don't know exactly all available artists, we respond with
|
if mpd_query is None:
|
||||||
the artists we know for sure, which is all artists in our stored playlists.
|
return {}
|
||||||
"""
|
# shlex does not seem to be friends with unicode objects
|
||||||
|
tokens = shlex.split(mpd_query.encode('utf-8'))
|
||||||
|
tokens = [t.decode('utf-8') for t in tokens]
|
||||||
|
if len(tokens) == 1:
|
||||||
|
if field == u'album':
|
||||||
|
return {'artist': [tokens[0]]}
|
||||||
|
else:
|
||||||
|
raise MpdArgError(
|
||||||
|
u'should be "Album" for 3 arguments', command=u'list')
|
||||||
|
elif len(tokens) % 2 == 0:
|
||||||
|
query = {}
|
||||||
|
while tokens:
|
||||||
|
key = tokens[0].lower()
|
||||||
|
key = str(key) # Needed for kwargs keys on OS X and Windows
|
||||||
|
value = tokens[1]
|
||||||
|
tokens = tokens[2:]
|
||||||
|
if key not in (u'artist', u'album', u'date', u'genre'):
|
||||||
|
raise MpdArgError(u'not able to parse args', command=u'list')
|
||||||
|
if key in query:
|
||||||
|
query[key].append(value)
|
||||||
|
else:
|
||||||
|
query[key] = [value]
|
||||||
|
return query
|
||||||
|
else:
|
||||||
|
raise MpdArgError(u'not able to parse args', command=u'list')
|
||||||
|
|
||||||
|
def _list_artist(frontend, query):
|
||||||
artists = set()
|
artists = set()
|
||||||
for playlist in frontend.backend.stored_playlists.playlists:
|
playlist = frontend.backend.library.find_exact(**query)
|
||||||
for track in playlist.tracks:
|
for track in playlist.tracks:
|
||||||
for artist in track.artists:
|
for artist in track.artists:
|
||||||
artists.add((u'Artist', artist.name))
|
artists.add((u'Artist', artist.name))
|
||||||
return artists
|
return artists
|
||||||
|
|
||||||
def _list_album_artist(frontend, artist):
|
def _list_album(frontend, query):
|
||||||
playlist = frontend.backend.library.find_exact(artist=[artist])
|
|
||||||
albums = set()
|
albums = set()
|
||||||
|
playlist = frontend.backend.library.find_exact(**query)
|
||||||
for track in playlist.tracks:
|
for track in playlist.tracks:
|
||||||
albums.add((u'Album', track.album.name))
|
if track.album is not None:
|
||||||
|
albums.add((u'Album', track.album.name))
|
||||||
return albums
|
return albums
|
||||||
|
|
||||||
|
def _list_date(frontend, query):
|
||||||
|
dates = set()
|
||||||
|
playlist = frontend.backend.library.find_exact(**query)
|
||||||
|
for track in playlist.tracks:
|
||||||
|
if track.date is not None:
|
||||||
|
dates.add((u'Date', track.date.strftime('%Y-%m-%d')))
|
||||||
|
return dates
|
||||||
|
|
||||||
@handle_pattern(r'^listall "(?P<uri>[^"]+)"')
|
@handle_pattern(r'^listall "(?P<uri>[^"]+)"')
|
||||||
def listall(frontend, uri):
|
def listall(frontend, uri):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -138,6 +138,10 @@ def playid(frontend, cpid):
|
|||||||
at the first track.
|
at the first track.
|
||||||
"""
|
"""
|
||||||
cpid = int(cpid)
|
cpid = int(cpid)
|
||||||
|
paused = (frontend.backend.playback.state ==
|
||||||
|
frontend.backend.playback.PAUSED)
|
||||||
|
if cpid == -1 and paused:
|
||||||
|
return frontend.backend.playback.resume()
|
||||||
try:
|
try:
|
||||||
if cpid == -1:
|
if cpid == -1:
|
||||||
cp_track = _get_cp_track_for_play_minus_one(frontend)
|
cp_track = _get_cp_track_for_play_minus_one(frontend)
|
||||||
|
|||||||
@ -24,19 +24,23 @@ class MpdServer(asyncore.dispatcher):
|
|||||||
try:
|
try:
|
||||||
if socket.has_ipv6:
|
if socket.has_ipv6:
|
||||||
self.create_socket(socket.AF_INET6, socket.SOCK_STREAM)
|
self.create_socket(socket.AF_INET6, socket.SOCK_STREAM)
|
||||||
|
# Explicitly configure socket to work for both IPv4 and IPv6
|
||||||
|
self.socket.setsockopt(
|
||||||
|
socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
|
||||||
else:
|
else:
|
||||||
self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
|
self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
self.set_reuse_addr()
|
self.set_reuse_addr()
|
||||||
hostname = self._format_hostname(settings.MPD_SERVER_HOSTNAME)
|
hostname = self._format_hostname(settings.MPD_SERVER_HOSTNAME)
|
||||||
port = settings.MPD_SERVER_PORT
|
port = settings.MPD_SERVER_PORT
|
||||||
logger.debug(u'Binding to [%s]:%s', hostname, port)
|
logger.debug(u'MPD server is binding to [%s]:%s', hostname, port)
|
||||||
self.bind((hostname, port))
|
self.bind((hostname, port))
|
||||||
self.listen(1)
|
self.listen(1)
|
||||||
logger.info(u'MPD server running at [%s]:%s',
|
logger.info(u'MPD server running at [%s]:%s',
|
||||||
self._format_hostname(settings.MPD_SERVER_HOSTNAME),
|
self._format_hostname(settings.MPD_SERVER_HOSTNAME),
|
||||||
settings.MPD_SERVER_PORT)
|
settings.MPD_SERVER_PORT)
|
||||||
except IOError, e:
|
except IOError, e:
|
||||||
sys.exit('MPD server startup failed: %s' % e)
|
logger.error('MPD server startup failed: %s' % e)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
def handle_accept(self):
|
def handle_accept(self):
|
||||||
"""Handle new client connection."""
|
"""Handle new client connection."""
|
||||||
|
|||||||
@ -48,6 +48,7 @@ class MpdSession(asynchat.async_chat):
|
|||||||
"""Handle request by sending it to the MPD frontend."""
|
"""Handle request by sending it to the MPD frontend."""
|
||||||
my_end, other_end = multiprocessing.Pipe()
|
my_end, other_end = multiprocessing.Pipe()
|
||||||
self.core_queue.put({
|
self.core_queue.put({
|
||||||
|
'to': 'frontend',
|
||||||
'command': 'mpd_request',
|
'command': 'mpd_request',
|
||||||
'request': request,
|
'request': request,
|
||||||
'reply_to': pickle_connection(other_end),
|
'reply_to': pickle_connection(other_end),
|
||||||
|
|||||||
18
mopidy/frontends/mpd/thread.py
Normal file
18
mopidy/frontends/mpd/thread.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import asyncore
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mopidy.frontends.mpd.server import MpdServer
|
||||||
|
from mopidy.utils.process import BaseThread
|
||||||
|
|
||||||
|
logger = logging.getLogger('mopidy.frontends.mpd.thread')
|
||||||
|
|
||||||
|
class MpdThread(BaseThread):
|
||||||
|
def __init__(self, core_queue):
|
||||||
|
super(MpdThread, self).__init__(core_queue)
|
||||||
|
self.name = u'MpdThread'
|
||||||
|
|
||||||
|
def run_inside_try(self):
|
||||||
|
logger.debug(u'Starting MPD server thread')
|
||||||
|
server = MpdServer(self.core_queue)
|
||||||
|
server.start()
|
||||||
|
asyncore.loop()
|
||||||
@ -1,7 +1,4 @@
|
|||||||
import multiprocessing
|
|
||||||
|
|
||||||
from mopidy.mixers import BaseMixer
|
from mopidy.mixers import BaseMixer
|
||||||
from mopidy.utils.process import pickle_connection
|
|
||||||
|
|
||||||
class GStreamerSoftwareMixer(BaseMixer):
|
class GStreamerSoftwareMixer(BaseMixer):
|
||||||
"""Mixer which uses GStreamer to control volume in software."""
|
"""Mixer which uses GStreamer to control volume in software."""
|
||||||
@ -10,16 +7,7 @@ class GStreamerSoftwareMixer(BaseMixer):
|
|||||||
super(GStreamerSoftwareMixer, self).__init__(*args, **kwargs)
|
super(GStreamerSoftwareMixer, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
def _get_volume(self):
|
def _get_volume(self):
|
||||||
my_end, other_end = multiprocessing.Pipe()
|
return self.backend.output.get_volume()
|
||||||
self.backend.output_queue.put({
|
|
||||||
'command': 'get_volume',
|
|
||||||
'reply_to': pickle_connection(other_end),
|
|
||||||
})
|
|
||||||
my_end.poll(None)
|
|
||||||
return my_end.recv()
|
|
||||||
|
|
||||||
def _set_volume(self, volume):
|
def _set_volume(self, volume):
|
||||||
self.backend.output_queue.put({
|
self.backend.output.set_volume(volume)
|
||||||
'command': 'set_volume',
|
|
||||||
'volume': volume,
|
|
||||||
})
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from multiprocessing import Pipe
|
|||||||
|
|
||||||
from mopidy import settings
|
from mopidy import settings
|
||||||
from mopidy.mixers import BaseMixer
|
from mopidy.mixers import BaseMixer
|
||||||
from mopidy.utils.process import BaseProcess
|
from mopidy.utils.process import BaseThread
|
||||||
|
|
||||||
logger = logging.getLogger('mopidy.mixers.nad')
|
logger = logging.getLogger('mopidy.mixers.nad')
|
||||||
|
|
||||||
@ -50,7 +50,7 @@ class NadMixer(BaseMixer):
|
|||||||
self._pipe.send({'command': 'set_volume', 'volume': volume})
|
self._pipe.send({'command': 'set_volume', 'volume': volume})
|
||||||
|
|
||||||
|
|
||||||
class NadTalker(BaseProcess):
|
class NadTalker(BaseThread):
|
||||||
"""
|
"""
|
||||||
Independent process which does the communication with the NAD device.
|
Independent process which does the communication with the NAD device.
|
||||||
|
|
||||||
|
|||||||
88
mopidy/outputs/base.py
Normal file
88
mopidy/outputs/base.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
class BaseOutput(object):
|
||||||
|
"""
|
||||||
|
Base class for audio outputs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, core_queue):
|
||||||
|
self.core_queue = core_queue
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Start the output."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def destroy(self):
|
||||||
|
"""Destroy the output."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def process_message(self, message):
|
||||||
|
"""Process messages with the output as destination."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def play_uri(self, uri):
|
||||||
|
"""
|
||||||
|
Play URI.
|
||||||
|
|
||||||
|
:param uri: the URI to play
|
||||||
|
:type uri: string
|
||||||
|
:rtype: :class:`True` if successful, else :class:`False`
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def deliver_data(self, capabilities, data):
|
||||||
|
"""
|
||||||
|
Deliver audio data to be played.
|
||||||
|
|
||||||
|
:param capabilities: a GStreamer capabilities string
|
||||||
|
:type capabilities: string
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def end_of_data_stream(self):
|
||||||
|
"""Signal that the last audio data has been delivered."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def get_position(self):
|
||||||
|
"""
|
||||||
|
Get position in milliseconds.
|
||||||
|
|
||||||
|
:rtype: int
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def set_position(self, position):
|
||||||
|
"""
|
||||||
|
Set position in milliseconds.
|
||||||
|
|
||||||
|
:param position: the position in milliseconds
|
||||||
|
:type volume: int
|
||||||
|
:rtype: :class:`True` if successful, else :class:`False`
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def set_state(self, state):
|
||||||
|
"""
|
||||||
|
Set playback state.
|
||||||
|
|
||||||
|
:param state: the state
|
||||||
|
:type state: string
|
||||||
|
:rtype: :class:`True` if successful, else :class:`False`
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def get_volume(self):
|
||||||
|
"""
|
||||||
|
Get volume level for software mixer.
|
||||||
|
|
||||||
|
:rtype: int in range [0..100]
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def set_volume(self, volume):
|
||||||
|
"""
|
||||||
|
Set volume level for software mixer.
|
||||||
|
|
||||||
|
:param volume: the volume in the range [0..100]
|
||||||
|
:type volume: int
|
||||||
|
:rtype: :class:`True` if successful, else :class:`False`
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
76
mopidy/outputs/dummy.py
Normal file
76
mopidy/outputs/dummy.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
from mopidy.outputs.base import BaseOutput
|
||||||
|
|
||||||
|
class DummyOutput(BaseOutput):
|
||||||
|
"""
|
||||||
|
Audio output used for testing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: For testing. :class:`True` if :meth:`start` has been called.
|
||||||
|
start_called = False
|
||||||
|
|
||||||
|
#: For testing. :class:`True` if :meth:`destroy` has been called.
|
||||||
|
destroy_called = False
|
||||||
|
|
||||||
|
#: For testing. Contains all messages :meth:`process_message` has received.
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
#: For testing. Contains the last URI passed to :meth:`play_uri`.
|
||||||
|
uri = None
|
||||||
|
|
||||||
|
#: For testing. Contains the last capabilities passed to
|
||||||
|
#: :meth:`deliver_data`.
|
||||||
|
capabilities = None
|
||||||
|
|
||||||
|
#: For testing. Contains the last data passed to :meth:`deliver_data`.
|
||||||
|
data = None
|
||||||
|
|
||||||
|
#: For testing. :class:`True` if :meth:`end_of_data_stream` has been
|
||||||
|
#: called.
|
||||||
|
end_of_data_stream_called = False
|
||||||
|
|
||||||
|
#: For testing. Contains the current position.
|
||||||
|
position = 0
|
||||||
|
|
||||||
|
#: For testing. Contains the current state.
|
||||||
|
state = 'NULL'
|
||||||
|
|
||||||
|
#: For testing. Contains the current volume.
|
||||||
|
volume = 100
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.start_called = True
|
||||||
|
|
||||||
|
def destroy(self):
|
||||||
|
self.destroy_called = True
|
||||||
|
|
||||||
|
def process_message(self, message):
|
||||||
|
self.messages.append(message)
|
||||||
|
|
||||||
|
def play_uri(self, uri):
|
||||||
|
self.uri = uri
|
||||||
|
return True
|
||||||
|
|
||||||
|
def deliver_data(self, capabilities, data):
|
||||||
|
self.capabilities = capabilities
|
||||||
|
self.data = data
|
||||||
|
|
||||||
|
def end_of_data_stream(self):
|
||||||
|
self.end_of_data_stream_called = True
|
||||||
|
|
||||||
|
def get_position(self):
|
||||||
|
return self.position
|
||||||
|
|
||||||
|
def set_position(self, position):
|
||||||
|
self.position = position
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set_state(self, state):
|
||||||
|
self.state = state
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_volume(self):
|
||||||
|
return self.volume
|
||||||
|
|
||||||
|
def set_volume(self, volume):
|
||||||
|
self.volume = volume
|
||||||
|
return True
|
||||||
@ -6,36 +6,100 @@ pygst.require('0.10')
|
|||||||
import gst
|
import gst
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import multiprocessing
|
||||||
|
|
||||||
from mopidy import settings
|
from mopidy import settings
|
||||||
from mopidy.utils.process import BaseProcess, unpickle_connection
|
from mopidy.outputs.base import BaseOutput
|
||||||
|
from mopidy.utils.process import (BaseThread, pickle_connection,
|
||||||
|
unpickle_connection)
|
||||||
|
|
||||||
logger = logging.getLogger('mopidy.outputs.gstreamer')
|
logger = logging.getLogger('mopidy.outputs.gstreamer')
|
||||||
|
|
||||||
class GStreamerOutput(object):
|
class GStreamerOutput(BaseOutput):
|
||||||
"""
|
"""
|
||||||
Audio output through GStreamer.
|
Audio output through GStreamer.
|
||||||
|
|
||||||
Starts the :class:`GStreamerProcess`.
|
Starts :class:`GStreamerMessagesThread` and :class:`GStreamerPlayerThread`.
|
||||||
|
|
||||||
**Settings:**
|
**Settings:**
|
||||||
|
|
||||||
- :attr:`mopidy.settings.GSTREAMER_AUDIO_SINK`
|
- :attr:`mopidy.settings.GSTREAMER_AUDIO_SINK`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, core_queue, output_queue):
|
def __init__(self, *args, **kwargs):
|
||||||
self.process = GStreamerProcess(core_queue, output_queue)
|
super(GStreamerOutput, self).__init__(*args, **kwargs)
|
||||||
self.process.start()
|
# Start a helper thread that can run the gobject.MainLoop
|
||||||
|
self.messages_thread = GStreamerMessagesThread(self.core_queue)
|
||||||
|
|
||||||
|
# Start a helper thread that can process the output_queue
|
||||||
|
self.output_queue = multiprocessing.Queue()
|
||||||
|
self.player_thread = GStreamerPlayerThread(self.core_queue,
|
||||||
|
self.output_queue)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.messages_thread.start()
|
||||||
|
self.player_thread.start()
|
||||||
|
|
||||||
def destroy(self):
|
def destroy(self):
|
||||||
self.process.terminate()
|
self.messages_thread.destroy()
|
||||||
|
self.player_thread.destroy()
|
||||||
|
|
||||||
class GStreamerMessagesThread(threading.Thread):
|
def process_message(self, message):
|
||||||
def run(self):
|
assert message['to'] == 'output', \
|
||||||
|
u'Message recipient must be "output".'
|
||||||
|
self.output_queue.put(message)
|
||||||
|
|
||||||
|
def _send_recv(self, message):
|
||||||
|
(my_end, other_end) = multiprocessing.Pipe()
|
||||||
|
message['to'] = 'output'
|
||||||
|
message['reply_to'] = pickle_connection(other_end)
|
||||||
|
self.process_message(message)
|
||||||
|
my_end.poll(None)
|
||||||
|
return my_end.recv()
|
||||||
|
|
||||||
|
def _send(self, message):
|
||||||
|
message['to'] = 'output'
|
||||||
|
self.process_message(message)
|
||||||
|
|
||||||
|
def play_uri(self, uri):
|
||||||
|
return self._send_recv({'command': 'play_uri', 'uri': uri})
|
||||||
|
|
||||||
|
def deliver_data(self, capabilities, data):
|
||||||
|
return self._send({
|
||||||
|
'command': 'deliver_data',
|
||||||
|
'caps': capabilities,
|
||||||
|
'data': data,
|
||||||
|
})
|
||||||
|
|
||||||
|
def end_of_data_stream(self):
|
||||||
|
return self._send({'command': 'end_of_data_stream'})
|
||||||
|
|
||||||
|
def get_position(self):
|
||||||
|
return self._send_recv({'command': 'get_position'})
|
||||||
|
|
||||||
|
def set_position(self, position):
|
||||||
|
return self._send_recv({'command': 'set_position', 'position': position})
|
||||||
|
|
||||||
|
def set_state(self, state):
|
||||||
|
return self._send_recv({'command': 'set_state', 'state': state})
|
||||||
|
|
||||||
|
def get_volume(self):
|
||||||
|
return self._send_recv({'command': 'get_volume'})
|
||||||
|
|
||||||
|
def set_volume(self, volume):
|
||||||
|
return self._send_recv({'command': 'set_volume', 'volume': volume})
|
||||||
|
|
||||||
|
|
||||||
|
class GStreamerMessagesThread(BaseThread):
|
||||||
|
def __init__(self, core_queue):
|
||||||
|
super(GStreamerMessagesThread, self).__init__(core_queue)
|
||||||
|
self.name = u'GStreamerMessagesThread'
|
||||||
|
|
||||||
|
def run_inside_try(self):
|
||||||
gobject.MainLoop().run()
|
gobject.MainLoop().run()
|
||||||
|
|
||||||
class GStreamerProcess(BaseProcess):
|
|
||||||
|
class GStreamerPlayerThread(BaseThread):
|
||||||
"""
|
"""
|
||||||
A process for all work related to GStreamer.
|
A process for all work related to GStreamer.
|
||||||
|
|
||||||
@ -48,8 +112,8 @@ class GStreamerProcess(BaseProcess):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, core_queue, output_queue):
|
def __init__(self, core_queue, output_queue):
|
||||||
super(GStreamerProcess, self).__init__(name='GStreamerProcess')
|
super(GStreamerPlayerThread, self).__init__(core_queue)
|
||||||
self.core_queue = core_queue
|
self.name = u'GStreamerPlayerThread'
|
||||||
self.output_queue = output_queue
|
self.output_queue = output_queue
|
||||||
self.gst_pipeline = None
|
self.gst_pipeline = None
|
||||||
|
|
||||||
@ -62,11 +126,6 @@ class GStreamerProcess(BaseProcess):
|
|||||||
def setup(self):
|
def setup(self):
|
||||||
logger.debug(u'Setting up GStreamer pipeline')
|
logger.debug(u'Setting up GStreamer pipeline')
|
||||||
|
|
||||||
# Start a helper thread that can run the gobject.MainLoop
|
|
||||||
messages_thread = GStreamerMessagesThread()
|
|
||||||
messages_thread.daemon = True
|
|
||||||
messages_thread.start()
|
|
||||||
|
|
||||||
self.gst_pipeline = gst.parse_launch(' ! '.join([
|
self.gst_pipeline = gst.parse_launch(' ! '.join([
|
||||||
'audioconvert name=convert',
|
'audioconvert name=convert',
|
||||||
'volume name=volume',
|
'volume name=volume',
|
||||||
@ -80,7 +139,16 @@ class GStreamerProcess(BaseProcess):
|
|||||||
uri_bin.connect('pad-added', self.process_new_pad, pad)
|
uri_bin.connect('pad-added', self.process_new_pad, pad)
|
||||||
self.gst_pipeline.add(uri_bin)
|
self.gst_pipeline.add(uri_bin)
|
||||||
else:
|
else:
|
||||||
app_src = gst.element_factory_make('appsrc', 'src')
|
app_src = gst.element_factory_make('appsrc', 'appsrc')
|
||||||
|
app_src_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""")
|
||||||
|
app_src.set_property('caps', app_src_caps)
|
||||||
self.gst_pipeline.add(app_src)
|
self.gst_pipeline.add(app_src)
|
||||||
app_src.get_pad('src').link(pad)
|
app_src.get_pad('src').link(pad)
|
||||||
|
|
||||||
@ -111,7 +179,9 @@ class GStreamerProcess(BaseProcess):
|
|||||||
connection = unpickle_connection(message['reply_to'])
|
connection = unpickle_connection(message['reply_to'])
|
||||||
connection.send(volume)
|
connection.send(volume)
|
||||||
elif message['command'] == 'set_volume':
|
elif message['command'] == 'set_volume':
|
||||||
self.set_volume(message['volume'])
|
response = self.set_volume(message['volume'])
|
||||||
|
connection = unpickle_connection(message['reply_to'])
|
||||||
|
connection.send(response)
|
||||||
elif message['command'] == 'set_position':
|
elif message['command'] == 'set_position':
|
||||||
response = self.set_position(message['position'])
|
response = self.set_position(message['position'])
|
||||||
connection = unpickle_connection(message['reply_to'])
|
connection = unpickle_connection(message['reply_to'])
|
||||||
@ -144,12 +214,12 @@ class GStreamerProcess(BaseProcess):
|
|||||||
|
|
||||||
def deliver_data(self, caps_string, data):
|
def deliver_data(self, caps_string, data):
|
||||||
"""Deliver audio data to be played"""
|
"""Deliver audio data to be played"""
|
||||||
data_src = self.gst_pipeline.get_by_name('src')
|
app_src = self.gst_pipeline.get_by_name('appsrc')
|
||||||
caps = gst.caps_from_string(caps_string)
|
caps = gst.caps_from_string(caps_string)
|
||||||
buffer_ = gst.Buffer(buffer(data))
|
buffer_ = gst.Buffer(buffer(data))
|
||||||
buffer_.set_caps(caps)
|
buffer_.set_caps(caps)
|
||||||
data_src.set_property('caps', caps)
|
app_src.set_property('caps', caps)
|
||||||
data_src.emit('push-buffer', buffer_)
|
app_src.emit('push-buffer', buffer_)
|
||||||
|
|
||||||
def end_of_data_stream(self):
|
def end_of_data_stream(self):
|
||||||
"""
|
"""
|
||||||
@ -158,7 +228,7 @@ class GStreamerProcess(BaseProcess):
|
|||||||
We will get a GStreamer message when the stream playback reaches the
|
We will get a GStreamer message when the stream playback reaches the
|
||||||
token, and can then do any end-of-stream related tasks.
|
token, and can then do any end-of-stream related tasks.
|
||||||
"""
|
"""
|
||||||
self.gst_pipeline.get_by_name('src').emit('end-of-stream')
|
self.gst_pipeline.get_by_name('appsrc').emit('end-of-stream')
|
||||||
|
|
||||||
def set_state(self, state_name):
|
def set_state(self, state_name):
|
||||||
"""
|
"""
|
||||||
@ -195,6 +265,7 @@ class GStreamerProcess(BaseProcess):
|
|||||||
"""Set volume in range [0..100]"""
|
"""Set volume in range [0..100]"""
|
||||||
gst_volume = self.gst_pipeline.get_by_name('volume')
|
gst_volume = self.gst_pipeline.get_by_name('volume')
|
||||||
gst_volume.set_property('volume', volume / 100.0)
|
gst_volume.set_property('volume', volume / 100.0)
|
||||||
|
return True
|
||||||
|
|
||||||
def set_position(self, position):
|
def set_position(self, position):
|
||||||
self.gst_pipeline.get_state() # block until state changes are done
|
self.gst_pipeline.get_state() # block until state changes are done
|
||||||
|
|||||||
@ -20,36 +20,39 @@ BACKENDS = (
|
|||||||
u'mopidy.backends.libspotify.LibspotifyBackend',
|
u'mopidy.backends.libspotify.LibspotifyBackend',
|
||||||
)
|
)
|
||||||
|
|
||||||
#: The log format used on the console. See
|
#: The log format used for informational logging.
|
||||||
#: http://docs.python.org/library/logging.html#formatter-objects for details on
|
#:
|
||||||
#: the format.
|
#: See http://docs.python.org/library/logging.html#formatter-objects for
|
||||||
CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s' + \
|
#: details on the format.
|
||||||
|
CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(message)s'
|
||||||
|
|
||||||
|
#: The log format used for debug logging.
|
||||||
|
#:
|
||||||
|
#: See http://docs.python.org/library/logging.html#formatter-objects for
|
||||||
|
#: details on the format.
|
||||||
|
DEBUG_LOG_FORMAT = u'%(levelname)-8s %(asctime)s' + \
|
||||||
' [%(process)d:%(threadName)s] %(name)s\n %(message)s'
|
' [%(process)d:%(threadName)s] %(name)s\n %(message)s'
|
||||||
|
|
||||||
#: The log format used for dump logs.
|
|
||||||
#:
|
|
||||||
#: Default::
|
|
||||||
#:
|
|
||||||
#: DUMP_LOG_FILENAME = CONSOLE_LOG_FORMAT
|
|
||||||
DUMP_LOG_FORMAT = CONSOLE_LOG_FORMAT
|
|
||||||
|
|
||||||
#: The file to dump debug log data to when Mopidy is run with the
|
#: The file to dump debug log data to when Mopidy is run with the
|
||||||
#: :option:`--dump` option.
|
#: :option:`--save-debug-log` option.
|
||||||
#:
|
#:
|
||||||
#: Default::
|
#: Default::
|
||||||
#:
|
#:
|
||||||
#: DUMP_LOG_FILENAME = u'dump.log'
|
#: DEBUG_LOG_FILENAME = u'mopidy.log'
|
||||||
DUMP_LOG_FILENAME = u'dump.log'
|
DEBUG_LOG_FILENAME = u'mopidy.log'
|
||||||
|
|
||||||
#: List of server frontends to use.
|
#: List of server frontends to use.
|
||||||
#:
|
#:
|
||||||
#: Default::
|
#: Default::
|
||||||
#:
|
#:
|
||||||
#: FRONTENDS = (u'mopidy.frontends.mpd.MpdFrontend',)
|
#: FRONTENDS = (
|
||||||
#:
|
#: u'mopidy.frontends.mpd.MpdFrontend',
|
||||||
#: .. note::
|
#: u'mopidy.frontends.lastfm.LastfmFrontend',
|
||||||
#: Currently only the first frontend in the list is used.
|
#: )
|
||||||
FRONTENDS = (u'mopidy.frontends.mpd.MpdFrontend',)
|
FRONTENDS = (
|
||||||
|
u'mopidy.frontends.mpd.MpdFrontend',
|
||||||
|
u'mopidy.frontends.lastfm.LastfmFrontend',
|
||||||
|
)
|
||||||
|
|
||||||
#: Which GStreamer audio sink to use in :mod:`mopidy.outputs.gstreamer`.
|
#: Which GStreamer audio sink to use in :mod:`mopidy.outputs.gstreamer`.
|
||||||
#:
|
#:
|
||||||
@ -58,6 +61,16 @@ FRONTENDS = (u'mopidy.frontends.mpd.MpdFrontend',)
|
|||||||
#: GSTREAMER_AUDIO_SINK = u'autoaudiosink'
|
#: GSTREAMER_AUDIO_SINK = u'autoaudiosink'
|
||||||
GSTREAMER_AUDIO_SINK = u'autoaudiosink'
|
GSTREAMER_AUDIO_SINK = u'autoaudiosink'
|
||||||
|
|
||||||
|
#: Your `Last.fm <http://www.last.fm/>`_ username.
|
||||||
|
#:
|
||||||
|
#: Used by :mod:`mopidy.frontends.lastfm`.
|
||||||
|
LASTFM_USERNAME = u''
|
||||||
|
|
||||||
|
#: Your `Last.fm <http://www.last.fm/>`_ password.
|
||||||
|
#:
|
||||||
|
#: Used by :mod:`mopidy.frontends.lastfm`.
|
||||||
|
LASTFM_PASSWORD = u''
|
||||||
|
|
||||||
#: Path to folder with local music.
|
#: Path to folder with local music.
|
||||||
#:
|
#:
|
||||||
#: Used by :mod:`mopidy.backends.local`.
|
#: Used by :mod:`mopidy.backends.local`.
|
||||||
|
|||||||
@ -3,27 +3,40 @@ import logging.handlers
|
|||||||
|
|
||||||
from mopidy import settings
|
from mopidy import settings
|
||||||
|
|
||||||
def setup_logging(verbosity_level, dump):
|
def setup_logging(verbosity_level, save_debug_log):
|
||||||
|
setup_root_logger()
|
||||||
setup_console_logging(verbosity_level)
|
setup_console_logging(verbosity_level)
|
||||||
if dump:
|
if save_debug_log:
|
||||||
setup_dump_logging()
|
setup_debug_logging_to_file()
|
||||||
|
|
||||||
|
def setup_root_logger():
|
||||||
|
root = logging.getLogger('')
|
||||||
|
root.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
def setup_console_logging(verbosity_level):
|
def setup_console_logging(verbosity_level):
|
||||||
if verbosity_level == 0:
|
if verbosity_level == 0:
|
||||||
level = logging.WARNING
|
log_level = logging.WARNING
|
||||||
|
log_format = settings.CONSOLE_LOG_FORMAT
|
||||||
elif verbosity_level == 2:
|
elif verbosity_level == 2:
|
||||||
level = logging.DEBUG
|
log_level = logging.DEBUG
|
||||||
|
log_format = settings.DEBUG_LOG_FORMAT
|
||||||
else:
|
else:
|
||||||
level = logging.INFO
|
log_level = logging.INFO
|
||||||
logging.basicConfig(format=settings.CONSOLE_LOG_FORMAT, level=level)
|
log_format = settings.CONSOLE_LOG_FORMAT
|
||||||
|
formatter = logging.Formatter(log_format)
|
||||||
def setup_dump_logging():
|
handler = logging.StreamHandler()
|
||||||
root = logging.getLogger('')
|
|
||||||
root.setLevel(logging.DEBUG)
|
|
||||||
formatter = logging.Formatter(settings.DUMP_LOG_FORMAT)
|
|
||||||
handler = logging.handlers.RotatingFileHandler(
|
|
||||||
settings.DUMP_LOG_FILENAME, maxBytes=102400, backupCount=3)
|
|
||||||
handler.setFormatter(formatter)
|
handler.setFormatter(formatter)
|
||||||
|
handler.setLevel(log_level)
|
||||||
|
root = logging.getLogger('')
|
||||||
|
root.addHandler(handler)
|
||||||
|
|
||||||
|
def setup_debug_logging_to_file():
|
||||||
|
formatter = logging.Formatter(settings.DEBUG_LOG_FORMAT)
|
||||||
|
handler = logging.handlers.RotatingFileHandler(
|
||||||
|
settings.DEBUG_LOG_FILENAME, maxBytes=10485760, backupCount=3)
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
handler.setLevel(logging.DEBUG)
|
||||||
|
root = logging.getLogger('')
|
||||||
root.addHandler(handler)
|
root.addHandler(handler)
|
||||||
|
|
||||||
def indent(string, places=4, linebreak='\n'):
|
def indent(string, places=4, linebreak='\n'):
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
|
import multiprocessing.dummy
|
||||||
from multiprocessing.reduction import reduce_connection
|
from multiprocessing.reduction import reduce_connection
|
||||||
import pickle
|
import pickle
|
||||||
import sys
|
import sys
|
||||||
@ -18,22 +19,70 @@ def unpickle_connection(pickled_connection):
|
|||||||
|
|
||||||
|
|
||||||
class BaseProcess(multiprocessing.Process):
|
class BaseProcess(multiprocessing.Process):
|
||||||
|
def __init__(self, core_queue):
|
||||||
|
super(BaseProcess, self).__init__()
|
||||||
|
self.core_queue = core_queue
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
logger.debug(u'%s: Starting process', self.name)
|
logger.debug(u'%s: Starting process', self.name)
|
||||||
try:
|
try:
|
||||||
self.run_inside_try()
|
self.run_inside_try()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logger.info(u'%s: Interrupted by user', self.name)
|
logger.info(u'Interrupted by user')
|
||||||
sys.exit(0)
|
self.exit(0, u'Interrupted by user')
|
||||||
except SettingsError as e:
|
except SettingsError as e:
|
||||||
logger.error(e.message)
|
logger.error(e.message)
|
||||||
sys.exit(1)
|
self.exit(1, u'Settings error')
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
sys.exit(1)
|
self.exit(2, u'Import error')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
raise e
|
self.exit(3, u'Unknown error')
|
||||||
|
|
||||||
def run_inside_try(self):
|
def run_inside_try(self):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def destroy(self):
|
||||||
|
self.terminate()
|
||||||
|
|
||||||
|
def exit(self, status=0, reason=None):
|
||||||
|
self.core_queue.put({'to': 'core', 'command': 'exit',
|
||||||
|
'status': status, 'reason': reason})
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
class BaseThread(multiprocessing.dummy.Process):
|
||||||
|
def __init__(self, core_queue):
|
||||||
|
super(BaseThread, self).__init__()
|
||||||
|
self.core_queue = core_queue
|
||||||
|
# No thread should block process from exiting
|
||||||
|
self.daemon = True
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
logger.debug(u'%s: Starting thread', self.name)
|
||||||
|
try:
|
||||||
|
self.run_inside_try()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info(u'Interrupted by user')
|
||||||
|
self.exit(0, u'Interrupted by user')
|
||||||
|
except SettingsError as e:
|
||||||
|
logger.error(e.message)
|
||||||
|
self.exit(1, u'Settings error')
|
||||||
|
except ImportError as e:
|
||||||
|
logger.error(e)
|
||||||
|
self.exit(2, u'Import error')
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(e)
|
||||||
|
self.exit(3, u'Unknown error')
|
||||||
|
|
||||||
|
def run_inside_try(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def destroy(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def exit(self, status=0, reason=None):
|
||||||
|
self.core_queue.put({'to': 'core', 'command': 'exit',
|
||||||
|
'status': status, 'reason': reason})
|
||||||
|
self.destroy()
|
||||||
|
|||||||
@ -15,6 +15,7 @@ class SettingsProxy(object):
|
|||||||
self.default = self._get_settings_dict_from_module(
|
self.default = self._get_settings_dict_from_module(
|
||||||
default_settings_module)
|
default_settings_module)
|
||||||
self.local = self._get_local_settings()
|
self.local = self._get_local_settings()
|
||||||
|
self.runtime = {}
|
||||||
|
|
||||||
def _get_local_settings(self):
|
def _get_local_settings(self):
|
||||||
dotdir = os.path.expanduser(u'~/.mopidy/')
|
dotdir = os.path.expanduser(u'~/.mopidy/')
|
||||||
@ -37,6 +38,7 @@ class SettingsProxy(object):
|
|||||||
def current(self):
|
def current(self):
|
||||||
current = copy(self.default)
|
current = copy(self.default)
|
||||||
current.update(self.local)
|
current.update(self.local)
|
||||||
|
current.update(self.runtime)
|
||||||
return current
|
return current
|
||||||
|
|
||||||
def __getattr__(self, attr):
|
def __getattr__(self, attr):
|
||||||
@ -49,6 +51,12 @@ class SettingsProxy(object):
|
|||||||
raise SettingsError(u'Setting "%s" is empty.' % attr)
|
raise SettingsError(u'Setting "%s" is empty.' % attr)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
def __setattr__(self, attr, value):
|
||||||
|
if self._is_setting(attr):
|
||||||
|
self.runtime[attr] = value
|
||||||
|
else:
|
||||||
|
super(SettingsProxy, self).__setattr__(attr, value)
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
if self.get_errors():
|
if self.get_errors():
|
||||||
logger.error(u'Settings validation errors: %s',
|
logger.error(u'Settings validation errors: %s',
|
||||||
@ -81,6 +89,8 @@ def validate_settings(defaults, settings):
|
|||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
changed = {
|
changed = {
|
||||||
|
'DUMP_LOG_FILENAME': 'DEBUG_LOG_FILENAME',
|
||||||
|
'DUMP_LOG_FORMAT': 'DEBUG_LOG_FORMAT',
|
||||||
'FRONTEND': 'FRONTENDS',
|
'FRONTEND': 'FRONTENDS',
|
||||||
'SERVER': None,
|
'SERVER': None,
|
||||||
'SERVER_HOSTNAME': 'MPD_SERVER_HOSTNAME',
|
'SERVER_HOSTNAME': 'MPD_SERVER_HOSTNAME',
|
||||||
@ -122,7 +132,7 @@ def list_settings_optparse_callback(*args):
|
|||||||
lines = []
|
lines = []
|
||||||
for (key, value) in sorted(settings.current.iteritems()):
|
for (key, value) in sorted(settings.current.iteritems()):
|
||||||
default_value = settings.default.get(key)
|
default_value = settings.default.get(key)
|
||||||
if key.endswith('PASSWORD'):
|
if key.endswith('PASSWORD') and len(value):
|
||||||
value = u'********'
|
value = u'********'
|
||||||
lines.append(u'%s:' % key)
|
lines.append(u'%s:' % key)
|
||||||
lines.append(u' Value: %s' % repr(value))
|
lines.append(u' Value: %s' % repr(value))
|
||||||
|
|||||||
1
requirements-lastfm.txt
Normal file
1
requirements-lastfm.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
pylast >= 0.4.30
|
||||||
@ -4,6 +4,7 @@ import random
|
|||||||
from mopidy import settings
|
from mopidy import settings
|
||||||
from mopidy.mixers.dummy import DummyMixer
|
from mopidy.mixers.dummy import DummyMixer
|
||||||
from mopidy.models import Playlist, Track
|
from mopidy.models import Playlist, Track
|
||||||
|
from mopidy.outputs.dummy import DummyOutput
|
||||||
from mopidy.utils import get_class
|
from mopidy.utils import get_class
|
||||||
|
|
||||||
from tests.backends.base import populate_playlist
|
from tests.backends.base import populate_playlist
|
||||||
@ -12,12 +13,10 @@ class BaseCurrentPlaylistControllerTest(object):
|
|||||||
tracks = []
|
tracks = []
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.output_queue = multiprocessing.Queue()
|
|
||||||
self.core_queue = multiprocessing.Queue()
|
self.core_queue = multiprocessing.Queue()
|
||||||
self.output = get_class(settings.OUTPUT)(
|
self.output = DummyOutput(self.core_queue)
|
||||||
self.core_queue, self.output_queue)
|
|
||||||
self.backend = self.backend_class(
|
self.backend = self.backend_class(
|
||||||
self.core_queue, self.output_queue, DummyMixer)
|
self.core_queue, self.output, DummyMixer)
|
||||||
self.controller = self.backend.current_playlist
|
self.controller = self.backend.current_playlist
|
||||||
self.playback = self.backend.playback
|
self.playback = self.backend.playback
|
||||||
|
|
||||||
@ -129,7 +128,7 @@ class BaseCurrentPlaylistControllerTest(object):
|
|||||||
def test_append_does_not_reset_version(self):
|
def test_append_does_not_reset_version(self):
|
||||||
version = self.controller.version
|
version = self.controller.version
|
||||||
self.controller.append([])
|
self.controller.append([])
|
||||||
self.assertEqual(self.controller.version, version + 1)
|
self.assertEqual(self.controller.version, version)
|
||||||
|
|
||||||
@populate_playlist
|
@populate_playlist
|
||||||
def test_append_preserves_playing_state(self):
|
def test_append_preserves_playing_state(self):
|
||||||
@ -250,7 +249,12 @@ class BaseCurrentPlaylistControllerTest(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))
|
||||||
|
|
||||||
def test_version(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([])
|
||||||
|
self.assertEquals(version, self.controller.version)
|
||||||
|
|
||||||
|
def test_version_increases_when_appending_something(self):
|
||||||
|
version = self.controller.version
|
||||||
|
self.controller.append([Track()])
|
||||||
self.assert_(version < self.controller.version)
|
self.assert_(version < self.controller.version)
|
||||||
|
|||||||
@ -5,21 +5,22 @@ import time
|
|||||||
from mopidy import settings
|
from mopidy import settings
|
||||||
from mopidy.mixers.dummy import DummyMixer
|
from mopidy.mixers.dummy import DummyMixer
|
||||||
from mopidy.models import Track
|
from mopidy.models import Track
|
||||||
|
from mopidy.outputs.dummy import DummyOutput
|
||||||
from mopidy.utils import get_class
|
from mopidy.utils import get_class
|
||||||
|
|
||||||
from tests import SkipTest
|
from tests import SkipTest
|
||||||
from tests.backends.base import populate_playlist
|
from tests.backends.base import populate_playlist
|
||||||
|
|
||||||
|
# TODO Test 'playlist repeat', e.g. repeat=1,single=0
|
||||||
|
|
||||||
class BasePlaybackControllerTest(object):
|
class BasePlaybackControllerTest(object):
|
||||||
tracks = []
|
tracks = []
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.output_queue = multiprocessing.Queue()
|
|
||||||
self.core_queue = multiprocessing.Queue()
|
self.core_queue = multiprocessing.Queue()
|
||||||
self.output = get_class(settings.OUTPUT)(
|
self.output = DummyOutput(self.core_queue)
|
||||||
self.core_queue, self.output_queue)
|
|
||||||
self.backend = self.backend_class(
|
self.backend = self.backend_class(
|
||||||
self.core_queue, self.output_queue, DummyMixer)
|
self.core_queue, self.output, DummyMixer)
|
||||||
self.playback = self.backend.playback
|
self.playback = self.backend.playback
|
||||||
self.current_playlist = self.backend.current_playlist
|
self.current_playlist = self.backend.current_playlist
|
||||||
|
|
||||||
@ -523,16 +524,17 @@ class BasePlaybackControllerTest(object):
|
|||||||
wrapper.called = False
|
wrapper.called = False
|
||||||
|
|
||||||
self.playback.on_current_playlist_change = wrapper
|
self.playback.on_current_playlist_change = wrapper
|
||||||
self.backend.current_playlist.append([])
|
self.backend.current_playlist.append([Track()])
|
||||||
|
|
||||||
self.assert_(wrapper.called)
|
self.assert_(wrapper.called)
|
||||||
|
|
||||||
|
@SkipTest # Blocks for 10ms and does not work with DummyOutput
|
||||||
@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()
|
||||||
result = self.playback.seek(self.tracks[0].length - 10)
|
result = self.playback.seek(self.tracks[0].length - 10)
|
||||||
self.assert_(result, 'Seek failed')
|
self.assertTrue(result, 'Seek failed')
|
||||||
message = self.core_queue.get()
|
message = self.core_queue.get(True, 1)
|
||||||
self.assertEqual('end_of_track', message['command'])
|
self.assertEqual('end_of_track', message['command'])
|
||||||
|
|
||||||
@populate_playlist
|
@populate_playlist
|
||||||
@ -606,6 +608,7 @@ class BasePlaybackControllerTest(object):
|
|||||||
self.playback.pause()
|
self.playback.pause()
|
||||||
self.assertEqual(self.playback.resume(), None)
|
self.assertEqual(self.playback.resume(), None)
|
||||||
|
|
||||||
|
@SkipTest # Uses sleep and does not work with DummyOutput+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()
|
||||||
@ -626,8 +629,7 @@ class BasePlaybackControllerTest(object):
|
|||||||
self.assert_(position >= 990, position)
|
self.assert_(position >= 990, position)
|
||||||
|
|
||||||
def test_seek_on_empty_playlist(self):
|
def test_seek_on_empty_playlist(self):
|
||||||
result = self.playback.seek(0)
|
self.assertFalse(self.playback.seek(0))
|
||||||
self.assert_(not result, 'Seek return value was %s' % result)
|
|
||||||
|
|
||||||
def test_seek_on_empty_playlist_updates_position(self):
|
def test_seek_on_empty_playlist_updates_position(self):
|
||||||
self.playback.seek(0)
|
self.playback.seek(0)
|
||||||
@ -738,15 +740,16 @@ class BasePlaybackControllerTest(object):
|
|||||||
def test_time_position_when_stopped_with_playlist(self):
|
def test_time_position_when_stopped_with_playlist(self):
|
||||||
self.assertEqual(self.playback.time_position, 0)
|
self.assertEqual(self.playback.time_position, 0)
|
||||||
|
|
||||||
|
@SkipTest # Uses sleep and does not work with LocalBackend+DummyOutput
|
||||||
@populate_playlist
|
@populate_playlist
|
||||||
def test_time_position_when_playing(self):
|
def test_time_position_when_playing(self):
|
||||||
self.playback.play()
|
self.playback.play()
|
||||||
first = self.playback.time_position
|
first = self.playback.time_position
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
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
|
||||||
@populate_playlist
|
@populate_playlist
|
||||||
def test_time_position_when_paused(self):
|
def test_time_position_when_paused(self):
|
||||||
self.playback.play()
|
self.playback.play()
|
||||||
@ -755,7 +758,6 @@ class BasePlaybackControllerTest(object):
|
|||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
first = self.playback.time_position
|
first = self.playback.time_position
|
||||||
second = self.playback.time_position
|
second = self.playback.time_position
|
||||||
|
|
||||||
self.assertEqual(first, second)
|
self.assertEqual(first, second)
|
||||||
|
|
||||||
@populate_playlist
|
@populate_playlist
|
||||||
|
|||||||
@ -10,10 +10,6 @@ from tests import SkipTest, data_folder
|
|||||||
|
|
||||||
class BaseStoredPlaylistsControllerTest(object):
|
class BaseStoredPlaylistsControllerTest(object):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.original_playlist_folder = settings.LOCAL_PLAYLIST_FOLDER
|
|
||||||
self.original_tag_cache = settings.LOCAL_TAG_CACHE
|
|
||||||
self.original_music_folder = settings.LOCAL_MUSIC_FOLDER
|
|
||||||
|
|
||||||
settings.LOCAL_PLAYLIST_FOLDER = tempfile.mkdtemp()
|
settings.LOCAL_PLAYLIST_FOLDER = tempfile.mkdtemp()
|
||||||
settings.LOCAL_TAG_CACHE = data_folder('library_tag_cache')
|
settings.LOCAL_TAG_CACHE = data_folder('library_tag_cache')
|
||||||
settings.LOCAL_MUSIC_FOLDER = data_folder('')
|
settings.LOCAL_MUSIC_FOLDER = data_folder('')
|
||||||
@ -27,9 +23,7 @@ class BaseStoredPlaylistsControllerTest(object):
|
|||||||
if os.path.exists(settings.LOCAL_PLAYLIST_FOLDER):
|
if os.path.exists(settings.LOCAL_PLAYLIST_FOLDER):
|
||||||
shutil.rmtree(settings.LOCAL_PLAYLIST_FOLDER)
|
shutil.rmtree(settings.LOCAL_PLAYLIST_FOLDER)
|
||||||
|
|
||||||
settings.LOCAL_PLAYLIST_FOLDER = self.original_playlist_folder
|
settings.runtime.clear()
|
||||||
settings.LOCAL_TAG_CACHE = self.original_tag_cache
|
|
||||||
settings.LOCAL_MUSIC_FOLDER = self.original_music_folder
|
|
||||||
|
|
||||||
def test_create(self):
|
def test_create(self):
|
||||||
playlist = self.stored.create('test')
|
playlist = self.stored.create('test')
|
||||||
|
|||||||
@ -22,10 +22,9 @@ class LocalCurrentPlaylistControllerTest(BaseCurrentPlaylistControllerTest,
|
|||||||
for i in range(1, 4)]
|
for i in range(1, 4)]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.original_backends = settings.BACKENDS
|
|
||||||
settings.BACKENDS = ('mopidy.backends.local.LocalBackend',)
|
settings.BACKENDS = ('mopidy.backends.local.LocalBackend',)
|
||||||
super(LocalCurrentPlaylistControllerTest, self).setUp()
|
super(LocalCurrentPlaylistControllerTest, self).setUp()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
super(LocalCurrentPlaylistControllerTest, self).tearDown()
|
super(LocalCurrentPlaylistControllerTest, self).tearDown()
|
||||||
settings.BACKENDS = settings.original_backends
|
settings.runtime.clear()
|
||||||
|
|||||||
@ -17,16 +17,12 @@ class LocalLibraryControllerTest(BaseLibraryControllerTest, unittest.TestCase):
|
|||||||
backend_class = LocalBackend
|
backend_class = LocalBackend
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.original_tag_cache = settings.LOCAL_TAG_CACHE
|
|
||||||
self.original_music_folder = settings.LOCAL_MUSIC_FOLDER
|
|
||||||
|
|
||||||
settings.LOCAL_TAG_CACHE = data_folder('library_tag_cache')
|
settings.LOCAL_TAG_CACHE = data_folder('library_tag_cache')
|
||||||
settings.LOCAL_MUSIC_FOLDER = data_folder('')
|
settings.LOCAL_MUSIC_FOLDER = data_folder('')
|
||||||
|
|
||||||
super(LocalLibraryControllerTest, self).setUp()
|
super(LocalLibraryControllerTest, self).setUp()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
settings.LOCAL_TAG_CACHE = self.original_tag_cache
|
settings.runtime.clear()
|
||||||
settings.LOCAL_MUSIC_FOLDER = self.original_music_folder
|
|
||||||
|
|
||||||
super(LocalLibraryControllerTest, self).tearDown()
|
super(LocalLibraryControllerTest, self).tearDown()
|
||||||
|
|||||||
@ -23,7 +23,6 @@ class LocalPlaybackControllerTest(BasePlaybackControllerTest,
|
|||||||
for i in range(1, 4)]
|
for i in range(1, 4)]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.original_backends = settings.BACKENDS
|
|
||||||
settings.BACKENDS = ('mopidy.backends.local.LocalBackend',)
|
settings.BACKENDS = ('mopidy.backends.local.LocalBackend',)
|
||||||
|
|
||||||
super(LocalPlaybackControllerTest, self).setUp()
|
super(LocalPlaybackControllerTest, self).setUp()
|
||||||
@ -32,7 +31,7 @@ class LocalPlaybackControllerTest(BasePlaybackControllerTest,
|
|||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
super(LocalPlaybackControllerTest, self).tearDown()
|
super(LocalPlaybackControllerTest, self).tearDown()
|
||||||
settings.BACKENDS = settings.original_backends
|
settings.runtime.clear()
|
||||||
|
|
||||||
def add_track(self, path):
|
def add_track(self, path):
|
||||||
uri = path_to_uri(data_folder(path))
|
uri = path_to_uri(data_folder(path))
|
||||||
|
|||||||
@ -33,6 +33,11 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
|
|||||||
self.assertEqual(result[0],
|
self.assertEqual(result[0],
|
||||||
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):
|
||||||
|
result = self.h.handle_request(u'add ""')
|
||||||
|
# TODO check that we add all tracks (we currently don't)
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
def test_addid_without_songpos(self):
|
def test_addid_without_songpos(self):
|
||||||
needle = Track(uri='dummy://foo')
|
needle = Track(uri='dummy://foo')
|
||||||
self.b.library._library = [Track(), Track(), needle, Track()]
|
self.b.library._library = [Track(), Track(), needle, Track()]
|
||||||
@ -46,6 +51,11 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
|
|||||||
in result)
|
in result)
|
||||||
self.assert_(u'OK' in result)
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_addid_with_empty_uri_does_not_lookup_and_acks(self):
|
||||||
|
self.b.library.lookup = lambda uri: self.fail("Shouldn't run")
|
||||||
|
result = self.h.handle_request(u'addid ""')
|
||||||
|
self.assertEqual(result[0], 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')
|
||||||
self.b.library._library = [Track(), Track(), needle, Track()]
|
self.b.library._library = [Track(), Track(), needle, Track()]
|
||||||
@ -125,7 +135,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
|
|||||||
def test_deleteid(self):
|
def test_deleteid(self):
|
||||||
self.b.current_playlist.append([Track(), Track()])
|
self.b.current_playlist.append([Track(), Track()])
|
||||||
self.assertEqual(len(self.b.current_playlist.tracks), 2)
|
self.assertEqual(len(self.b.current_playlist.tracks), 2)
|
||||||
result = self.h.handle_request(u'deleteid "2"')
|
result = self.h.handle_request(u'deleteid "1"')
|
||||||
self.assertEqual(len(self.b.current_playlist.tracks), 1)
|
self.assertEqual(len(self.b.current_playlist.tracks), 1)
|
||||||
self.assert_(u'OK' in result)
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
@ -183,7 +193,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
|
|||||||
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.h.handle_request(u'moveid "5" "2"')
|
result = self.h.handle_request(u'moveid "4" "2"')
|
||||||
self.assertEqual(self.b.current_playlist.tracks[0].name, 'a')
|
self.assertEqual(self.b.current_playlist.tracks[0].name, 'a')
|
||||||
self.assertEqual(self.b.current_playlist.tracks[1].name, 'b')
|
self.assertEqual(self.b.current_playlist.tracks[1].name, 'b')
|
||||||
self.assertEqual(self.b.current_playlist.tracks[2].name, 'e')
|
self.assertEqual(self.b.current_playlist.tracks[2].name, 'e')
|
||||||
@ -219,7 +229,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
|
|||||||
result = self.h.handle_request(
|
result = self.h.handle_request(
|
||||||
u'playlistfind filename "file:///exists"')
|
u'playlistfind filename "file:///exists"')
|
||||||
self.assert_(u'file: file:///exists' in result)
|
self.assert_(u'file: file:///exists' in result)
|
||||||
self.assert_(u'Id: 1' in result)
|
self.assert_(u'Id: 0' in result)
|
||||||
self.assert_(u'Pos: 0' in result)
|
self.assert_(u'Pos: 0' in result)
|
||||||
self.assert_(u'OK' in result)
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
@ -232,11 +242,11 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_playlistid_with_songid(self):
|
def test_playlistid_with_songid(self):
|
||||||
self.b.current_playlist.append([Track(name='a'), Track(name='b')])
|
self.b.current_playlist.append([Track(name='a'), Track(name='b')])
|
||||||
result = self.h.handle_request(u'playlistid "2"')
|
result = self.h.handle_request(u'playlistid "1"')
|
||||||
self.assert_(u'Title: a' not in result)
|
self.assert_(u'Title: a' not in result)
|
||||||
self.assert_(u'Id: 1' not in result)
|
self.assert_(u'Id: 0' not in result)
|
||||||
self.assert_(u'Title: b' in result)
|
self.assert_(u'Title: b' in result)
|
||||||
self.assert_(u'Id: 2' in result)
|
self.assert_(u'Id: 1' in result)
|
||||||
self.assert_(u'OK' in result)
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
def test_playlistid_with_not_existing_songid_fails(self):
|
def test_playlistid_with_not_existing_songid_fails(self):
|
||||||
@ -419,7 +429,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
|
|||||||
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.h.handle_request(u'swapid "2" "5"')
|
result = self.h.handle_request(u'swapid "1" "4"')
|
||||||
self.assertEqual(self.b.current_playlist.tracks[0].name, 'a')
|
self.assertEqual(self.b.current_playlist.tracks[0].name, 'a')
|
||||||
self.assertEqual(self.b.current_playlist.tracks[1].name, 'e')
|
self.assertEqual(self.b.current_playlist.tracks[1].name, 'e')
|
||||||
self.assertEqual(self.b.current_playlist.tracks[2].name, 'c')
|
self.assertEqual(self.b.current_playlist.tracks[2].name, 'c')
|
||||||
|
|||||||
@ -15,6 +15,59 @@ class MusicDatabaseHandlerTest(unittest.TestCase):
|
|||||||
self.assert_(u'playtime: 0' in result)
|
self.assert_(u'playtime: 0' in result)
|
||||||
self.assert_(u'OK' in result)
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_findadd(self):
|
||||||
|
result = self.h.handle_request(u'findadd "album" "what"')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_listall(self):
|
||||||
|
result = self.h.handle_request(u'listall "file:///dev/urandom"')
|
||||||
|
self.assert_(u'ACK [0@0] {} Not implemented' in result)
|
||||||
|
|
||||||
|
def test_listallinfo(self):
|
||||||
|
result = self.h.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.h.handle_request(u'lsinfo')
|
||||||
|
listplaylists_result = self.h.handle_request(u'listplaylists')
|
||||||
|
self.assertEqual(lsinfo_result, listplaylists_result)
|
||||||
|
|
||||||
|
def test_lsinfo_with_empty_path_returns_same_as_listplaylists(self):
|
||||||
|
lsinfo_result = self.h.handle_request(u'lsinfo ""')
|
||||||
|
listplaylists_result = self.h.handle_request(u'listplaylists')
|
||||||
|
self.assertEqual(lsinfo_result, listplaylists_result)
|
||||||
|
|
||||||
|
def test_lsinfo_for_root_returns_same_as_listplaylists(self):
|
||||||
|
lsinfo_result = self.h.handle_request(u'lsinfo "/"')
|
||||||
|
listplaylists_result = self.h.handle_request(u'listplaylists')
|
||||||
|
self.assertEqual(lsinfo_result, listplaylists_result)
|
||||||
|
|
||||||
|
def test_update_without_uri(self):
|
||||||
|
result = self.h.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.h.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.h.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.h.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.b = DummyBackend(mixer_class=DummyMixer)
|
||||||
|
self.h = dispatcher.MpdDispatcher(backend=self.b)
|
||||||
|
|
||||||
def test_find_album(self):
|
def test_find_album(self):
|
||||||
result = self.h.handle_request(u'find "album" "what"')
|
result = self.h.handle_request(u'find "album" "what"')
|
||||||
self.assert_(u'OK' in result)
|
self.assert_(u'OK' in result)
|
||||||
@ -48,11 +101,20 @@ class MusicDatabaseHandlerTest(unittest.TestCase):
|
|||||||
u'find album "album_what" artist "artist_what"')
|
u'find album "album_what" artist "artist_what"')
|
||||||
self.assert_(u'OK' in result)
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
def test_findadd(self):
|
|
||||||
result = self.h.handle_request(u'findadd "album" "what"')
|
|
||||||
self.assert_(u'OK' in result)
|
|
||||||
|
|
||||||
def test_list_artist(self):
|
class MusicDatabaseListTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.b = DummyBackend(mixer_class=DummyMixer)
|
||||||
|
self.h = dispatcher.MpdDispatcher(backend=self.b)
|
||||||
|
|
||||||
|
def test_list_foo_returns_ack(self):
|
||||||
|
result = self.h.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.h.handle_request(u'list "artist"')
|
result = self.h.handle_request(u'list "artist"')
|
||||||
self.assert_(u'OK' in result)
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
@ -64,44 +126,177 @@ class MusicDatabaseHandlerTest(unittest.TestCase):
|
|||||||
result = self.h.handle_request(u'list Artist')
|
result = self.h.handle_request(u'list Artist')
|
||||||
self.assert_(u'OK' in result)
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
def test_list_artist_with_artist_should_fail(self):
|
def test_list_artist_with_query_of_one_token(self):
|
||||||
result = self.h.handle_request(u'list "artist" "anartist"')
|
result = self.h.handle_request(u'list "artist" "anartist"')
|
||||||
self.assertEqual(result[0], u'ACK [2@0] {list} incorrect arguments')
|
self.assertEqual(result[0],
|
||||||
|
u'ACK [2@0] {list} should be "Album" for 3 arguments')
|
||||||
|
|
||||||
def test_list_album_without_artist(self):
|
def test_list_artist_with_unknown_field_in_query_returns_ack(self):
|
||||||
|
result = self.h.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.h.handle_request(u'list "artist" "artist" "anartist"')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_artist_by_album(self):
|
||||||
|
result = self.h.handle_request(u'list "artist" "album" "analbum"')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_artist_by_full_date(self):
|
||||||
|
result = self.h.handle_request(u'list "artist" "date" "2001-01-01"')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_artist_by_year(self):
|
||||||
|
result = self.h.handle_request(u'list "artist" "date" "2001"')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_artist_by_genre(self):
|
||||||
|
result = self.h.handle_request(u'list "artist" "genre" "agenre"')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_artist_by_artist_and_album(self):
|
||||||
|
result = self.h.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.h.handle_request(u'list "album"')
|
result = self.h.handle_request(u'list "album"')
|
||||||
self.assert_(u'OK' in result)
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
def test_list_album_with_artist(self):
|
def test_list_album_without_quotes(self):
|
||||||
|
result = self.h.handle_request(u'list album')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_album_without_quotes_and_capitalized(self):
|
||||||
|
result = self.h.handle_request(u'list Album')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_album_with_artist_name(self):
|
||||||
result = self.h.handle_request(u'list "album" "anartist"')
|
result = self.h.handle_request(u'list "album" "anartist"')
|
||||||
self.assert_(u'OK' in result)
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
def test_list_album_artist_with_artist_without_quotes(self):
|
def test_list_album_by_artist(self):
|
||||||
result = self.h.handle_request(u'list album artist "anartist"')
|
result = self.h.handle_request(u'list "album" "artist" "anartist"')
|
||||||
self.assert_(u'OK' in result)
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
def test_listall(self):
|
def test_list_album_by_album(self):
|
||||||
result = self.h.handle_request(u'listall "file:///dev/urandom"')
|
result = self.h.handle_request(u'list "album" "album" "analbum"')
|
||||||
self.assert_(u'ACK [0@0] {} Not implemented' in result)
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
def test_listallinfo(self):
|
def test_list_album_by_full_date(self):
|
||||||
result = self.h.handle_request(u'listallinfo "file:///dev/urandom"')
|
result = self.h.handle_request(u'list "album" "date" "2001-01-01"')
|
||||||
self.assert_(u'ACK [0@0] {} Not implemented' in result)
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
def test_lsinfo_without_path_returns_same_as_listplaylists(self):
|
def test_list_album_by_year(self):
|
||||||
lsinfo_result = self.h.handle_request(u'lsinfo')
|
result = self.h.handle_request(u'list "album" "date" "2001"')
|
||||||
listplaylists_result = self.h.handle_request(u'listplaylists')
|
self.assert_(u'OK' in result)
|
||||||
self.assertEqual(lsinfo_result, listplaylists_result)
|
|
||||||
|
|
||||||
def test_lsinfo_with_empty_path_returns_same_as_listplaylists(self):
|
def test_list_album_by_genre(self):
|
||||||
lsinfo_result = self.h.handle_request(u'lsinfo ""')
|
result = self.h.handle_request(u'list "album" "genre" "agenre"')
|
||||||
listplaylists_result = self.h.handle_request(u'listplaylists')
|
self.assert_(u'OK' in result)
|
||||||
self.assertEqual(lsinfo_result, listplaylists_result)
|
|
||||||
|
|
||||||
def test_lsinfo_for_root_returns_same_as_listplaylists(self):
|
def test_list_album_by_artist_and_album(self):
|
||||||
lsinfo_result = self.h.handle_request(u'lsinfo "/"')
|
result = self.h.handle_request(
|
||||||
listplaylists_result = self.h.handle_request(u'listplaylists')
|
u'list "album" "artist" "anartist" "album" "analbum"')
|
||||||
self.assertEqual(lsinfo_result, listplaylists_result)
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
### Date
|
||||||
|
|
||||||
|
def test_list_date_with_quotes(self):
|
||||||
|
result = self.h.handle_request(u'list "date"')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_date_without_quotes(self):
|
||||||
|
result = self.h.handle_request(u'list date')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_date_without_quotes_and_capitalized(self):
|
||||||
|
result = self.h.handle_request(u'list Date')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_date_with_query_of_one_token(self):
|
||||||
|
result = self.h.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.h.handle_request(u'list "date" "artist" "anartist"')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_date_by_album(self):
|
||||||
|
result = self.h.handle_request(u'list "date" "album" "analbum"')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_date_by_full_date(self):
|
||||||
|
result = self.h.handle_request(u'list "date" "date" "2001-01-01"')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_date_by_year(self):
|
||||||
|
result = self.h.handle_request(u'list "date" "date" "2001"')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_date_by_genre(self):
|
||||||
|
result = self.h.handle_request(u'list "date" "genre" "agenre"')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_date_by_artist_and_album(self):
|
||||||
|
result = self.h.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.h.handle_request(u'list "genre"')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_genre_without_quotes(self):
|
||||||
|
result = self.h.handle_request(u'list genre')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_genre_without_quotes_and_capitalized(self):
|
||||||
|
result = self.h.handle_request(u'list Genre')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_genre_with_query_of_one_token(self):
|
||||||
|
result = self.h.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.h.handle_request(u'list "genre" "artist" "anartist"')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_genre_by_album(self):
|
||||||
|
result = self.h.handle_request(u'list "genre" "album" "analbum"')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_genre_by_full_date(self):
|
||||||
|
result = self.h.handle_request(u'list "genre" "date" "2001-01-01"')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_genre_by_year(self):
|
||||||
|
result = self.h.handle_request(u'list "genre" "date" "2001"')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_genre_by_genre(self):
|
||||||
|
result = self.h.handle_request(u'list "genre" "genre" "agenre"')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
def test_list_genre_by_artist_and_album(self):
|
||||||
|
result = self.h.handle_request(
|
||||||
|
u'list "genre" "artist" "anartist" "album" "analbum"')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
|
|
||||||
|
class MusicDatabaseSearchTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.b = DummyBackend(mixer_class=DummyMixer)
|
||||||
|
self.h = dispatcher.MpdDispatcher(backend=self.b)
|
||||||
|
|
||||||
def test_search_album(self):
|
def test_search_album(self):
|
||||||
result = self.h.handle_request(u'search "album" "analbum"')
|
result = self.h.handle_request(u'search "album" "analbum"')
|
||||||
@ -147,22 +342,4 @@ class MusicDatabaseHandlerTest(unittest.TestCase):
|
|||||||
result = self.h.handle_request(u'search "sometype" "something"')
|
result = self.h.handle_request(u'search "sometype" "something"')
|
||||||
self.assertEqual(result[0], u'ACK [2@0] {search} incorrect arguments')
|
self.assertEqual(result[0], u'ACK [2@0] {search} incorrect arguments')
|
||||||
|
|
||||||
def test_update_without_uri(self):
|
|
||||||
result = self.h.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.h.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.h.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.h.handle_request(u'rescan "file:///dev/urandom"')
|
|
||||||
self.assert_(u'OK' in result)
|
|
||||||
self.assert_(u'updating_db: 0' in result)
|
|
||||||
|
|||||||
@ -254,7 +254,7 @@ class PlaybackControlHandlerTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_playid(self):
|
def test_playid(self):
|
||||||
self.b.current_playlist.append([Track()])
|
self.b.current_playlist.append([Track()])
|
||||||
result = self.h.handle_request(u'playid "1"')
|
result = self.h.handle_request(u'playid "0"')
|
||||||
self.assert_(u'OK' in result)
|
self.assert_(u'OK' in result)
|
||||||
self.assertEqual(self.b.playback.PLAYING, self.b.playback.state)
|
self.assertEqual(self.b.playback.PLAYING, self.b.playback.state)
|
||||||
|
|
||||||
@ -285,6 +285,18 @@ class PlaybackControlHandlerTest(unittest.TestCase):
|
|||||||
self.assertEqual(self.b.playback.STOPPED, self.b.playback.state)
|
self.assertEqual(self.b.playback.STOPPED, self.b.playback.state)
|
||||||
self.assertEqual(self.b.playback.current_track, None)
|
self.assertEqual(self.b.playback.current_track, None)
|
||||||
|
|
||||||
|
def test_playid_minus_one_resumes_if_paused(self):
|
||||||
|
self.b.current_playlist.append([Track(length=40000)])
|
||||||
|
self.b.playback.seek(30000)
|
||||||
|
self.assert_(self.b.playback.time_position >= 30000)
|
||||||
|
self.assertEquals(self.b.playback.PLAYING, self.b.playback.state)
|
||||||
|
self.b.playback.pause()
|
||||||
|
self.assertEquals(self.b.playback.PAUSED, self.b.playback.state)
|
||||||
|
result = self.h.handle_request(u'playid "-1"')
|
||||||
|
self.assert_(u'OK' in result)
|
||||||
|
self.assertEqual(self.b.playback.PLAYING, self.b.playback.state)
|
||||||
|
self.assert_(self.b.playback.time_position >= 30000)
|
||||||
|
|
||||||
def test_playid_which_does_not_exist(self):
|
def test_playid_which_does_not_exist(self):
|
||||||
self.b.current_playlist.append([Track()])
|
self.b.current_playlist.append([Track()])
|
||||||
result = self.h.handle_request(u'playid "12345"')
|
result = self.h.handle_request(u'playid "12345"')
|
||||||
@ -310,7 +322,7 @@ class PlaybackControlHandlerTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_seekid(self):
|
def test_seekid(self):
|
||||||
self.b.current_playlist.append([Track(length=40000)])
|
self.b.current_playlist.append([Track(length=40000)])
|
||||||
result = self.h.handle_request(u'seekid "1" "30"')
|
result = self.h.handle_request(u'seekid "0" "30"')
|
||||||
self.assert_(u'OK' in result)
|
self.assert_(u'OK' in result)
|
||||||
self.assert_(self.b.playback.time_position >= 30000)
|
self.assert_(self.b.playback.time_position >= 30000)
|
||||||
|
|
||||||
@ -318,8 +330,8 @@ class PlaybackControlHandlerTest(unittest.TestCase):
|
|||||||
seek_track = Track(uri='2', length=40000)
|
seek_track = Track(uri='2', length=40000)
|
||||||
self.b.current_playlist.append(
|
self.b.current_playlist.append(
|
||||||
[Track(length=40000), seek_track])
|
[Track(length=40000), seek_track])
|
||||||
result = self.h.handle_request(u'seekid "2" "30"')
|
result = self.h.handle_request(u'seekid "1" "30"')
|
||||||
self.assertEqual(self.b.playback.current_cpid, 2)
|
self.assertEqual(self.b.playback.current_cpid, 1)
|
||||||
self.assertEqual(self.b.playback.current_track, seek_track)
|
self.assertEqual(self.b.playback.current_track, seek_track)
|
||||||
|
|
||||||
def test_stop(self):
|
def test_stop(self):
|
||||||
|
|||||||
110
tests/frontends/mpd/regression_test.py
Normal file
110
tests/frontends/mpd/regression_test.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import random
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from mopidy.backends.dummy import DummyBackend
|
||||||
|
from mopidy.frontends.mpd import dispatcher
|
||||||
|
from mopidy.mixers.dummy import DummyMixer
|
||||||
|
from mopidy.models import Track
|
||||||
|
|
||||||
|
class IssueGH17RegressionTest(unittest.TestCase):
|
||||||
|
"""
|
||||||
|
The issue: http://github.com/jodal/mopidy/issues#issue/17
|
||||||
|
|
||||||
|
How to reproduce:
|
||||||
|
|
||||||
|
- Play a playlist where one track cannot be played
|
||||||
|
- Turn on random mode
|
||||||
|
- Press next until you get to the unplayable track
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.backend = DummyBackend(mixer_class=DummyMixer)
|
||||||
|
self.backend.current_playlist.append([
|
||||||
|
Track(uri='a'), Track(uri='b'), None,
|
||||||
|
Track(uri='d'), Track(uri='e'), Track(uri='f')])
|
||||||
|
self.mpd = dispatcher.MpdDispatcher(backend=self.backend)
|
||||||
|
|
||||||
|
def test(self):
|
||||||
|
random.seed(1) # Playlist order: abcfde
|
||||||
|
self.mpd.handle_request(u'play')
|
||||||
|
self.assertEquals('a', self.backend.playback.current_track.uri)
|
||||||
|
self.mpd.handle_request(u'random "1"')
|
||||||
|
self.mpd.handle_request(u'next')
|
||||||
|
self.assertEquals('b', self.backend.playback.current_track.uri)
|
||||||
|
self.mpd.handle_request(u'next')
|
||||||
|
# Should now be at track 'c', but playback fails and it skips ahead
|
||||||
|
self.assertEquals('f', self.backend.playback.current_track.uri)
|
||||||
|
self.mpd.handle_request(u'next')
|
||||||
|
self.assertEquals('d', self.backend.playback.current_track.uri)
|
||||||
|
self.mpd.handle_request(u'next')
|
||||||
|
self.assertEquals('e', self.backend.playback.current_track.uri)
|
||||||
|
|
||||||
|
|
||||||
|
class IssueGH18RegressionTest(unittest.TestCase):
|
||||||
|
"""
|
||||||
|
The issue: http://github.com/jodal/mopidy/issues#issue/18
|
||||||
|
|
||||||
|
How to reproduce:
|
||||||
|
|
||||||
|
Play, random on, next, random off, next, next.
|
||||||
|
|
||||||
|
At this point it gives the same song over and over.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.backend = DummyBackend(mixer_class=DummyMixer)
|
||||||
|
self.backend.current_playlist.append([
|
||||||
|
Track(uri='a'), Track(uri='b'), Track(uri='c'),
|
||||||
|
Track(uri='d'), Track(uri='e'), Track(uri='f')])
|
||||||
|
self.mpd = dispatcher.MpdDispatcher(backend=self.backend)
|
||||||
|
|
||||||
|
def test(self):
|
||||||
|
random.seed(1)
|
||||||
|
self.mpd.handle_request(u'play')
|
||||||
|
self.mpd.handle_request(u'random "1"')
|
||||||
|
self.mpd.handle_request(u'next')
|
||||||
|
self.mpd.handle_request(u'random "0"')
|
||||||
|
self.mpd.handle_request(u'next')
|
||||||
|
|
||||||
|
self.mpd.handle_request(u'next')
|
||||||
|
cp_track_1 = self.backend.playback.current_cp_track
|
||||||
|
self.mpd.handle_request(u'next')
|
||||||
|
cp_track_2 = self.backend.playback.current_cp_track
|
||||||
|
self.mpd.handle_request(u'next')
|
||||||
|
cp_track_3 = self.backend.playback.current_cp_track
|
||||||
|
|
||||||
|
self.assertNotEqual(cp_track_1, cp_track_2)
|
||||||
|
self.assertNotEqual(cp_track_2, cp_track_3)
|
||||||
|
|
||||||
|
|
||||||
|
class IssueGH22RegressionTest(unittest.TestCase):
|
||||||
|
"""
|
||||||
|
The issue: http://github.com/jodal/mopidy/issues/#issue/22
|
||||||
|
|
||||||
|
How to reproduce:
|
||||||
|
|
||||||
|
Play, random on, remove all tracks from the current playlist (as in
|
||||||
|
"delete" each one, not "clear").
|
||||||
|
|
||||||
|
Alternatively: Play, random on, remove a random track from the current
|
||||||
|
playlist, press next until it crashes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.backend = DummyBackend(mixer_class=DummyMixer)
|
||||||
|
self.backend.current_playlist.append([
|
||||||
|
Track(uri='a'), Track(uri='b'), Track(uri='c'),
|
||||||
|
Track(uri='d'), Track(uri='e'), Track(uri='f')])
|
||||||
|
self.mpd = dispatcher.MpdDispatcher(backend=self.backend)
|
||||||
|
|
||||||
|
def test(self):
|
||||||
|
random.seed(1)
|
||||||
|
self.mpd.handle_request(u'play')
|
||||||
|
self.mpd.handle_request(u'random "1"')
|
||||||
|
self.mpd.handle_request(u'deleteid "1"')
|
||||||
|
self.mpd.handle_request(u'deleteid "2"')
|
||||||
|
self.mpd.handle_request(u'deleteid "3"')
|
||||||
|
self.mpd.handle_request(u'deleteid "4"')
|
||||||
|
self.mpd.handle_request(u'deleteid "5"')
|
||||||
|
self.mpd.handle_request(u'deleteid "6"')
|
||||||
|
self.mpd.handle_request(u'status')
|
||||||
@ -27,7 +27,7 @@ class StatusHandlerTest(unittest.TestCase):
|
|||||||
self.assert_(u'Track: 0' in result)
|
self.assert_(u'Track: 0' in result)
|
||||||
self.assert_(u'Date: ' in result)
|
self.assert_(u'Date: ' in result)
|
||||||
self.assert_(u'Pos: 0' in result)
|
self.assert_(u'Pos: 0' in result)
|
||||||
self.assert_(u'Id: 1' in result)
|
self.assert_(u'Id: 0' in result)
|
||||||
self.assert_(u'OK' in result)
|
self.assert_(u'OK' in result)
|
||||||
|
|
||||||
def test_currentsong_without_song(self):
|
def test_currentsong_without_song(self):
|
||||||
@ -166,7 +166,7 @@ class StatusHandlerTest(unittest.TestCase):
|
|||||||
self.b.playback.play()
|
self.b.playback.play()
|
||||||
result = dict(dispatcher.status.status(self.h))
|
result = dict(dispatcher.status.status(self.h))
|
||||||
self.assert_('songid' in result)
|
self.assert_('songid' in result)
|
||||||
self.assertEqual(int(result['songid']), 1)
|
self.assertEqual(int(result['songid']), 0)
|
||||||
|
|
||||||
def test_status_method_when_playing_contains_time_with_no_length(self):
|
def test_status_method_when_playing_contains_time_with_no_length(self):
|
||||||
self.b.current_playlist.append([Track(length=None)])
|
self.b.current_playlist.append([Track(length=None)])
|
||||||
|
|||||||
@ -1,61 +1,65 @@
|
|||||||
import multiprocessing
|
import multiprocessing
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from tests import SkipTest
|
||||||
|
|
||||||
|
# FIXME Our Windows build server does not support GStreamer yet
|
||||||
|
import sys
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
raise SkipTest
|
||||||
|
|
||||||
from mopidy import settings
|
from mopidy import settings
|
||||||
from mopidy.outputs.gstreamer import GStreamerOutput
|
from mopidy.outputs.gstreamer import GStreamerOutput
|
||||||
from mopidy.utils.path import path_to_uri
|
from mopidy.utils.path import path_to_uri
|
||||||
from mopidy.utils.process import pickle_connection
|
from mopidy.utils.process import pickle_connection
|
||||||
|
|
||||||
from tests import data_folder, SkipTest
|
from tests import data_folder
|
||||||
|
|
||||||
class GStreamerOutputTest(unittest.TestCase):
|
class GStreamerOutputTest(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.original_backends = settings.BACKENDS
|
|
||||||
settings.BACKENDS = ('mopidy.backends.local.LocalBackend',)
|
settings.BACKENDS = ('mopidy.backends.local.LocalBackend',)
|
||||||
self.song_uri = path_to_uri(data_folder('song1.wav'))
|
self.song_uri = path_to_uri(data_folder('song1.wav'))
|
||||||
self.output_queue = multiprocessing.Queue()
|
|
||||||
self.core_queue = multiprocessing.Queue()
|
self.core_queue = multiprocessing.Queue()
|
||||||
self.output = GStreamerOutput(self.core_queue, self.output_queue)
|
self.output = GStreamerOutput(self.core_queue)
|
||||||
|
self.output.start()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.output.destroy()
|
self.output.destroy()
|
||||||
settings.BACKENDS = settings.original_backends
|
settings.runtime.clear()
|
||||||
|
|
||||||
def send_recv(self, message):
|
|
||||||
(my_end, other_end) = multiprocessing.Pipe()
|
|
||||||
message.update({'reply_to': pickle_connection(other_end)})
|
|
||||||
self.output_queue.put(message)
|
|
||||||
my_end.poll(None)
|
|
||||||
return my_end.recv()
|
|
||||||
|
|
||||||
|
|
||||||
def send(self, message):
|
|
||||||
self.output_queue.put(message)
|
|
||||||
|
|
||||||
def test_play_uri_existing_file(self):
|
def test_play_uri_existing_file(self):
|
||||||
message = {'command': 'play_uri', 'uri': self.song_uri}
|
self.assertTrue(self.output.play_uri(self.song_uri))
|
||||||
self.assertEqual(True, self.send_recv(message))
|
|
||||||
|
|
||||||
def test_play_uri_non_existing_file(self):
|
def test_play_uri_non_existing_file(self):
|
||||||
message = {'command': 'play_uri', 'uri': self.song_uri + 'bogus'}
|
self.assertFalse(self.output.play_uri(self.song_uri + 'bogus'))
|
||||||
self.assertEqual(False, self.send_recv(message))
|
|
||||||
|
@SkipTest
|
||||||
|
def test_deliver_data(self):
|
||||||
|
pass # TODO
|
||||||
|
|
||||||
|
@SkipTest
|
||||||
|
def test_end_of_data_stream(self):
|
||||||
|
pass # TODO
|
||||||
|
|
||||||
def test_default_get_volume_result(self):
|
def test_default_get_volume_result(self):
|
||||||
message = {'command': 'get_volume'}
|
self.assertEqual(100, self.output.get_volume())
|
||||||
self.assertEqual(100, self.send_recv(message))
|
|
||||||
|
|
||||||
def test_set_volume(self):
|
def test_set_volume(self):
|
||||||
self.send({'command': 'set_volume', 'volume': 50})
|
self.assertTrue(self.output.set_volume(50))
|
||||||
self.assertEqual(50, self.send_recv({'command': 'get_volume'}))
|
self.assertEqual(50, self.output.get_volume())
|
||||||
|
|
||||||
def test_set_volume_to_zero(self):
|
def test_set_volume_to_zero(self):
|
||||||
self.send({'command': 'set_volume', 'volume': 0})
|
self.assertTrue(self.output.set_volume(0))
|
||||||
self.assertEqual(0, self.send_recv({'command': 'get_volume'}))
|
self.assertEqual(0, self.output.get_volume())
|
||||||
|
|
||||||
def test_set_volume_to_one_hundred(self):
|
def test_set_volume_to_one_hundred(self):
|
||||||
self.send({'command': 'set_volume', 'volume': 100})
|
self.assertTrue(self.output.set_volume(100))
|
||||||
self.assertEqual(100, self.send_recv({'command': 'get_volume'}))
|
self.assertEqual(100, self.output.get_volume())
|
||||||
|
|
||||||
@SkipTest
|
@SkipTest
|
||||||
def test_set_state(self):
|
def test_set_state(self):
|
||||||
raise NotImplementedError
|
pass # TODO
|
||||||
|
|
||||||
|
@SkipTest
|
||||||
|
def test_set_position(self):
|
||||||
|
pass # TODO
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from mopidy.utils.settings import validate_settings
|
from mopidy import settings as default_settings_module
|
||||||
|
from mopidy.utils.settings import validate_settings, SettingsProxy
|
||||||
|
|
||||||
class ValidateSettingsTest(unittest.TestCase):
|
class ValidateSettingsTest(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@ -43,3 +44,24 @@ class ValidateSettingsTest(unittest.TestCase):
|
|||||||
result = validate_settings(self.defaults,
|
result = validate_settings(self.defaults,
|
||||||
{'FOO': '', 'BAR': ''})
|
{'FOO': '', 'BAR': ''})
|
||||||
self.assertEquals(len(result), 2)
|
self.assertEquals(len(result), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsProxyTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.settings = SettingsProxy(default_settings_module)
|
||||||
|
|
||||||
|
def test_set_and_get_attr(self):
|
||||||
|
self.settings.TEST = 'test'
|
||||||
|
self.assertEqual(self.settings.TEST, 'test')
|
||||||
|
|
||||||
|
def test_setattr_updates_runtime_settings(self):
|
||||||
|
self.settings.TEST = 'test'
|
||||||
|
self.assert_('TEST' in self.settings.runtime)
|
||||||
|
|
||||||
|
def test_setattr_updates_runtime_with_value(self):
|
||||||
|
self.settings.TEST = 'test'
|
||||||
|
self.assertEqual(self.settings.runtime['TEST'], 'test')
|
||||||
|
|
||||||
|
def test_runtime_value_included_in_current(self):
|
||||||
|
self.settings.TEST = 'test'
|
||||||
|
self.assertEqual(self.settings.current['TEST'], 'test')
|
||||||
|
|||||||
@ -11,7 +11,7 @@ class VersionTest(unittest.TestCase):
|
|||||||
self.assert_(SV('0.1.0a0') < SV('0.1.0a1'))
|
self.assert_(SV('0.1.0a0') < SV('0.1.0a1'))
|
||||||
self.assert_(SV('0.1.0a1') < SV('0.1.0a2'))
|
self.assert_(SV('0.1.0a1') < SV('0.1.0a2'))
|
||||||
self.assert_(SV('0.1.0a2') < SV('0.1.0a3'))
|
self.assert_(SV('0.1.0a2') < SV('0.1.0a3'))
|
||||||
self.assert_(SV('0.1.0a3') < SV(get_version()))
|
self.assert_(SV('0.1.0a3') < SV('0.1.0'))
|
||||||
self.assert_(SV(get_version()) < SV('0.1.1'))
|
self.assert_(SV('0.1.0') < SV(get_version()))
|
||||||
self.assert_(SV('0.1.1') < SV('0.2.0'))
|
self.assert_(SV(get_version()) < SV('0.2.1'))
|
||||||
self.assert_(SV('0.2.0') < SV('1.0.0'))
|
self.assert_(SV('0.2.0') < SV('1.0.0'))
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user