Merge branch 'develop' into feature/multi-backend
Conflicts: mopidy/backends/local/__init__.py mopidy/outputs/gstreamer.py tests/frontends/mpd/audio_output_test.py tests/frontends/mpd/command_list_test.py tests/frontends/mpd/connection_test.py tests/frontends/mpd/current_playlist_test.py tests/frontends/mpd/dispatcher_test.py tests/frontends/mpd/music_db_test.py tests/frontends/mpd/playback_test.py tests/frontends/mpd/reflection_test.py tests/frontends/mpd/regression_test.py tests/frontends/mpd/status_test.py tests/frontends/mpd/stickers_test.py tests/frontends/mpd/stored_playlists_test.py
This commit is contained in:
commit
5f7988d974
3
.gitignore
vendored
3
.gitignore
vendored
@ -2,11 +2,12 @@
|
||||
*.swp
|
||||
.coverage
|
||||
.noseids
|
||||
.tox
|
||||
MANIFEST
|
||||
build/
|
||||
cover/
|
||||
coverage.xml
|
||||
dist/
|
||||
docs/_build/
|
||||
mopidy.log
|
||||
mopidy.log*
|
||||
nosetests.xml
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
include LICENSE pylintrc *.rst data/mopidy.desktop
|
||||
include mopidy/backends/libspotify/spotify_appkey.key
|
||||
include mopidy/backends/spotify/spotify_appkey.key
|
||||
recursive-include docs *
|
||||
prune docs/_build
|
||||
recursive-include requirements *
|
||||
|
||||
15
README.rst
15
README.rst
@ -6,14 +6,15 @@ Mopidy is a music server which can play music from `Spotify
|
||||
<http://www.spotify.com/>`_ or from your local hard drive. To search for music
|
||||
in Spotify's vast archive, manage playlists, and play music, you can use most
|
||||
`MPD clients <http://mpd.wikia.com/>`_. MPD clients are available for most
|
||||
platforms, including Windows, Mac OS X, Linux, and iPhone and Android phones.
|
||||
platforms, including Windows, Mac OS X, Linux, Android and iOS.
|
||||
|
||||
To install Mopidy, check out
|
||||
`the installation docs <http://www.mopidy.com/docs/master/installation/>`_.
|
||||
|
||||
* `Documentation (latest release) <http://www.mopidy.com/docs/master/>`_
|
||||
* `Documentation (development version) <http://www.mopidy.com/docs/develop/>`_
|
||||
* `Source code <http://github.com/mopidy/mopidy>`_
|
||||
* `Issue tracker <http://github.com/mopidy/mopidy/issues>`_
|
||||
* IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
|
||||
* `Download development snapshot <http://github.com/mopidy/mopidy/tarball/develop#egg=mopidy-dev>`_
|
||||
- `Documentation for the latest release <http://www.mopidy.com/docs/master/>`_
|
||||
- `Documentation for the development version
|
||||
<http://www.mopidy.com/docs/develop/>`_
|
||||
- `Source code <http://github.com/mopidy/mopidy>`_
|
||||
- `Issue tracker <http://github.com/mopidy/mopidy/issues>`_
|
||||
- IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
|
||||
- `Download development snapshot <http://github.com/mopidy/mopidy/tarball/develop#egg=mopidy-dev>`_
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
#! /usr/bin/env python
|
||||
|
||||
if __name__ == '__main__':
|
||||
from mopidy.__main__ import main
|
||||
from mopidy.core import main
|
||||
main()
|
||||
|
||||
BIN
docs/_static/mopidy.png
vendored
Normal file
BIN
docs/_static/mopidy.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
267
docs/changes.rst
267
docs/changes.rst
@ -5,21 +5,134 @@ Changes
|
||||
This change log is used to track all major changes to Mopidy.
|
||||
|
||||
|
||||
0.3.0 (in development)
|
||||
0.4.0 (in development)
|
||||
======================
|
||||
|
||||
No description yet.
|
||||
|
||||
|
||||
**Important changes**
|
||||
|
||||
- Mopidy now depends on `Pykka <http://jodal.github.com/pykka>`_ >=0.12. If you
|
||||
install from APT, Pykka will automatically be installed. If you are not
|
||||
installing from APT, you may install Pykka from PyPI::
|
||||
|
||||
sudo pip install -U Pykka
|
||||
|
||||
- If you use the Spotify backend, you *should* upgrade to libspotify 0.0.7 and
|
||||
the latest pyspotify from the Mopidy developers. If you install from APT,
|
||||
libspotify and pyspotify will automatically be upgraded. If you are not
|
||||
installing from APT, follow the instructions at
|
||||
:doc:`/installation/libspotify/`.
|
||||
|
||||
|
||||
**Changes**
|
||||
|
||||
- Mopidy now use Pykka actors for thread management and inter-thread
|
||||
communication. The immediate advantage of this is that Mopidy now works on
|
||||
Python 2.7. (Fixes: :issue:`66`)
|
||||
|
||||
- Spotify backend:
|
||||
|
||||
- If you use the Spotify backend, you need to upgrade to libspotify 0.0.6 and
|
||||
the latest pyspotify from the Mopidy developers. Follow the instructions at
|
||||
:doc:`/installation/libspotify/`.
|
||||
- Fixed multiple segmentation faults due to bugs in Pyspotify. Thanks to
|
||||
Antoine Pierlot-Garcin and Jamie Kirkpatrick for patches to Pyspotify.
|
||||
|
||||
- Support high bitrate (320k) audio. See
|
||||
:attr:`mopidy.settings.SPOTIFY_HIGH_BITRATE` for details.
|
||||
- Better error messages on wrong login or network problems. Thanks to Antoine
|
||||
Pierlot-Garcin for patches to Mopidy and Pyspotify. (Fixes: :issue:`77`)
|
||||
|
||||
- Reduce log level for trivial log messages from warning to info. (Fixes:
|
||||
:issue:`71`)
|
||||
|
||||
- Local backend:
|
||||
|
||||
- Fix crash in :command:`mopidy-scan` if a track has no artist name. Thanks
|
||||
to Martins Grunskis for test and patch and "octe" for patch.
|
||||
|
||||
- Fix crash in `tag_cache` parsing if a track has no total number of tracks
|
||||
in the album. Thanks to Martins Grunskis for the patch.
|
||||
|
||||
- MPD frontend:
|
||||
|
||||
- Add support for "date" queries to both the ``find`` and ``search``
|
||||
commands. This makes media library browsing in ncmpcpp work, though very
|
||||
slow due to all the meta data requests to Spotify.
|
||||
|
||||
- Add support for ``play "-1"`` when in playing or paused state, which fixes
|
||||
resume and addition of tracks to the current playlist while playing for the
|
||||
MPoD client.
|
||||
|
||||
- Fix bug where ``status`` returned ``song: None``, which caused MPDroid to
|
||||
crash. (Fixes: :issue:`69`)
|
||||
|
||||
- Settings:
|
||||
|
||||
- Fix crash on ``--list-settings`` on clean installation. Thanks to Martins
|
||||
Grunskis for the bug report and patch. (Fixes: :issue:`63`)
|
||||
|
||||
- Packaging:
|
||||
|
||||
- Replace test data symlinks with real files to avoid symlink issues when
|
||||
installing with pip. (Fixes: :issue:`68`)
|
||||
|
||||
|
||||
0.3.1 (2010-01-22)
|
||||
==================
|
||||
|
||||
A couple of fixes to the 0.3.0 release is needed to get a smooth installation.
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- The Spotify application key was missing from the Python package.
|
||||
|
||||
- Installation of the Python package as a normal user failed because it did not
|
||||
have permissions to install ``mopidy.desktop``. The file is now only
|
||||
installed if the installation is executed as the root user.
|
||||
|
||||
|
||||
0.3.0 (2010-01-22)
|
||||
==================
|
||||
|
||||
Mopidy 0.3.0 brings a bunch of small changes all over the place, but no large
|
||||
changes. The main features are support for high bitrate audio from Spotify, and
|
||||
MPD password authentication.
|
||||
|
||||
Regarding the docs, we've improved the :ref:`installation instructions
|
||||
<installation>` and done a bit of testing of the available :ref:`Android
|
||||
<android_mpd_clients>` and :ref:`iOS clients <ios_mpd_clients>` for MPD.
|
||||
|
||||
Please note that 0.3.0 requires some updated dependencies, as listed under
|
||||
*Important changes* below. Also, there is a known bug in the Spotify playlist
|
||||
loading, as described below. As the bug will take some time to fix and has a
|
||||
known workaround, we did not want to delay the release while waiting for a fix
|
||||
to this problem.
|
||||
|
||||
|
||||
.. warning:: Known bug in Spotify playlist loading
|
||||
|
||||
There is a known bug in the loading of Spotify playlists. This bug affects
|
||||
both Mopidy 0.2.1 and 0.3.0, given that you use libspotify 0.0.6. To avoid
|
||||
the bug, either use Mopidy 0.2.1 with libspotify 0.0.4, or use either
|
||||
Mopidy version with libspotify 0.0.6 and follow the simple workaround
|
||||
described at :issue:`59`.
|
||||
|
||||
|
||||
**Important changes**
|
||||
|
||||
- If you use the Spotify backend, you need to upgrade to libspotify 0.0.6 and
|
||||
the latest pyspotify from the Mopidy developers. Follow the instructions at
|
||||
:doc:`/installation/libspotify/`.
|
||||
|
||||
- If you use the Last.fm frontend, you need to upgrade to pylast 0.5.7. Run
|
||||
``sudo pip install --upgrade pylast`` or install Mopidy from APT.
|
||||
|
||||
|
||||
**Changes**
|
||||
|
||||
- Spotify backend:
|
||||
|
||||
- Support high bitrate (320k) audio. Set the new setting
|
||||
:attr:`mopidy.settings.SPOTIFY_HIGH_BITRATE` to :class:`True` to switch to
|
||||
high bitrate audio.
|
||||
|
||||
- Rename :mod:`mopidy.backends.libspotify` to :mod:`mopidy.backends.spotify`.
|
||||
If you have set :attr:`mopidy.settings.BACKENDS` explicitly, you may need
|
||||
@ -27,78 +140,106 @@ No description yet.
|
||||
|
||||
- Catch and log error caused by playlist folder boundaries being threated as
|
||||
normal playlists. More permanent fix requires support for checking playlist
|
||||
types in pyspotify.
|
||||
types in pyspotify (see :issue:`62`).
|
||||
|
||||
- Last.fm frontend:
|
||||
|
||||
- If you use the Last.fm frontend, you need to upgrade to pylast 0.5.
|
||||
|
||||
- Update to use Last.fm's new Scrobbling 2.0 API, as the old Submissions
|
||||
Protocol 1.2.1 is deprecated. (Fixes: :issue:`33`)
|
||||
|
||||
|
||||
**Changes**
|
||||
|
||||
- Settings:
|
||||
|
||||
- Automatically expand ``~`` to the user's home directory and make the path
|
||||
absolute for settings with names ending in ``_PATH`` or ``_FILE``.
|
||||
- Rename the following settings. The settings validator will warn you if you
|
||||
need to change your local settings.
|
||||
|
||||
- ``LOCAL_MUSIC_FOLDER`` to :attr:`mopidy.settings.LOCAL_MUSIC_PATH`
|
||||
- ``LOCAL_PLAYLIST_FOLDER`` to
|
||||
:attr:`mopidy.settings.LOCAL_PLAYLIST_PATH`
|
||||
- ``LOCAL_TAG_CACHE`` to :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE`
|
||||
- ``SPOTIFY_LIB_CACHE`` to :attr:`mopidy.settings.SPOTIFY_CACHE_PATH`
|
||||
|
||||
- Packaging and distribution:
|
||||
|
||||
- Install ``mopidy.desktop`` file that makes Mopidy available from e.g. Gnome
|
||||
application menus.
|
||||
- Create infrastructure for creating Debian packages of Mopidy.
|
||||
|
||||
- MPD frontend:
|
||||
|
||||
- Support ``setvol 50`` without quotes around the argument. Fixes volume
|
||||
control in Droid MPD.
|
||||
- Support ``seek 1 120`` without quotes around the arguments. Fixes seek in
|
||||
Droid MPD.
|
||||
- Fix crash on failed lookup of track by URI. (Fixes: :issue:`60`)
|
||||
|
||||
- Local backend:
|
||||
|
||||
- Add :command:`mopidy-scan` command to generate ``tag_cache`` files without
|
||||
any help from the original MPD server.
|
||||
- Support UTF-8 encoded tag caches with non-ASCII characters.
|
||||
any help from the original MPD server. See :ref:`generating_a_tag_cache`
|
||||
for instructions on how to use it.
|
||||
|
||||
- Models:
|
||||
- Fix support for UTF-8 encoding in tag caches.
|
||||
|
||||
- MPD frontend:
|
||||
|
||||
- Add support for password authentication. See
|
||||
:attr:`mopidy.settings.MPD_SERVER_PASSWORD` and
|
||||
:ref:`use_mpd_on_a_network` for details on how to use it. (Fixes:
|
||||
:issue:`41`)
|
||||
|
||||
- Support ``setvol 50`` without quotes around the argument. Fixes volume
|
||||
control in Droid MPD.
|
||||
|
||||
- Support ``seek 1 120`` without quotes around the arguments. Fixes seek in
|
||||
Droid MPD.
|
||||
|
||||
- Last.fm frontend:
|
||||
|
||||
- Update to use Last.fm's new Scrobbling 2.0 API, as the old Submissions
|
||||
Protocol 1.2.1 is deprecated. (Fixes: :issue:`33`)
|
||||
|
||||
- Fix crash when track object does not contain all the expected meta data.
|
||||
|
||||
- Fix crash when response from Last.fm cannot be decoded as UTF-8. (Fixes:
|
||||
:issue:`37`)
|
||||
|
||||
- Fix crash when response from Last.fm contains invalid XML.
|
||||
|
||||
- Fix crash when response from Last.fm has an invalid HTTP status line.
|
||||
|
||||
- Mixers:
|
||||
|
||||
- Support use of unicode strings for settings specific to
|
||||
:mod:`mopidy.mixers.nad`.
|
||||
|
||||
- Settings:
|
||||
|
||||
- Automatically expand the "~" characted to the user's home directory and
|
||||
make the path absolute for settings with names ending in ``_PATH`` or
|
||||
``_FILE``.
|
||||
|
||||
- Rename the following settings. The settings validator will warn you if you
|
||||
need to change your local settings.
|
||||
|
||||
- ``LOCAL_MUSIC_FOLDER`` to :attr:`mopidy.settings.LOCAL_MUSIC_PATH`
|
||||
- ``LOCAL_PLAYLIST_FOLDER`` to
|
||||
:attr:`mopidy.settings.LOCAL_PLAYLIST_PATH`
|
||||
- ``LOCAL_TAG_CACHE`` to :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE`
|
||||
- ``SPOTIFY_LIB_CACHE`` to :attr:`mopidy.settings.SPOTIFY_CACHE_PATH`
|
||||
|
||||
- Fix bug which made settings set to :class:`None` or 0 cause a
|
||||
:exc:`mopidy.SettingsError` to be raised.
|
||||
|
||||
- Packaging and distribution:
|
||||
|
||||
- Setup APT repository and crate Debian packages of Mopidy. See
|
||||
:ref:`installation` for instructions for how to install Mopidy, including
|
||||
all dependencies, from APT.
|
||||
|
||||
- Install ``mopidy.desktop`` file that makes Mopidy available from e.g. Gnome
|
||||
application menus.
|
||||
|
||||
- API:
|
||||
|
||||
- Rename and generalize ``Playlist._with(**kwargs)`` to
|
||||
:meth:`mopidy.models.ImmutableObject.copy`.
|
||||
|
||||
- Add ``musicbrainz_id`` field to :class:`mopidy.models.Artist`,
|
||||
:class:`mopidy.models.Album`, and :class:`mopidy.models.Track`.
|
||||
|
||||
- Introduce the :ref:`provider concept <backend-concepts>`. Split the backend
|
||||
API into a :ref:`backend controller API <backend-controller-api>` (for
|
||||
frontend use) and a :ref:`backend provider API <backend-provider-api>` (for
|
||||
backend implementation use), which includes the following changes:
|
||||
- Prepare for multi-backend support (see :issue:`40`) by introducing the
|
||||
:ref:`provider concept <backend-concepts>`. Split the backend API into a
|
||||
:ref:`backend controller API <backend-controller-api>` (for frontend use)
|
||||
and a :ref:`backend provider API <backend-provider-api>` (for backend
|
||||
implementation use), which includes the following changes:
|
||||
|
||||
- Rename ``BaseBackend`` to :class:`mopidy.backends.base.Backend`.
|
||||
- Rename ``BaseCurrentPlaylistController`` to
|
||||
:class:`mopidy.backends.base.CurrentPlaylistController`.
|
||||
- Split ``BaseLibraryController`` to
|
||||
:class:`mopidy.backends.base.LibraryController` and
|
||||
:class:`mopidy.backends.base.BaseLibraryProvider`.
|
||||
- Split ``BasePlaybackController`` to
|
||||
:class:`mopidy.backends.base.PlaybackController` and
|
||||
:class:`mopidy.backends.base.BasePlaybackProvider`.
|
||||
- Split ``BaseStoredPlaylistsController`` to
|
||||
:class:`mopidy.backends.base.StoredPlaylistsController` and
|
||||
:class:`mopidy.backends.base.BaseStoredPlaylistsProvider`.
|
||||
|
||||
- Other API and package structure cleaning:
|
||||
- Rename ``BaseBackend`` to :class:`mopidy.backends.base.Backend`.
|
||||
- Rename ``BaseCurrentPlaylistController`` to
|
||||
:class:`mopidy.backends.base.CurrentPlaylistController`.
|
||||
- Split ``BaseLibraryController`` to
|
||||
:class:`mopidy.backends.base.LibraryController` and
|
||||
:class:`mopidy.backends.base.BaseLibraryProvider`.
|
||||
- Split ``BasePlaybackController`` to
|
||||
:class:`mopidy.backends.base.PlaybackController` and
|
||||
:class:`mopidy.backends.base.BasePlaybackProvider`.
|
||||
- Split ``BaseStoredPlaylistsController`` to
|
||||
:class:`mopidy.backends.base.StoredPlaylistsController` and
|
||||
:class:`mopidy.backends.base.BaseStoredPlaylistsProvider`.
|
||||
|
||||
- Move ``BaseMixer`` to :class:`mopidy.mixers.base.BaseMixer`.
|
||||
|
||||
- Add docs for the current non-stable output API,
|
||||
:class:`mopidy.outputs.base.BaseOutput`.
|
||||
|
||||
|
||||
@ -16,11 +16,14 @@ 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.
|
||||
support yet (see :issue:`32`). If you want a console client, use ncmpcpp
|
||||
instead.
|
||||
|
||||
|
||||
ncmpcpp
|
||||
-------
|
||||
@ -28,6 +31,9 @@ ncmpcpp
|
||||
A console client that generally works well with Mopidy, and is regularly used
|
||||
by Mopidy developers.
|
||||
|
||||
Search
|
||||
^^^^^^
|
||||
|
||||
Search only works for ncmpcpp versions 0.5.1 and higher, and in two of the
|
||||
three search modes:
|
||||
|
||||
@ -39,6 +45,18 @@ three search modes:
|
||||
If you run Ubuntu 10.04 or older, you can fetch an updated version of ncmpcpp
|
||||
from `Launchpad <https://launchpad.net/ubuntu/+source/ncmpcpp>`_.
|
||||
|
||||
Communication mode
|
||||
^^^^^^^^^^^^^^^^^^
|
||||
|
||||
In newer versions of ncmpcpp, like 0.5.5 shipped with Ubuntu 11.04, ncmcpp
|
||||
defaults to "notifications" mode for MPD communications, which Mopidy currently
|
||||
does not support. To workaround this limitation in Mopidy, edit the ncmpcpp
|
||||
configuration file at ``~/.ncmpcpp/config`` and add the following setting::
|
||||
|
||||
mpd_communication_mode = "polling"
|
||||
|
||||
You can track the development of "notifications" mode support in Mopidy in
|
||||
:issue:`32`.
|
||||
|
||||
|
||||
Graphical clients
|
||||
@ -47,52 +65,260 @@ Graphical clients
|
||||
GMPC
|
||||
----
|
||||
|
||||
A GTK+ client which works well with Mopidy, and is regularly used by Mopidy
|
||||
developers.
|
||||
`GMPC <http://gmpc.wikia.com>`_ is a graphical MPD client (GTK+) which works
|
||||
well with Mopidy, and is regularly used by Mopidy developers.
|
||||
|
||||
GMPC may sometimes requests a lot of meta data of related albums, artists, etc.
|
||||
This takes more time with Mopidy, which needs to query Spotify for the data,
|
||||
than with a normal MPD server, which has a local cache of meta data. Thus, GMPC
|
||||
may sometimes feel frozen, but usually you just need to give it a bit of slack
|
||||
before it will catch up.
|
||||
|
||||
|
||||
Sonata
|
||||
------
|
||||
|
||||
A GTK+ client. Generally works well with Mopidy.
|
||||
`Sonata <http://sonata.berlios.de/>`_ is a graphical MPD client (GTK+).
|
||||
It generally works well with Mopidy, except for search.
|
||||
|
||||
Search does not work, because they do most of the search on the client side.
|
||||
See :issue:`1` for details.
|
||||
When you search in Sonata, it only sends the first to letters of the search
|
||||
query to Mopidy, and then does the rest of the filtering itself on the client
|
||||
side. Since Spotify has a collection of millions of tracks and they only return
|
||||
the first 100 hits for any search query, searching for two-letter combinations
|
||||
seldom returns any useful results. See :issue:`1` and the matching `Sonata
|
||||
bug`_ for details.
|
||||
|
||||
.. _Sonata bug: http://developer.berlios.de/feature/?func=detailfeature&feature_id=5038&group_id=7323
|
||||
|
||||
|
||||
Theremin
|
||||
--------
|
||||
|
||||
`Theremin <http://theremin.sigterm.eu/>`_ is a graphical MPD client for OS X.
|
||||
It generally works well with Mopidy.
|
||||
|
||||
|
||||
.. _android_mpd_clients:
|
||||
|
||||
Android clients
|
||||
===============
|
||||
|
||||
We've tested all six MPD clients we could find for Android with Mopidy 0.3 on a
|
||||
HTC Hero with Android 2.1, using the following test procedure:
|
||||
|
||||
#. Connect to Mopidy
|
||||
#. Search for ``foo``, with search type "any" if it can be selected
|
||||
#. Add "The Pretender" from the search results to the current playlist
|
||||
#. Start playback
|
||||
#. Pause and resume playback
|
||||
#. Adjust volume
|
||||
#. Find a playlist and append it to the current playlist
|
||||
#. Skip to next track
|
||||
#. Skip to previous track
|
||||
#. Select the last track from the current playlist
|
||||
#. Turn on repeat mode
|
||||
#. Seek to 10 seconds or so before the end of the track
|
||||
#. Wait for the end of the track and confirm that playback continues at the
|
||||
start of the playlist
|
||||
#. Turn off repeat mode
|
||||
#. Turn on random mode
|
||||
#. Skip to next track and confirm that it random mode works
|
||||
#. Turn off random mode
|
||||
#. Stop playback
|
||||
#. Check if the app got support for single mode and consume mode
|
||||
#. Kill Mopidy and confirm that the app handles it without crashing
|
||||
|
||||
In summary:
|
||||
|
||||
- BitMPC lacks finishing touches on its user interface but supports all
|
||||
features tested.
|
||||
- Droid MPD Client works well, but got a couple of bugs one can live with and
|
||||
does not expose stored playlist anywhere.
|
||||
- IcyBeats is not usable yet.
|
||||
- MPDroid is working well and looking good, but does not have search
|
||||
functionality.
|
||||
- PMix is just a lesser MPDroid, so use MPDroid instead.
|
||||
- ThreeMPD is too buggy to even get connected to Mopidy.
|
||||
|
||||
Our recommendation:
|
||||
|
||||
- If you do not care about looks, use BitMPC.
|
||||
- If you do not care about stored playlists, use Droid MPD Client.
|
||||
- If you do not care about searching, use MPDroid.
|
||||
|
||||
|
||||
BitMPC
|
||||
------
|
||||
|
||||
Works well with Mopidy.
|
||||
We tested version 1.0.0, which at the time had 1k-5k downloads, <100 ratings,
|
||||
3.5 stars.
|
||||
|
||||
Droid MPD
|
||||
---------
|
||||
The user interface lacks some finishing touches. E.g. you can't enter a
|
||||
hostname for the server. Only IPv4 addresses are allowed.
|
||||
|
||||
All features exercised in the test procedure works. BitMPC lacks support for
|
||||
single mode and consume mode. BitMPC crashes if Mopidy is killed or crash.
|
||||
|
||||
|
||||
Droid MPD Client
|
||||
----------------
|
||||
|
||||
We tested version 0.4.0, which at the time had 5k-10k downloads, >200 ratings,
|
||||
4 stars.
|
||||
|
||||
To find the search functionality, you have to select the menu, then "Playlist
|
||||
manager", then the search tab. I do not understand why search is hidden inside
|
||||
"Playlist manager".
|
||||
|
||||
The user interface have some French remnants, like "Rechercher" in the search
|
||||
field.
|
||||
|
||||
When selecting the artist tab, it issues the ``list Artist`` command and
|
||||
becomes stuck waiting for the results. Same thing happens for the album tab,
|
||||
which issues ``list Album``, and the folder tab, which issues ``lsinfo``.
|
||||
Mopidy returned zero hits immediately on all three commands. If Mopidy has
|
||||
loaded your stored playlists and returns more than zero hits on these commands,
|
||||
they artist and album tabs do not hang. The folder tab still freezes when
|
||||
``lsinfo`` returns a list of stored playlists, though zero files. Thus, we've
|
||||
discovered a couple of bugs in Droid MPD Client.
|
||||
|
||||
The volume control is very slick, with a turn knob, just like on an amplifier.
|
||||
It lends itself to showing off to friends when combined with Mopidy's external
|
||||
amplifier mixers. Everybody loves turning a knob on a touch screen and see the
|
||||
physical knob on the amplifier turn as well ;-)
|
||||
|
||||
Even though ``lsinfo`` returns the stored playlists for the folder tab, they
|
||||
are not displayed anywhere. Thus, we had to select an album in the album tab to
|
||||
complete the test procedure.
|
||||
|
||||
At one point, I had problems turning off repeat mode. After I adjusted the
|
||||
volume and tried again, it worked.
|
||||
|
||||
Droid MPD client does not support single mode or consume mode. It does not
|
||||
detect that the server is killed/crashed. You'll only notice it by no actions
|
||||
having any effect, e.g. you can't turn the volume knob any more.
|
||||
|
||||
In conclusion, some bugs and caveats, but most of the test procedure was
|
||||
possible to perform.
|
||||
|
||||
|
||||
IcyBeats
|
||||
--------
|
||||
|
||||
We tested version 0.2, which at the time had 50-100 downloads, no ratings.
|
||||
The app was still in beta when we tried it.
|
||||
|
||||
IcyBeats successfully connected to Mopidy and I was able to adjust volume. When
|
||||
I was searching for some tracks, I could not figure out how to actually start
|
||||
the search, as there was no search button and pressing enter in the input field
|
||||
just added a new line. I was stuck. In other words, IcyBeats 0.2 is not usable
|
||||
with Mopidy.
|
||||
|
||||
IcyBeats does have something going for it: IcyBeats uses IPv6 to connect to
|
||||
Mopidy. The future is just around the corner!
|
||||
|
||||
Works well with Mopidy.
|
||||
|
||||
MPDroid
|
||||
-------
|
||||
|
||||
Works well with Mopidy, and is regularly used by Mopidy developers.
|
||||
We tested version 0.6.9, which at the time had 5k-10k downloads, <200 ratings,
|
||||
4.5 stars. MPDroid started out as a fork of PMix.
|
||||
|
||||
First of all, MPDroid's user interface looks nice.
|
||||
|
||||
I couldn't find any search functionality, so I added the initial track using
|
||||
another client. Other than the missing search functionality, everything in the
|
||||
test procedure worked out flawlessly. Like all other Android clients, MPDroid
|
||||
does not support single mode or consume mode. When Mopidy is killed, MPDroid
|
||||
handles it gracefully and asks if you want to try to reconnect.
|
||||
|
||||
All in all, MPDroid is a good MPD client without search support.
|
||||
|
||||
|
||||
PMix
|
||||
----
|
||||
|
||||
Works well with Mopidy.
|
||||
We tested version 0.4.0, which at the time had 10k-50k downloads, >200 ratings,
|
||||
4 stars.
|
||||
|
||||
Add MPDroid is a fork from PMix, it is no surprise that PMix does not support
|
||||
search either. In addition, I could not find stored playlists. Other than that,
|
||||
I was able to complete the test procedure. PMix crashed once during testing,
|
||||
but handled the killing of Mopidy just as nicely as MPDroid. It does not
|
||||
support single mode or consume mode.
|
||||
|
||||
All in all, PMix works but can do less than MPDroid. Use MPDroid instead.
|
||||
|
||||
|
||||
ThreeMPD
|
||||
--------
|
||||
|
||||
Does not work well with Mopidy, because we haven't implemented ``listallinfo``
|
||||
yet.
|
||||
We tested version 0.3.0, which at the time had 1k-5k downloads, <25 ratings,
|
||||
2.5 average. The developer request users to use MPDroid instead, due to limited
|
||||
time for maintenance. Does not support password authentication.
|
||||
|
||||
ThreeMPD froze during startup, so we were not able to test it.
|
||||
|
||||
|
||||
.. _ios_mpd_clients:
|
||||
|
||||
iPhone/iPod Touch clients
|
||||
=========================
|
||||
|
||||
impdclient
|
||||
----------
|
||||
|
||||
There's an open source MPD client for iOS called `impdclient
|
||||
<http://code.google.com/p/impdclient/>`_ which has not seen any updates since
|
||||
August 2008. So far, we've not heard of users trying it with Mopidy. Please
|
||||
notify us of your successes and/or problems if you do try it out.
|
||||
|
||||
|
||||
MPod
|
||||
----
|
||||
|
||||
Works well with Mopidy as far as we've heard from users.
|
||||
The `MPoD <http://www.katoemba.net/makesnosenseatall/mpod/>`_ client can be
|
||||
installed from the `iTunes Store
|
||||
<http://itunes.apple.com/us/app/mpod/id285063020>`_.
|
||||
|
||||
Users have reported varying success in using MPoD together with Mopidy. Thus,
|
||||
we've tested a fresh install of MPoD 1.5.1 with Mopidy as of revision e7ed28d
|
||||
(pre-0.3) on an iPod Touch 3rd generation. The following are our findings:
|
||||
|
||||
- **Works:** Playback control generally works, including stop, play, pause,
|
||||
previous, next, repeat, random, seek, and volume control.
|
||||
|
||||
- **Bug:** Search does not work, neither in the artist, album, or song
|
||||
tabs. Mopidy gets no requests at all from MPoD when executing searches. Seems
|
||||
like MPoD only searches in local cache, even if "Use local cache" is turned
|
||||
off in MPoD's settings. Until this is fixed by the MPoD developer, MPoD will
|
||||
be much less useful with Mopidy.
|
||||
|
||||
- **Bug:** When adding another playlist to the current playlist in MPoD,
|
||||
the currently playing track restarts at the beginning. I do not currently
|
||||
know enough about this bug, because I'm not sure if MPoD was in the "add to
|
||||
active playlist" or "replace active playlist" mode when I tested it. I only
|
||||
later learned what that button was for. Anyway, what I experienced was:
|
||||
|
||||
#. I play a track
|
||||
#. I select a new playlist
|
||||
#. MPoD reconnects to Mopidy for unknown reason
|
||||
#. MPoD issues MPD command ``load "a playlist name"``
|
||||
#. MPoD issues MPD command ``play "-1"``
|
||||
#. MPoD issues MPD command ``playlistinfo "-1"``
|
||||
#. I hear that the currently playing tracks restarts playback
|
||||
|
||||
- **Tips:** MPoD seems to cache stored playlists, but they won't work if the
|
||||
server hasn't loaded stored playlists from e.g. Spotify yet. A trick to force
|
||||
refetching of playlists from Mopidy is to add a new empty playlist in MPoD.
|
||||
|
||||
- **Wishlist:** Modifying the current playlists is not supported by MPoD it
|
||||
seems.
|
||||
|
||||
- **Wishlist:** MPoD supports playback of Last.fm radio streams through the MPD
|
||||
server. Mopidy does not currently support this, but there is a wishlist bug
|
||||
at :issue:`38`.
|
||||
|
||||
- **Wishlist:** MPoD supports autodetection/-configuration of MPD servers
|
||||
through the use of Bonjour. Mopidy does not currently support this, but there
|
||||
is a wishlist bug at :issue:`39`.
|
||||
|
||||
@ -43,7 +43,7 @@ master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = u'Mopidy'
|
||||
copyright = u'2010, Stein Magnus Jodal and contributors'
|
||||
copyright = u'2010-2011, Stein Magnus Jodal and contributors'
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
@ -116,7 +116,7 @@ html_theme_path = ['_themes']
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top
|
||||
# of the sidebar.
|
||||
#html_logo = None
|
||||
html_logo = '_static/mopidy.png'
|
||||
|
||||
# The name of an image file (within the static path) to use as favicon of the
|
||||
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
|
||||
@ -153,7 +153,7 @@ html_last_updated_fmt = '%b %d, %Y'
|
||||
#html_split_index = False
|
||||
|
||||
# If true, links to the reST sources are added to the pages.
|
||||
html_show_sourcelink = False
|
||||
#html_show_sourcelink = True
|
||||
|
||||
# If true, an OpenSearch description file will be output, and all pages will
|
||||
# contain a <link> tag referring to it. The value of this option must be the
|
||||
@ -202,4 +202,4 @@ latex_documents = [
|
||||
|
||||
needs_sphinx = '1.0'
|
||||
|
||||
extlinks = {'issue': ('http://github.com/mopidy/mopidy/issues#issue/%s', 'GH-')}
|
||||
extlinks = {'issue': ('http://github.com/mopidy/mopidy/issues/%s', 'GH-')}
|
||||
|
||||
@ -74,11 +74,11 @@ Running tests
|
||||
To run tests, you need a couple of dependencies. They can be installed through
|
||||
Debian/Ubuntu package management::
|
||||
|
||||
sudo aptitude install python-coverage python-nose
|
||||
sudo aptitude install python-coverage python-mock python-nose
|
||||
|
||||
Or, they can be installed using ``pip``::
|
||||
|
||||
sudo pip install -r requirements-tests.txt
|
||||
sudo pip install -r requirements/tests.txt
|
||||
|
||||
Then, to run all tests, go to the project directory and run::
|
||||
|
||||
@ -107,14 +107,14 @@ For more documentation on testing, check out the `nose documentation
|
||||
Continuous integration server
|
||||
=============================
|
||||
|
||||
We run a continuous integration server called Hudson at
|
||||
http://hudson.mopidy.com/ that runs all test on multiple platforms (Ubuntu, OS
|
||||
X, etc.) for every commit we push to GitHub.
|
||||
We run a continuous integration (CI) server at http://ci.mopidy.com/ that runs
|
||||
all test on multiple platforms (Ubuntu, OS X, etc.) for every commit we push to
|
||||
GitHub.
|
||||
|
||||
In addition to running tests, Hudson also does coverage statistics and uses
|
||||
pylint to check for errors and possible improvements in our code. So, if you're
|
||||
out of work, the code coverage and pylint data in Hudson should give you a
|
||||
place to start.
|
||||
In addition to running tests, the CI server also gathers coverage statistics
|
||||
and uses pylint to check for errors and possible improvements in our code. So,
|
||||
if you're out of work, the code coverage and pylint data at the CI server
|
||||
should give you a place to start.
|
||||
|
||||
|
||||
Writing documentation
|
||||
|
||||
@ -2,88 +2,33 @@
|
||||
Roadmap
|
||||
*******
|
||||
|
||||
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
|
||||
time.
|
||||
|
||||
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.
|
||||
Release schedule
|
||||
================
|
||||
|
||||
We intend to have about one timeboxed feature release every month
|
||||
in periods of active development. The feature releases are numbered 0.x.0. The
|
||||
features added is a mix of what we feel is most important/requested of the
|
||||
missing features, and features we develop just because we find them fun to
|
||||
make, even though they may be useful for very few users or for a limited use
|
||||
case.
|
||||
|
||||
Bugfix releases, numbered 0.x.y, will be released whenever we discover bugs
|
||||
that are too serious to wait for the next feature release. We will only release
|
||||
bugfix releases for the last feature release. E.g. when 0.3.0 is released, we
|
||||
will no longer provide bugfix releases for the 0.2 series. In other words,
|
||||
there will be just a single supported release at any point in time.
|
||||
|
||||
|
||||
Possible targets for the next version
|
||||
=====================================
|
||||
Feature wishlist
|
||||
================
|
||||
|
||||
- Reintroduce support for OS X. See :issue:`25` for details.
|
||||
- **[WIP: feature/multi-backend]** Support for using multiple Mopidy backends
|
||||
simultaneously. Should make it possible to have both Spotify tracks and local
|
||||
tracks in the same playlist.
|
||||
- MPD frontend:
|
||||
|
||||
- **[WIP: feature/mpd-password]** Password authentication.
|
||||
- ``idle`` support.
|
||||
|
||||
- Spotify backend:
|
||||
|
||||
- Write-support for Spotify, i.e. playlist management.
|
||||
- Virtual directories with e.g. starred tracks from Spotify.
|
||||
- **[DONE: v0.3]** Support for 320 kbps audio.
|
||||
|
||||
- Local backend:
|
||||
|
||||
- Better music library support.
|
||||
- **[DONE: v0.3]** A script for creating a tag cache.
|
||||
- An alternative to tag cache for caching metadata, i.e. Sqlite.
|
||||
|
||||
- **[DONE: v0.2]** Last.fm scrobbling.
|
||||
|
||||
|
||||
Stuff we want to do, but not right now, and maybe never
|
||||
=======================================================
|
||||
|
||||
- Packaging and distribution:
|
||||
|
||||
- **[BLOCKED]** Create `Homebrew <http://mxcl.github.com/homebrew/>`_
|
||||
recipies for all our dependencies and Mopidy itself to make OS X
|
||||
installation a breeze. See `Homebrew's issue #1612
|
||||
<http://github.com/mxcl/homebrew/issues/issue/1612>`_.
|
||||
- **[DONE]** Create `Debian packages
|
||||
<http://www.debian.org/doc/maint-guide/>`_ of all our dependencies and
|
||||
Mopidy itself (hosted in our own Debian repo until we get stuff into the
|
||||
various distros) to make Debian/Ubuntu installation a breeze.
|
||||
|
||||
- Compatability:
|
||||
|
||||
- **[WIP: feature/blackbox-testing]** Run frontend tests against a real MPD
|
||||
server to ensure we are in sync.
|
||||
|
||||
- Backends:
|
||||
|
||||
- `Last.fm <http://www.last.fm/api>`_
|
||||
- `WIMP <http://twitter.com/wimp/status/8975885632>`_
|
||||
- DNLA/UPnP so Mopidy can play music from other DNLA MediaServers.
|
||||
|
||||
- Frontends:
|
||||
|
||||
- Publish the server's presence to the network using `Zeroconf
|
||||
<http://en.wikipedia.org/wiki/Zeroconf>`_/Avahi.
|
||||
- **[WIP: feature/mpris-frontend]** D-Bus/`MPRIS <http://www.mpris.org/>`_
|
||||
- **[WIP: feature/http-frontend]** REST/JSON web service with a jQuery client
|
||||
as example application. Maybe based upon `Tornado
|
||||
<http://github.com/facebook/tornado>`_ and `jQuery
|
||||
Mobile <http://jquerymobile.com/>`_.
|
||||
- DNLA/UPnP so Mopidy can be controlled from i.e. TVs.
|
||||
- `XMMS2 <http://www.xmms2.org/>`_
|
||||
- LIRC frontend for controlling Mopidy with a remote.
|
||||
|
||||
- Mixers:
|
||||
|
||||
- LIRC mixer for controlling arbitrary amplifiers remotely.
|
||||
|
||||
- Audio streaming:
|
||||
|
||||
- Ogg Vorbis/MP3 audio stream over HTTP, to MPD clients, `Squeezeboxes
|
||||
<http://www.logitechsqueezebox.com/>`_, etc.
|
||||
- Feed audio to an `Icecast <http://www.icecast.org/>`_ server.
|
||||
- Stream to AirPort Express using `RAOP
|
||||
<http://en.wikipedia.org/wiki/Remote_Audio_Output_Protocol>`_.
|
||||
We maintain our collection of sane or less sane ideas for future Mopidy
|
||||
features as `issues <https://github.com/mopidy/mopidy/issues>`_ at GitHub
|
||||
labeled with `the "wishlist" label
|
||||
<https://github.com/mopidy/mopidy/issues/labels/wishlist>`_. Feel free to vote
|
||||
up any feature you would love to see in Mopidy, but please refrain from adding
|
||||
a comment just to say "I want this too!". You are of course free to add
|
||||
comments if you have suggestions for how the feature should work or be
|
||||
implemented, and you may add new wishlist issues if your ideas are not already
|
||||
represented.
|
||||
|
||||
@ -1,4 +1,31 @@
|
||||
.. include:: ../README.rst
|
||||
******
|
||||
Mopidy
|
||||
******
|
||||
|
||||
Mopidy is a music server which can play music from `Spotify
|
||||
<http://www.spotify.com/>`_ or from your local hard drive. To search for music
|
||||
in Spotify's vast archive, manage playlists, and play music, you can use most
|
||||
`MPD clients <http://mpd.wikia.com/>`_. MPD clients are available for most
|
||||
platforms, including Windows, Mac OS X, Linux, Android, and iOS.
|
||||
|
||||
To install Mopidy, start out by reading :ref:`installation`.
|
||||
|
||||
If you get stuck, we usually hang around at ``#mopidy`` at `irc.freenode.net
|
||||
<http://freenode.net/>`_. If you stumble into a bug or got a feature request,
|
||||
please create an issue in the `issue tracker
|
||||
<http://github.com/mopidy/mopidy/issues>`_.
|
||||
|
||||
|
||||
Project resources
|
||||
=================
|
||||
|
||||
- `Documentation for the latest release <http://www.mopidy.com/docs/master/>`_
|
||||
- `Documentation for the development version
|
||||
<http://www.mopidy.com/docs/develop/>`_
|
||||
- `Source code <http://github.com/mopidy/mopidy>`_
|
||||
- `Issue tracker <http://github.com/mopidy/mopidy/issues>`_
|
||||
- IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
|
||||
|
||||
|
||||
User documentation
|
||||
==================
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
.. _installation:
|
||||
|
||||
************
|
||||
Installation
|
||||
************
|
||||
@ -23,6 +25,8 @@ Otherwise, make sure you got the required dependencies installed.
|
||||
|
||||
- Python >= 2.6, < 3
|
||||
|
||||
- `Pykka <http://jodal.github.com/pykka/>`_ >= 0.12
|
||||
|
||||
- GStreamer >= 0.10, with Python bindings. See :doc:`gstreamer`.
|
||||
|
||||
- Mixer dependencies: The default mixer does not require any additional
|
||||
|
||||
@ -30,7 +30,7 @@ If you run a Debian based Linux distribution, like Ubuntu, see
|
||||
http://apt.mopidy.com/ for how to the Mopidy APT archive as a software source
|
||||
on your installation. Then, simply run::
|
||||
|
||||
sudo apt-get install libspotify6
|
||||
sudo apt-get install libspotify7
|
||||
|
||||
When libspotify has been installed, continue with
|
||||
:ref:`pyspotify_installation`.
|
||||
@ -39,14 +39,14 @@ When libspotify has been installed, continue with
|
||||
On Linux from source
|
||||
--------------------
|
||||
|
||||
Download and install libspotify 0.0.6 for your OS and CPU architecture from
|
||||
Download and install libspotify 0.0.7 for your OS and CPU architecture from
|
||||
https://developer.spotify.com/en/libspotify/.
|
||||
|
||||
For 64-bit Linux the process is as follows::
|
||||
|
||||
wget http://developer.spotify.com/download/libspotify/libspotify-0.0.6-linux6-x86_64.tar.gz
|
||||
tar zxfv libspotify-0.0.6-linux6-x86_64.tar.gz
|
||||
cd libspotify-0.0.6-linux6-x86_64/
|
||||
wget http://developer.spotify.com/download/libspotify/libspotify-0.0.7-linux6-x86_64.tar.gz
|
||||
tar zxfv libspotify-0.0.7-linux6-x86_64.tar.gz
|
||||
cd libspotify-0.0.7-linux6-x86_64/
|
||||
sudo make install prefix=/usr/local
|
||||
sudo ldconfig
|
||||
|
||||
@ -113,4 +113,4 @@ Get the pyspotify code, and install it::
|
||||
It is important that you install pyspotify from the ``mopidy`` branch of the
|
||||
``mopidy/pyspotify`` repository, as the upstream repository at
|
||||
``winjer/pyspotify`` is not updated with changes needed to support e.g.
|
||||
libspotify 0.0.6 and high bitrate audio.
|
||||
libspotify 0.0.7 and high bitrate audio.
|
||||
|
||||
@ -8,7 +8,7 @@ contributed what, please refer to our git repository.
|
||||
Source code license
|
||||
===================
|
||||
|
||||
Copyright 2009-2010 Stein Magnus Jodal and contributors
|
||||
Copyright 2009-2011 Stein Magnus Jodal and contributors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@ -26,7 +26,7 @@ limitations under the License.
|
||||
Documentation license
|
||||
=====================
|
||||
|
||||
Copyright 2010 Stein Magnus Jodal and contributors
|
||||
Copyright 2010-2011 Stein Magnus Jodal and contributors
|
||||
|
||||
This work is licensed under the Creative Commons Attribution-ShareAlike 3.0
|
||||
Unported License. To view a copy of this license, visit
|
||||
|
||||
@ -59,11 +59,13 @@ You may also want to change some of the ``LOCAL_*`` settings. See
|
||||
hopefully have support for this in the 0.3 release.
|
||||
|
||||
|
||||
.. _generating_a_tag_cache:
|
||||
|
||||
Generating a tag cache
|
||||
----------------------
|
||||
|
||||
Previously the local storage backend relied purely on ``tag_cache`` files
|
||||
generated by the original MPD server. To remedy this the command
|
||||
Before Mopidy 0.3 the local storage backend relied purely on ``tag_cache``
|
||||
files generated by the original MPD server. To remedy this the command
|
||||
:command:`mopidy-scan` has been created. The program will scan your current
|
||||
:attr:`mopidy.settings.LOCAL_MUSIC_PATH` and build a MPD compatible
|
||||
``tag_cache``.
|
||||
@ -82,12 +84,14 @@ To make a ``tag_cache`` of your local music available for Mopidy:
|
||||
mopidy-scan > tag_cache
|
||||
|
||||
#. Move the ``tag_cache`` file to the location
|
||||
:attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` is set to, or change the setting to
|
||||
point to where your ``tag_cache`` file is.
|
||||
:attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` is set to, or change the
|
||||
setting to point to where your ``tag_cache`` file is.
|
||||
|
||||
#. Start Mopidy, find the music library in a client, and play some local music!
|
||||
|
||||
|
||||
.. _use_mpd_on_a_network:
|
||||
|
||||
Connecting from other machines on the network
|
||||
=============================================
|
||||
|
||||
@ -95,6 +99,13 @@ 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`.
|
||||
|
||||
If you open up Mopidy for your local network, you should consider turning on
|
||||
MPD password authentication by setting
|
||||
:attr:`mopidy.settings.MPD_SERVER_PASSWORD` to the password you want to use.
|
||||
If the password is set, Mopidy will require MPD clients to provide the password
|
||||
before they can do anything else. Mopidy only supports a single password, and
|
||||
do not support different permission schemes like the original MPD server.
|
||||
|
||||
|
||||
Scrobbling tracks to Last.fm
|
||||
============================
|
||||
|
||||
@ -2,8 +2,27 @@ import sys
|
||||
if not (2, 6) <= sys.version_info < (3,):
|
||||
sys.exit(u'Mopidy requires Python >= 2.6, < 3')
|
||||
|
||||
from subprocess import PIPE, Popen
|
||||
|
||||
VERSION = (0, 4, 0)
|
||||
|
||||
def get_git_version():
|
||||
process = Popen(['git', 'describe'], stdout=PIPE, stderr=PIPE)
|
||||
if process.wait() != 0:
|
||||
raise EnvironmentError('Execution of "git describe" failed')
|
||||
version = process.stdout.read().strip()
|
||||
if version.startswith('v'):
|
||||
version = version[1:]
|
||||
return version
|
||||
|
||||
def get_plain_version():
|
||||
return '.'.join(map(str, VERSION))
|
||||
|
||||
def get_version():
|
||||
return u'0.3.0'
|
||||
try:
|
||||
return get_git_version()
|
||||
except EnvironmentError:
|
||||
return get_plain_version()
|
||||
|
||||
class MopidyException(Exception):
|
||||
def __init__(self, message, *args, **kwargs):
|
||||
|
||||
@ -1,17 +1,10 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add ../ to the path so we can run Mopidy from a Git checkout without
|
||||
# installing it on the system.
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0,
|
||||
os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
|
||||
|
||||
from mopidy.core import CoreProcess
|
||||
|
||||
def main():
|
||||
# Explictly call run() instead of start(), since we don't need to start
|
||||
# another process.
|
||||
CoreProcess().run()
|
||||
|
||||
if __name__ == '__main__':
|
||||
from mopidy.core import main
|
||||
main()
|
||||
|
||||
@ -1,12 +1,4 @@
|
||||
from copy import copy
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.frontends.mpd import translator
|
||||
from mopidy.models import Playlist
|
||||
from mopidy.utils import get_class
|
||||
|
||||
from .current_playlist import CurrentPlaylistController
|
||||
from .library import LibraryController, BaseLibraryProvider
|
||||
@ -17,30 +9,6 @@ from .stored_playlists import (StoredPlaylistsController,
|
||||
logger = logging.getLogger('mopidy.backends.base')
|
||||
|
||||
class Backend(object):
|
||||
"""
|
||||
:param core_queue: a queue for sending messages to
|
||||
:class:`mopidy.process.CoreProcess`
|
||||
:type core_queue: :class:`multiprocessing.Queue`
|
||||
:param output: the audio output
|
||||
:type output: :class:`mopidy.outputs.gstreamer.GStreamerOutput` or similar
|
||||
:param mixer_class: either a mixer class, or :class:`None` to use the mixer
|
||||
defined in settings
|
||||
:type mixer_class: a subclass of :class:`mopidy.mixers.BaseMixer` or
|
||||
:class:`None`
|
||||
"""
|
||||
|
||||
def __init__(self, core_queue=None, output=None, mixer_class=None):
|
||||
self.core_queue = core_queue
|
||||
self.output = output
|
||||
if mixer_class is None:
|
||||
mixer_class = get_class(settings.MIXER)
|
||||
self.mixer = mixer_class(self)
|
||||
|
||||
#: A :class:`multiprocessing.Queue` which can be used by e.g. library
|
||||
#: callbacks executing in other threads to send messages to the core
|
||||
#: thread, so that action may be taken in the correct thread.
|
||||
core_queue = None
|
||||
|
||||
#: The current playlist controller. An instance of
|
||||
#: :class:`mopidy.backends.base.CurrentPlaylistController`.
|
||||
current_playlist = None
|
||||
@ -49,9 +17,6 @@ class Backend(object):
|
||||
# :class:`mopidy.backends.base.LibraryController`.
|
||||
library = None
|
||||
|
||||
#: The sound mixer. An instance of :class:`mopidy.mixers.BaseMixer`.
|
||||
mixer = None
|
||||
|
||||
#: The playback controller. An instance of
|
||||
#: :class:`mopidy.backends.base.PlaybackController`.
|
||||
playback = None
|
||||
@ -62,24 +27,3 @@ class Backend(object):
|
||||
|
||||
#: List of URI prefixes this backend can handle.
|
||||
uri_handlers = []
|
||||
|
||||
def destroy(self):
|
||||
"""
|
||||
Call destroy on all sub-components in backend so that they can cleanup
|
||||
after themselves.
|
||||
"""
|
||||
|
||||
if self.current_playlist:
|
||||
self.current_playlist.destroy()
|
||||
|
||||
if self.library:
|
||||
self.library.destroy()
|
||||
|
||||
if self.mixer:
|
||||
self.mixer.destroy()
|
||||
|
||||
if self.playback:
|
||||
self.playback.destroy()
|
||||
|
||||
if self.stored_playlists:
|
||||
self.stored_playlists.destroy()
|
||||
|
||||
@ -2,8 +2,6 @@ from copy import copy
|
||||
import logging
|
||||
import random
|
||||
|
||||
from mopidy.frontends.mpd import translator
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.base')
|
||||
|
||||
class CurrentPlaylistController(object):
|
||||
@ -12,6 +10,8 @@ class CurrentPlaylistController(object):
|
||||
:type backend: :class:`mopidy.backends.base.Backend`
|
||||
"""
|
||||
|
||||
pykka_traversable = True
|
||||
|
||||
def __init__(self, backend):
|
||||
self.backend = backend
|
||||
self._cp_tracks = []
|
||||
@ -197,8 +197,3 @@ class CurrentPlaylistController(object):
|
||||
random.shuffle(shuffled)
|
||||
self._cp_tracks = before + shuffled + after
|
||||
self.version += 1
|
||||
|
||||
def mpd_format(self, *args, **kwargs):
|
||||
"""Not a part of the generic backend API."""
|
||||
kwargs['cpids'] = [ct[0] for ct in self._cp_tracks]
|
||||
return translator.tracks_to_mpd_format(self.tracks, *args, **kwargs)
|
||||
|
||||
@ -10,6 +10,8 @@ class LibraryController(object):
|
||||
:type provider: instance of :class:`BaseLibraryProvider`
|
||||
"""
|
||||
|
||||
pykka_traversable = True
|
||||
|
||||
def __init__(self, backend, provider):
|
||||
self.backend = backend
|
||||
self.provider = provider
|
||||
@ -82,6 +84,8 @@ class BaseLibraryProvider(object):
|
||||
:type backend: :class:`mopidy.backends.base.Backend`
|
||||
"""
|
||||
|
||||
pykka_traversable = True
|
||||
|
||||
def __init__(self, backend):
|
||||
self.backend = backend
|
||||
|
||||
|
||||
@ -2,6 +2,11 @@ import logging
|
||||
import random
|
||||
import time
|
||||
|
||||
from pykka.registry import ActorRegistry
|
||||
|
||||
from mopidy.frontends.base import BaseFrontend
|
||||
from mopidy.outputs.base import BaseOutput
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.base')
|
||||
|
||||
class PlaybackController(object):
|
||||
@ -15,6 +20,8 @@ class PlaybackController(object):
|
||||
# pylint: disable = R0902
|
||||
# Too many instance attributes
|
||||
|
||||
pykka_traversable = True
|
||||
|
||||
#: Constant representing the paused state.
|
||||
PAUSED = u'paused'
|
||||
|
||||
@ -62,8 +69,8 @@ class PlaybackController(object):
|
||||
self._state = self.STOPPED
|
||||
self._shuffled = []
|
||||
self._first_shuffle = True
|
||||
self._play_time_accumulated = 0
|
||||
self._play_time_started = None
|
||||
self.play_time_accumulated = 0
|
||||
self.play_time_started = None
|
||||
|
||||
def destroy(self):
|
||||
"""
|
||||
@ -240,7 +247,7 @@ class PlaybackController(object):
|
||||
if self.repeat or self.consume or self.random:
|
||||
return self.current_cp_track
|
||||
|
||||
if self.current_cp_track is None or self.current_playlist_position == 0:
|
||||
if self.current_playlist_position in (None, 0):
|
||||
return None
|
||||
|
||||
return self.backend.current_playlist.cp_tracks[
|
||||
@ -269,7 +276,7 @@ class PlaybackController(object):
|
||||
def state(self, new_state):
|
||||
(old_state, self._state) = (self.state, new_state)
|
||||
logger.debug(u'Changing state: %s -> %s', old_state, new_state)
|
||||
# FIXME _play_time stuff assumes backend does not have a better way of
|
||||
# FIXME play_time stuff assumes backend does not have a better way of
|
||||
# handeling this stuff :/
|
||||
if (old_state in (self.PLAYING, self.STOPPED)
|
||||
and new_state == self.PLAYING):
|
||||
@ -282,28 +289,35 @@ class PlaybackController(object):
|
||||
@property
|
||||
def time_position(self):
|
||||
"""Time position in milliseconds."""
|
||||
output_position = self.backend.output.get_position()
|
||||
output_position = self._time_position_from_output()
|
||||
if output_position is not None:
|
||||
return output_position
|
||||
if self.state == self.PLAYING:
|
||||
time_since_started = (self._current_wall_time -
|
||||
self._play_time_started)
|
||||
return self._play_time_accumulated + time_since_started
|
||||
self.play_time_started)
|
||||
return self.play_time_accumulated + time_since_started
|
||||
elif self.state == self.PAUSED:
|
||||
return self._play_time_accumulated
|
||||
return self.play_time_accumulated
|
||||
elif self.state == self.STOPPED:
|
||||
return 0
|
||||
|
||||
def _time_position_from_output(self):
|
||||
output_refs = ActorRegistry.get_by_class(BaseOutput)
|
||||
if not output_refs:
|
||||
return None
|
||||
output = output_refs[0].proxy()
|
||||
return output.get_position()
|
||||
|
||||
def _play_time_start(self):
|
||||
self._play_time_accumulated = 0
|
||||
self._play_time_started = self._current_wall_time
|
||||
self.play_time_accumulated = 0
|
||||
self.play_time_started = self._current_wall_time
|
||||
|
||||
def _play_time_pause(self):
|
||||
time_since_started = self._current_wall_time - self._play_time_started
|
||||
self._play_time_accumulated += time_since_started
|
||||
time_since_started = self._current_wall_time - self.play_time_started
|
||||
self.play_time_accumulated += time_since_started
|
||||
|
||||
def _play_time_resume(self):
|
||||
self._play_time_started = self._current_wall_time
|
||||
self.play_time_started = self._current_wall_time
|
||||
|
||||
@property
|
||||
def _current_wall_time(self):
|
||||
@ -436,8 +450,8 @@ class PlaybackController(object):
|
||||
self.next()
|
||||
return True
|
||||
|
||||
self._play_time_started = self._current_wall_time
|
||||
self._play_time_accumulated = time_position
|
||||
self.play_time_started = self._current_wall_time
|
||||
self.play_time_accumulated = time_position
|
||||
|
||||
return self.provider.seek(time_position)
|
||||
|
||||
@ -449,11 +463,10 @@ class PlaybackController(object):
|
||||
stopping
|
||||
:type clear_current_track: boolean
|
||||
"""
|
||||
if self.state == self.STOPPED:
|
||||
return
|
||||
self._trigger_stopped_playing_event()
|
||||
if self.provider.stop():
|
||||
self.state = self.STOPPED
|
||||
if self.state != self.STOPPED:
|
||||
self._trigger_stopped_playing_event()
|
||||
if self.provider.stop():
|
||||
self.state = self.STOPPED
|
||||
if clear_current_track:
|
||||
self.current_cp_track = None
|
||||
|
||||
@ -464,9 +477,11 @@ class PlaybackController(object):
|
||||
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',
|
||||
if self.current_track is None:
|
||||
return
|
||||
frontend_refs = ActorRegistry.get_by_class(BaseFrontend)
|
||||
for frontend_ref in frontend_refs:
|
||||
frontend_ref.send_one_way({
|
||||
'command': 'started_playing',
|
||||
'track': self.current_track,
|
||||
})
|
||||
@ -479,9 +494,11 @@ class PlaybackController(object):
|
||||
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',
|
||||
if self.current_track is None:
|
||||
return
|
||||
frontend_refs = ActorRegistry.get_by_class(BaseFrontend)
|
||||
for frontend_ref in frontend_refs:
|
||||
frontend_ref.send_one_way({
|
||||
'command': 'stopped_playing',
|
||||
'track': self.current_track,
|
||||
'stop_position': self.time_position,
|
||||
@ -494,6 +511,8 @@ class BasePlaybackProvider(object):
|
||||
:type backend: :class:`mopidy.backends.base.Backend`
|
||||
"""
|
||||
|
||||
pykka_traversable = True
|
||||
|
||||
def __init__(self, backend):
|
||||
self.backend = backend
|
||||
|
||||
|
||||
@ -11,6 +11,8 @@ class StoredPlaylistsController(object):
|
||||
:type provider: instance of :class:`BaseStoredPlaylistsProvider`
|
||||
"""
|
||||
|
||||
pykka_traversable = True
|
||||
|
||||
def __init__(self, backend, provider):
|
||||
self.backend = backend
|
||||
self.provider = provider
|
||||
@ -125,6 +127,8 @@ class BaseStoredPlaylistsProvider(object):
|
||||
:type backend: :class:`mopidy.backends.base.Backend`
|
||||
"""
|
||||
|
||||
pykka_traversable = True
|
||||
|
||||
def __init__(self, backend):
|
||||
self.backend = backend
|
||||
self._playlists = []
|
||||
|
||||
@ -1,21 +1,13 @@
|
||||
from pykka.actor import ThreadingActor
|
||||
|
||||
from mopidy.backends.base import (Backend, CurrentPlaylistController,
|
||||
PlaybackController, BasePlaybackProvider, LibraryController,
|
||||
BaseLibraryProvider, StoredPlaylistsController,
|
||||
BaseStoredPlaylistsProvider)
|
||||
from mopidy.mixers.dummy import DummyMixer
|
||||
from mopidy.models import Playlist
|
||||
from mopidy.outputs.dummy import DummyOutput
|
||||
|
||||
|
||||
class DummyQueue(object):
|
||||
def __init__(self):
|
||||
self.received_messages = []
|
||||
|
||||
def put(self, message):
|
||||
self.received_messages.append(message)
|
||||
|
||||
|
||||
class DummyBackend(Backend):
|
||||
class DummyBackend(ThreadingActor, Backend):
|
||||
"""
|
||||
A backend which implements the backend API in the simplest way possible.
|
||||
Used in tests of the frontends.
|
||||
@ -24,9 +16,6 @@ class DummyBackend(Backend):
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs['core_queue'] = DummyQueue()
|
||||
kwargs['output'] = DummyOutput(core_queue=DummyQueue())
|
||||
kwargs['mixer_class'] = DummyMixer
|
||||
super(DummyBackend, self).__init__(*args, **kwargs)
|
||||
|
||||
self.current_playlist = CurrentPlaylistController(backend=self)
|
||||
@ -49,13 +38,13 @@ class DummyBackend(Backend):
|
||||
class DummyLibraryProvider(BaseLibraryProvider):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(DummyLibraryProvider, self).__init__(*args, **kwargs)
|
||||
self._library = []
|
||||
self.dummy_library = []
|
||||
|
||||
def find_exact(self, **query):
|
||||
return Playlist()
|
||||
|
||||
def lookup(self, uri):
|
||||
matches = filter(lambda t: uri == t.uri, self._library)
|
||||
matches = filter(lambda t: uri == t.uri, self.dummy_library)
|
||||
if matches:
|
||||
return matches[0]
|
||||
|
||||
|
||||
@ -1,22 +1,24 @@
|
||||
import glob
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from pykka.actor import ThreadingActor
|
||||
from pykka.registry import ActorRegistry
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.backends.base import (Backend, CurrentPlaylistController,
|
||||
LibraryController, BaseLibraryProvider, PlaybackController,
|
||||
BasePlaybackProvider, StoredPlaylistsController,
|
||||
BaseStoredPlaylistsProvider)
|
||||
from mopidy.models import Playlist, Track, Album
|
||||
from mopidy.utils.process import pickle_connection
|
||||
from mopidy.outputs.base import BaseOutput
|
||||
|
||||
from .translator import parse_m3u, parse_mpd_tag_cache
|
||||
|
||||
logger = logging.getLogger(u'mopidy.backends.local')
|
||||
|
||||
class LocalBackend(Backend):
|
||||
class LocalBackend(ThreadingActor, Backend):
|
||||
"""
|
||||
A backend for playing music from a local music archive.
|
||||
|
||||
@ -48,22 +50,29 @@ class LocalBackend(Backend):
|
||||
|
||||
self.uri_handlers = [u'file://']
|
||||
|
||||
self.output = None
|
||||
|
||||
def on_start(self):
|
||||
output_refs = ActorRegistry.get_by_class(BaseOutput)
|
||||
assert len(output_refs) == 1, 'Expected exactly one running output.'
|
||||
self.output = output_refs[0].proxy()
|
||||
|
||||
|
||||
class LocalPlaybackProvider(BasePlaybackProvider):
|
||||
def pause(self):
|
||||
return self.backend.output.set_state('PAUSED')
|
||||
return self.backend.output.set_state('PAUSED').get()
|
||||
|
||||
def play(self, track):
|
||||
return self.backend.output.play_uri(track.uri)
|
||||
return self.backend.output.play_uri(track.uri).get()
|
||||
|
||||
def resume(self):
|
||||
return self.backend.output.set_state('PLAYING')
|
||||
return self.backend.output.set_state('PLAYING').get()
|
||||
|
||||
def seek(self, time_position):
|
||||
return self.backend.output.set_position(time_position)
|
||||
return self.backend.output.set_position(time_position).get()
|
||||
|
||||
def stop(self):
|
||||
return self.backend.output.set_state('READY')
|
||||
return self.backend.output.set_state('READY').get()
|
||||
|
||||
|
||||
class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider):
|
||||
|
||||
@ -100,8 +100,11 @@ def _convert_mpd_data(data, tracks, music_dir):
|
||||
albumartist_kwargs = {}
|
||||
|
||||
if 'track' in data:
|
||||
album_kwargs['num_tracks'] = int(data['track'].split('/')[1])
|
||||
track_kwargs['track_no'] = int(data['track'].split('/')[0])
|
||||
if '/' in data['track']:
|
||||
album_kwargs['num_tracks'] = int(data['track'].split('/')[1])
|
||||
track_kwargs['track_no'] = int(data['track'].split('/')[0])
|
||||
else:
|
||||
track_kwargs['track_no'] = int(data['track'])
|
||||
|
||||
if 'artist' in data:
|
||||
artist_kwargs['name'] = data['artist']
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
import logging
|
||||
|
||||
from pykka.actor import ThreadingActor
|
||||
from pykka.registry import ActorRegistry
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.backends.base import (Backend, CurrentPlaylistController,
|
||||
LibraryController, PlaybackController, StoredPlaylistsController)
|
||||
from mopidy.outputs.base import BaseOutput
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.spotify')
|
||||
|
||||
ENCODING = 'utf-8'
|
||||
|
||||
class SpotifyBackend(Backend):
|
||||
class SpotifyBackend(ThreadingActor, Backend):
|
||||
"""
|
||||
A backend for playing music from the `Spotify <http://www.spotify.com/>`_
|
||||
music streaming service. The backend uses the official `libspotify
|
||||
@ -59,6 +63,14 @@ class SpotifyBackend(Backend):
|
||||
|
||||
self.uri_handlers = [u'spotify:', u'http://open.spotify.com/']
|
||||
|
||||
self.output = None
|
||||
self.spotify = None
|
||||
|
||||
def on_start(self):
|
||||
output_refs = ActorRegistry.get_by_class(BaseOutput)
|
||||
assert len(output_refs) == 1, 'Expected exactly one running output.'
|
||||
self.output = output_refs[0].proxy()
|
||||
|
||||
self.spotify = self._connect()
|
||||
|
||||
def _connect(self):
|
||||
@ -67,8 +79,6 @@ class SpotifyBackend(Backend):
|
||||
logger.info(u'Mopidy uses SPOTIFY(R) CORE')
|
||||
logger.debug(u'Connecting to Spotify')
|
||||
spotify = SpotifySessionManager(
|
||||
settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD,
|
||||
core_queue=self.core_queue,
|
||||
output=self.output)
|
||||
settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD)
|
||||
spotify.start()
|
||||
return spotify
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import logging
|
||||
import multiprocessing
|
||||
import Queue
|
||||
|
||||
from spotify import Link, SpotifyError
|
||||
|
||||
@ -22,7 +22,7 @@ class SpotifyLibraryProvider(BaseLibraryProvider):
|
||||
# playlists.
|
||||
return SpotifyTranslator.to_mopidy_track(spotify_track)
|
||||
except SpotifyError as e:
|
||||
logger.warning(u'Failed to lookup: %s', uri, e)
|
||||
logger.debug(u'Failed to lookup "%s": %s', uri, e)
|
||||
return None
|
||||
|
||||
def refresh(self, uri=None):
|
||||
@ -54,8 +54,9 @@ class SpotifyLibraryProvider(BaseLibraryProvider):
|
||||
spotify_query.append(u'%s:"%s"' % (field, value))
|
||||
spotify_query = u' '.join(spotify_query)
|
||||
logger.debug(u'Spotify search query: %s' % spotify_query)
|
||||
my_end, other_end = multiprocessing.Pipe()
|
||||
self.backend.spotify.search(spotify_query.encode(ENCODING), other_end)
|
||||
my_end.poll(None)
|
||||
playlist = my_end.recv()
|
||||
return playlist
|
||||
queue = Queue.Queue()
|
||||
self.backend.spotify.search(spotify_query.encode(ENCODING), queue)
|
||||
try:
|
||||
return queue.get(timeout=3) # XXX What is an reasonable timeout?
|
||||
except Queue.Empty:
|
||||
return Playlist(tracks=[])
|
||||
|
||||
@ -23,7 +23,7 @@ class SpotifyPlaybackProvider(BasePlaybackProvider):
|
||||
self.backend.output.set_state('PLAYING')
|
||||
return True
|
||||
except SpotifyError as e:
|
||||
logger.warning('Play %s failed: %s', track.uri, e)
|
||||
logger.info('Playback of %s failed: %s', track.uri, e)
|
||||
return False
|
||||
|
||||
def resume(self):
|
||||
|
||||
@ -2,11 +2,15 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
import spotify.manager
|
||||
from spotify.manager import SpotifySessionManager as PyspotifySessionManager
|
||||
|
||||
from pykka.registry import ActorRegistry
|
||||
|
||||
from mopidy import get_version, settings
|
||||
from mopidy.backends.base import Backend
|
||||
from mopidy.backends.spotify.translator import SpotifyTranslator
|
||||
from mopidy.models import Playlist
|
||||
from mopidy.outputs.base import BaseOutput
|
||||
from mopidy.utils.process import BaseThread
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.spotify.session_manager')
|
||||
@ -14,26 +18,41 @@ logger = logging.getLogger('mopidy.backends.spotify.session_manager')
|
||||
# pylint: disable = R0901
|
||||
# SpotifySessionManager: Too many ancestors (9/7)
|
||||
|
||||
class SpotifySessionManager(spotify.manager.SpotifySessionManager, BaseThread):
|
||||
class SpotifySessionManager(BaseThread, PyspotifySessionManager):
|
||||
cache_location = settings.SPOTIFY_CACHE_PATH
|
||||
settings_location = settings.SPOTIFY_CACHE_PATH
|
||||
appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key')
|
||||
user_agent = 'Mopidy %s' % get_version()
|
||||
|
||||
def __init__(self, username, password, core_queue, output):
|
||||
spotify.manager.SpotifySessionManager.__init__(
|
||||
self, username, password)
|
||||
BaseThread.__init__(self, core_queue)
|
||||
def __init__(self, username, password):
|
||||
PyspotifySessionManager.__init__(self, username, password)
|
||||
BaseThread.__init__(self)
|
||||
self.name = 'SpotifySMThread'
|
||||
self.output = output
|
||||
|
||||
self.output = None
|
||||
self.backend = None
|
||||
|
||||
self.connected = threading.Event()
|
||||
self.session = None
|
||||
|
||||
def run_inside_try(self):
|
||||
self.setup()
|
||||
self.connect()
|
||||
|
||||
def setup(self):
|
||||
output_refs = ActorRegistry.get_by_class(BaseOutput)
|
||||
assert len(output_refs) == 1, 'Expected exactly one running output.'
|
||||
self.output = output_refs[0].proxy()
|
||||
|
||||
backend_refs = ActorRegistry.get_by_class(Backend)
|
||||
assert len(backend_refs) == 1, 'Expected exactly one running backend.'
|
||||
self.backend = backend_refs[0].proxy()
|
||||
|
||||
def logged_in(self, session, error):
|
||||
"""Callback used by pyspotify"""
|
||||
if error:
|
||||
logger.error(u'Spotify login error: %s', error)
|
||||
return
|
||||
logger.info(u'Connected to Spotify')
|
||||
self.session = session
|
||||
if settings.SPOTIFY_HIGH_BITRATE:
|
||||
@ -55,7 +74,7 @@ class SpotifySessionManager(spotify.manager.SpotifySessionManager, BaseThread):
|
||||
|
||||
def connection_error(self, session, error):
|
||||
"""Callback used by pyspotify"""
|
||||
logger.error(u'Connection error: %s', error)
|
||||
logger.error(u'Spotify connection error: %s', error)
|
||||
|
||||
def message_to_user(self, session, message):
|
||||
"""Callback used by pyspotify"""
|
||||
@ -88,7 +107,7 @@ class SpotifySessionManager(spotify.manager.SpotifySessionManager, BaseThread):
|
||||
def play_token_lost(self, session):
|
||||
"""Callback used by pyspotify"""
|
||||
logger.debug(u'Play token lost')
|
||||
self.core_queue.put({'command': 'stop_playback'})
|
||||
self.backend.playback.pause()
|
||||
|
||||
def log_message(self, session, data):
|
||||
"""Callback used by pyspotify"""
|
||||
@ -107,19 +126,16 @@ class SpotifySessionManager(spotify.manager.SpotifySessionManager, BaseThread):
|
||||
playlists.append(
|
||||
SpotifyTranslator.to_mopidy_playlist(spotify_playlist))
|
||||
playlists = filter(None, playlists)
|
||||
self.core_queue.put({
|
||||
'command': 'set_stored_playlists',
|
||||
'playlists': playlists,
|
||||
})
|
||||
self.backend.stored_playlists.playlists = playlists
|
||||
logger.debug(u'Refreshed %d stored playlist(s)', len(playlists))
|
||||
|
||||
def search(self, query, connection):
|
||||
def search(self, query, queue):
|
||||
"""Search method used by Mopidy backend"""
|
||||
def callback(results, userdata=None):
|
||||
# TODO Include results from results.albums(), etc. too
|
||||
playlist = Playlist(tracks=[
|
||||
SpotifyTranslator.to_mopidy_track(t)
|
||||
for t in results.tracks()])
|
||||
connection.send(playlist)
|
||||
queue.put(playlist)
|
||||
self.connected.wait()
|
||||
self.session.search(query, callback)
|
||||
|
||||
@ -28,9 +28,9 @@ class SpotifyTranslator(object):
|
||||
|
||||
@classmethod
|
||||
def to_mopidy_track(cls, spotify_track):
|
||||
if not spotify_track.is_loaded():
|
||||
return Track(name=u'[loading...]')
|
||||
uri = str(Link.from_track(spotify_track, 0))
|
||||
if not spotify_track.is_loaded():
|
||||
return Track(uri=uri, name=u'[loading...]')
|
||||
if dt.MINYEAR <= int(spotify_track.album().year()) <= dt.MAXYEAR:
|
||||
date = dt.date(spotify_track.album().year(), 1, 1)
|
||||
else:
|
||||
@ -60,5 +60,5 @@ class SpotifyTranslator(object):
|
||||
tracks=[cls.to_mopidy_track(t) for t in spotify_playlist],
|
||||
)
|
||||
except SpotifyError, e:
|
||||
logger.warning(u'Failed translating Spotify playlist '
|
||||
logger.info(u'Failed translating Spotify playlist '
|
||||
'(probably a playlist folder boundary): %s', e)
|
||||
|
||||
154
mopidy/core.py
154
mopidy/core.py
@ -1,114 +1,76 @@
|
||||
import logging
|
||||
import multiprocessing
|
||||
import optparse
|
||||
import sys
|
||||
import time
|
||||
|
||||
from pykka.registry import ActorRegistry
|
||||
|
||||
from mopidy import get_version, settings, OptionalDependencyError
|
||||
from mopidy.utils import get_class
|
||||
from mopidy.utils.log import setup_logging
|
||||
from mopidy.utils.path import get_or_create_folder, get_or_create_file
|
||||
from mopidy.utils.process import BaseThread, GObjectEventThread
|
||||
from mopidy.utils.process import GObjectEventThread
|
||||
from mopidy.utils.settings import list_settings_optparse_callback
|
||||
|
||||
logger = logging.getLogger('mopidy.core')
|
||||
|
||||
class CoreProcess(BaseThread):
|
||||
def __init__(self):
|
||||
self.core_queue = multiprocessing.Queue()
|
||||
super(CoreProcess, self).__init__(self.core_queue)
|
||||
self.name = 'CoreProcess'
|
||||
self.options = self.parse_options()
|
||||
self.gobject_loop = None
|
||||
self.output = None
|
||||
self.backend = None
|
||||
self.frontends = []
|
||||
def main():
|
||||
options = parse_options()
|
||||
setup_logging(options.verbosity_level, options.save_debug_log)
|
||||
setup_settings()
|
||||
setup_gobject_loop()
|
||||
setup_output()
|
||||
setup_mixer()
|
||||
setup_backend()
|
||||
setup_frontends()
|
||||
try:
|
||||
while ActorRegistry.get_all():
|
||||
time.sleep(1)
|
||||
logger.info(u'No actors left. Exiting...')
|
||||
except KeyboardInterrupt:
|
||||
logger.info(u'User interrupt. Exiting...')
|
||||
ActorRegistry.stop_all()
|
||||
|
||||
def parse_options(self):
|
||||
parser = optparse.OptionParser(version='Mopidy %s' % get_version())
|
||||
parser.add_option('-q', '--quiet',
|
||||
action='store_const', const=0, dest='verbosity_level',
|
||||
help='less output (warning level)')
|
||||
parser.add_option('-v', '--verbose',
|
||||
action='store_const', const=2, dest='verbosity_level',
|
||||
help='more output (debug level)')
|
||||
parser.add_option('--save-debug-log',
|
||||
action='store_true', dest='save_debug_log',
|
||||
help='save debug log to "./mopidy.log"')
|
||||
parser.add_option('--list-settings',
|
||||
action='callback', callback=list_settings_optparse_callback,
|
||||
help='list current settings')
|
||||
return parser.parse_args()[0]
|
||||
def parse_options():
|
||||
parser = optparse.OptionParser(version='Mopidy %s' % get_version())
|
||||
parser.add_option('-q', '--quiet',
|
||||
action='store_const', const=0, dest='verbosity_level',
|
||||
help='less output (warning level)')
|
||||
parser.add_option('-v', '--verbose',
|
||||
action='store_const', const=2, dest='verbosity_level',
|
||||
help='more output (debug level)')
|
||||
parser.add_option('--save-debug-log',
|
||||
action='store_true', dest='save_debug_log',
|
||||
help='save debug log to "./mopidy.log"')
|
||||
parser.add_option('--list-settings',
|
||||
action='callback', callback=list_settings_optparse_callback,
|
||||
help='list current settings')
|
||||
return parser.parse_args()[0]
|
||||
|
||||
def run_inside_try(self):
|
||||
self.setup()
|
||||
while True:
|
||||
message = self.core_queue.get()
|
||||
self.process_message(message)
|
||||
def setup_settings():
|
||||
get_or_create_folder('~/.mopidy/')
|
||||
get_or_create_file('~/.mopidy/settings.py')
|
||||
settings.validate()
|
||||
|
||||
def setup(self):
|
||||
self.setup_logging()
|
||||
self.setup_settings()
|
||||
self.gobject_loop = self.setup_gobject_loop(self.core_queue)
|
||||
self.output = self.setup_output(self.core_queue)
|
||||
self.backend = self.setup_backend(self.core_queue, self.output)
|
||||
self.frontends = self.setup_frontends(self.core_queue, self.backend)
|
||||
def setup_gobject_loop():
|
||||
gobject_loop = GObjectEventThread()
|
||||
gobject_loop.start()
|
||||
return gobject_loop
|
||||
|
||||
def setup_logging(self):
|
||||
setup_logging(self.options.verbosity_level,
|
||||
self.options.save_debug_log)
|
||||
logger.info(u'-- Starting Mopidy --')
|
||||
def setup_output():
|
||||
return get_class(settings.OUTPUT).start().proxy()
|
||||
|
||||
def setup_settings(self):
|
||||
get_or_create_folder('~/.mopidy/')
|
||||
get_or_create_file('~/.mopidy/settings.py')
|
||||
settings.validate()
|
||||
def setup_mixer():
|
||||
return get_class(settings.MIXER).start().proxy()
|
||||
|
||||
def setup_gobject_loop(self, core_queue):
|
||||
gobject_loop = GObjectEventThread(core_queue)
|
||||
gobject_loop.start()
|
||||
return gobject_loop
|
||||
def setup_backend():
|
||||
return get_class(settings.BACKENDS[0]).start().proxy()
|
||||
|
||||
def setup_output(self, core_queue):
|
||||
output = get_class(settings.OUTPUT)(core_queue)
|
||||
output.start()
|
||||
return output
|
||||
|
||||
def setup_backend(self, core_queue, output):
|
||||
return get_class(settings.BACKENDS[0])(core_queue, output)
|
||||
|
||||
def setup_frontends(self, core_queue, backend):
|
||||
frontends = []
|
||||
for frontend_class_name in settings.FRONTENDS:
|
||||
try:
|
||||
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):
|
||||
if message.get('to') == 'core':
|
||||
self.process_message_to_core(message)
|
||||
elif message.get('to') == 'output':
|
||||
self.output.process_message(message)
|
||||
elif message.get('to') == 'frontend':
|
||||
for frontend in self.frontends:
|
||||
frontend.process_message(message)
|
||||
elif message['command'] == 'end_of_track':
|
||||
self.backend.playback.on_end_of_track()
|
||||
elif message['command'] == 'stop_playback':
|
||||
self.backend.playback.stop()
|
||||
elif message['command'] == 'set_stored_playlists':
|
||||
self.backend.stored_playlists.playlists = message['playlists']
|
||||
else:
|
||||
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)
|
||||
def setup_frontends():
|
||||
frontends = []
|
||||
for frontend_class_name in settings.FRONTENDS:
|
||||
try:
|
||||
frontend = get_class(frontend_class_name).start().proxy()
|
||||
frontends.append(frontend)
|
||||
except OptionalDependencyError as e:
|
||||
logger.info(u'Disabled: %s (%s)', frontend_class_name, e)
|
||||
return frontends
|
||||
|
||||
@ -1,40 +1,5 @@
|
||||
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.Backend`
|
||||
"""
|
||||
|
||||
def __init__(self, core_queue, backend):
|
||||
self.core_queue = core_queue
|
||||
self.backend = backend
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Start the frontend.
|
||||
|
||||
*MAY be implemented by subclass.*
|
||||
"""
|
||||
pass
|
||||
|
||||
def destroy(self):
|
||||
"""
|
||||
Destroy the frontend.
|
||||
|
||||
*MAY be implemented by subclass.*
|
||||
"""
|
||||
pass
|
||||
|
||||
def process_message(self, message):
|
||||
"""
|
||||
Process messages for the frontend.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
|
||||
:param message: the message
|
||||
:type message: dict
|
||||
"""
|
||||
raise NotImplementedError
|
||||
pass
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import logging
|
||||
import multiprocessing
|
||||
import socket
|
||||
import time
|
||||
|
||||
try:
|
||||
@ -9,16 +7,17 @@ except ImportError as import_error:
|
||||
from mopidy import OptionalDependencyError
|
||||
raise OptionalDependencyError(import_error)
|
||||
|
||||
from mopidy import get_version, settings, SettingsError
|
||||
from pykka.actor import ThreadingActor
|
||||
|
||||
from mopidy import settings, SettingsError
|
||||
from mopidy.frontends.base import BaseFrontend
|
||||
from mopidy.utils.process import BaseThread
|
||||
|
||||
logger = logging.getLogger('mopidy.frontends.lastfm')
|
||||
|
||||
API_KEY = '2236babefa8ebb3d93ea467560d00d04'
|
||||
API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd'
|
||||
|
||||
class LastfmFrontend(BaseFrontend):
|
||||
class LastfmFrontend(ThreadingActor, BaseFrontend):
|
||||
"""
|
||||
Frontend which scrobbles the music you play to your `Last.fm
|
||||
<http://www.last.fm>`_ profile.
|
||||
@ -29,7 +28,7 @@ class LastfmFrontend(BaseFrontend):
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- `pylast <http://code.google.com/p/pylast/>`_ >= 0.5
|
||||
- `pylast <http://code.google.com/p/pylast/>`_ >= 0.5.7
|
||||
|
||||
**Settings:**
|
||||
|
||||
@ -37,38 +36,11 @@ class LastfmFrontend(BaseFrontend):
|
||||
- :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):
|
||||
if self.thread.is_alive():
|
||||
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
|
||||
def __init__(self):
|
||||
self.lastfm = None
|
||||
self.last_start_time = None
|
||||
|
||||
def run_inside_try(self):
|
||||
self.setup()
|
||||
while self.lastfm is not None:
|
||||
self.connection.poll(None)
|
||||
message = self.connection.recv()
|
||||
self.process_message(message)
|
||||
|
||||
def setup(self):
|
||||
def on_start(self):
|
||||
try:
|
||||
username = settings.LASTFM_USERNAME
|
||||
password_hash = pylast.md5(settings.LASTFM_PASSWORD)
|
||||
@ -79,36 +51,40 @@ class LastfmFrontendThread(BaseThread):
|
||||
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)
|
||||
self.stop()
|
||||
except (pylast.NetworkError, pylast.MalformedResponseError,
|
||||
pylast.WSError) as e:
|
||||
logger.error(u'Error during Last.fm setup: %s', e)
|
||||
self.stop()
|
||||
|
||||
def process_message(self, message):
|
||||
if message['command'] == 'started_playing':
|
||||
def on_receive(self, message):
|
||||
if message.get('command') == 'started_playing':
|
||||
self.started_playing(message['track'])
|
||||
elif message['command'] == 'stopped_playing':
|
||||
elif message.get('command') == 'stopped_playing':
|
||||
self.stopped_playing(message['track'], message['stop_position'])
|
||||
else:
|
||||
pass # Ignore commands for other frontends
|
||||
pass # Ignore any other messages
|
||||
|
||||
def started_playing(self, track):
|
||||
artists = ', '.join([a.name for a in track.artists])
|
||||
duration = track.length // 1000
|
||||
duration = track.length and track.length // 1000 or 0
|
||||
self.last_start_time = int(time.time())
|
||||
logger.debug(u'Now playing track: %s - %s', artists, track.name)
|
||||
try:
|
||||
self.lastfm.update_now_playing(
|
||||
artists,
|
||||
track.name,
|
||||
album=track.album.name,
|
||||
(track.name or ''),
|
||||
album=(track.album and track.album.name or ''),
|
||||
duration=str(duration),
|
||||
track_number=str(track.track_no),
|
||||
mbid=(track.musicbrainz_id or ''))
|
||||
except (pylast.ScrobblingError, socket.error) as e:
|
||||
logger.warning(u'Last.fm now playing error: %s', e)
|
||||
except (pylast.ScrobblingError, pylast.NetworkError,
|
||||
pylast.MalformedResponseError, pylast.WSError) as e:
|
||||
logger.warning(u'Error submitting playing track to Last.fm: %s', e)
|
||||
|
||||
def stopped_playing(self, track, stop_position):
|
||||
artists = ', '.join([a.name for a in track.artists])
|
||||
duration = track.length // 1000
|
||||
duration = track.length and track.length // 1000 or 0
|
||||
stop_position = stop_position // 1000
|
||||
if duration < 30:
|
||||
logger.debug(u'Track too short to scrobble. (30s)')
|
||||
@ -123,11 +99,12 @@ class LastfmFrontendThread(BaseThread):
|
||||
try:
|
||||
self.lastfm.scrobble(
|
||||
artists,
|
||||
track.name,
|
||||
(track.name or ''),
|
||||
str(self.last_start_time),
|
||||
album=track.album.name,
|
||||
album=(track.album and track.album.name or ''),
|
||||
track_number=str(track.track_no),
|
||||
duration=str(duration),
|
||||
mbid=(track.musicbrainz_id or ''))
|
||||
except (pylast.ScrobblingError, socket.error) as e:
|
||||
logger.warning(u'Last.fm scrobbling error: %s', e)
|
||||
except (pylast.ScrobblingError, pylast.NetworkError,
|
||||
pylast.MalformedResponseError, pylast.WSError) as e:
|
||||
logger.warning(u'Error submitting played track to Last.fm: %s', e)
|
||||
|
||||
@ -1,48 +1,43 @@
|
||||
import asyncore
|
||||
import logging
|
||||
|
||||
from pykka.actor import ThreadingActor
|
||||
|
||||
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
|
||||
from mopidy.frontends.mpd.server import MpdServer
|
||||
from mopidy.utils.process import BaseThread
|
||||
|
||||
logger = logging.getLogger('mopidy.frontends.mpd')
|
||||
|
||||
class MpdFrontend(BaseFrontend):
|
||||
class MpdFrontend(ThreadingActor, BaseFrontend):
|
||||
"""
|
||||
The MPD frontend.
|
||||
|
||||
**Settings:**
|
||||
|
||||
- :attr:`mopidy.settings.MPD_SERVER_HOSTNAME`
|
||||
- :attr:`mopidy.settings.MPD_SERVER_PASSWORD`
|
||||
- :attr:`mopidy.settings.MPD_SERVER_PORT`
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(MpdFrontend, self).__init__(*args, **kwargs)
|
||||
self.thread = None
|
||||
self.dispatcher = MpdDispatcher(self.backend)
|
||||
def __init__(self):
|
||||
self._thread = None
|
||||
|
||||
def start(self):
|
||||
"""Starts the MPD server."""
|
||||
self.thread = MpdThread(self.core_queue)
|
||||
self.thread.start()
|
||||
def on_start(self):
|
||||
self._thread = MpdThread()
|
||||
self._thread.start()
|
||||
|
||||
def destroy(self):
|
||||
"""Destroys the MPD server."""
|
||||
self.thread.destroy()
|
||||
def on_receive(self, message):
|
||||
pass # Ignore any messages
|
||||
|
||||
def process_message(self, message):
|
||||
"""
|
||||
Processes messages with the MPD frontend as destination.
|
||||
|
||||
:param message: the message
|
||||
:type message: dict
|
||||
"""
|
||||
assert message['to'] == 'frontend', \
|
||||
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
|
||||
class MpdThread(BaseThread):
|
||||
def __init__(self):
|
||||
super(MpdThread, self).__init__()
|
||||
self.name = u'MpdThread'
|
||||
|
||||
def run_inside_try(self):
|
||||
logger.debug(u'Starting MPD server thread')
|
||||
server = MpdServer()
|
||||
server.start()
|
||||
asyncore.loop()
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import re
|
||||
|
||||
from pykka.registry import ActorRegistry
|
||||
|
||||
from mopidy.backends.base import Backend
|
||||
from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdArgError,
|
||||
MpdUnknownCommand)
|
||||
from mopidy.frontends.mpd.protocol import mpd_commands, request_handlers
|
||||
@ -10,15 +13,27 @@ from mopidy.frontends.mpd.protocol import (audio_output, command_list,
|
||||
connection, current_playlist, empty, music_db, playback, reflection,
|
||||
status, stickers, stored_playlists)
|
||||
# pylint: enable = W0611
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
from mopidy.utils import flatten
|
||||
|
||||
class MpdDispatcher(object):
|
||||
"""
|
||||
Dispatches MPD requests to the correct handler.
|
||||
The MPD session feeds the MPD dispatcher with requests. The dispatcher
|
||||
finds the correct handler, processes the request and sends the response
|
||||
back to the MPD session.
|
||||
"""
|
||||
|
||||
def __init__(self, backend=None):
|
||||
self.backend = backend
|
||||
# XXX Consider merging MpdDispatcher into MpdSession
|
||||
|
||||
def __init__(self):
|
||||
backend_refs = ActorRegistry.get_by_class(Backend)
|
||||
assert len(backend_refs) == 1, 'Expected exactly one running backend.'
|
||||
self.backend = backend_refs[0].proxy()
|
||||
|
||||
mixer_refs = ActorRegistry.get_by_class(BaseMixer)
|
||||
assert len(mixer_refs) == 1, 'Expected exactly one running mixer.'
|
||||
self.mixer = mixer_refs[0].proxy()
|
||||
|
||||
self.command_list = False
|
||||
self.command_list_ok = False
|
||||
|
||||
|
||||
@ -1,22 +1,20 @@
|
||||
from mopidy import MopidyException
|
||||
|
||||
class MpdAckError(MopidyException):
|
||||
"""
|
||||
Available MPD error codes::
|
||||
"""See fields on this class for available MPD error codes"""
|
||||
|
||||
ACK_ERROR_NOT_LIST = 1
|
||||
ACK_ERROR_ARG = 2
|
||||
ACK_ERROR_PASSWORD = 3
|
||||
ACK_ERROR_PERMISSION = 4
|
||||
ACK_ERROR_UNKNOWN = 5
|
||||
ACK_ERROR_NO_EXIST = 50
|
||||
ACK_ERROR_PLAYLIST_MAX = 51
|
||||
ACK_ERROR_SYSTEM = 52
|
||||
ACK_ERROR_PLAYLIST_LOAD = 53
|
||||
ACK_ERROR_UPDATE_ALREADY = 54
|
||||
ACK_ERROR_PLAYER_SYNC = 55
|
||||
ACK_ERROR_EXIST = 56
|
||||
"""
|
||||
ACK_ERROR_NOT_LIST = 1
|
||||
ACK_ERROR_ARG = 2
|
||||
ACK_ERROR_PASSWORD = 3
|
||||
ACK_ERROR_PERMISSION = 4
|
||||
ACK_ERROR_UNKNOWN = 5
|
||||
ACK_ERROR_NO_EXIST = 50
|
||||
ACK_ERROR_PLAYLIST_MAX = 51
|
||||
ACK_ERROR_SYSTEM = 52
|
||||
ACK_ERROR_PLAYLIST_LOAD = 53
|
||||
ACK_ERROR_UPDATE_ALREADY = 54
|
||||
ACK_ERROR_PLAYER_SYNC = 55
|
||||
ACK_ERROR_EXIST = 56
|
||||
|
||||
def __init__(self, message=u'', error_code=0, index=0, command=u''):
|
||||
super(MpdAckError, self).__init__(message, error_code, index, command)
|
||||
@ -37,19 +35,24 @@ class MpdAckError(MopidyException):
|
||||
class MpdArgError(MpdAckError):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(MpdArgError, self).__init__(*args, **kwargs)
|
||||
self.error_code = 2 # ACK_ERROR_ARG
|
||||
self.error_code = MpdAckError.ACK_ERROR_ARG
|
||||
|
||||
class MpdPasswordError(MpdAckError):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(MpdPasswordError, self).__init__(*args, **kwargs)
|
||||
self.error_code = MpdAckError.ACK_ERROR_PASSWORD
|
||||
|
||||
class MpdUnknownCommand(MpdAckError):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(MpdUnknownCommand, self).__init__(*args, **kwargs)
|
||||
self.message = u'unknown command "%s"' % self.command
|
||||
self.command = u''
|
||||
self.error_code = 5 # ACK_ERROR_UNKNOWN
|
||||
self.error_code = MpdAckError.ACK_ERROR_UNKNOWN
|
||||
|
||||
class MpdNoExistError(MpdAckError):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(MpdNoExistError, self).__init__(*args, **kwargs)
|
||||
self.error_code = 50 # ACK_ERROR_NO_EXIST
|
||||
self.error_code = MpdAckError.ACK_ERROR_NO_EXIST
|
||||
|
||||
class MpdNotImplemented(MpdAckError):
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@ -34,6 +34,6 @@ def outputs(frontend):
|
||||
"""
|
||||
return [
|
||||
('outputid', 0),
|
||||
('outputname', frontend.backend.__class__.__name__),
|
||||
('outputname', None),
|
||||
('outputenabled', 1),
|
||||
]
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
from mopidy import settings
|
||||
from mopidy.frontends.mpd.protocol import handle_pattern
|
||||
from mopidy.frontends.mpd.exceptions import MpdNotImplemented
|
||||
from mopidy.frontends.mpd.exceptions import MpdPasswordError
|
||||
|
||||
@handle_pattern(r'^close$')
|
||||
def close(frontend):
|
||||
@ -33,7 +34,11 @@ def password_(frontend, password):
|
||||
This is used for authentication with the server. ``PASSWORD`` is
|
||||
simply the plaintext password.
|
||||
"""
|
||||
raise MpdNotImplemented # TODO
|
||||
# You will not get to this code without being authenticated. This is for
|
||||
# when you are already authenticated, and are sending additional 'password'
|
||||
# requests.
|
||||
if settings.MPD_SERVER_PASSWORD != password:
|
||||
raise MpdPasswordError(u'incorrect password', command=u'password')
|
||||
|
||||
@handle_pattern(r'^ping$')
|
||||
def ping(frontend):
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
from mopidy.frontends.mpd.protocol import handle_pattern
|
||||
from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError,
|
||||
MpdNotImplemented)
|
||||
from mopidy.frontends.mpd.protocol import handle_pattern
|
||||
from mopidy.frontends.mpd.translator import tracks_to_mpd_format
|
||||
|
||||
@handle_pattern(r'^add "(?P<uri>[^"]*)"$')
|
||||
def add(frontend, uri):
|
||||
@ -18,9 +19,9 @@ def add(frontend, uri):
|
||||
"""
|
||||
if not uri:
|
||||
return
|
||||
for handler_prefix in frontend.backend.uri_handlers:
|
||||
for handler_prefix in frontend.backend.uri_handlers.get():
|
||||
if uri.startswith(handler_prefix):
|
||||
track = frontend.backend.library.lookup(uri)
|
||||
track = frontend.backend.library.lookup(uri).get()
|
||||
if track is not None:
|
||||
frontend.backend.current_playlist.add(track)
|
||||
return
|
||||
@ -50,13 +51,14 @@ def addid(frontend, uri, songpos=None):
|
||||
raise MpdNoExistError(u'No such song', command=u'addid')
|
||||
if songpos is not None:
|
||||
songpos = int(songpos)
|
||||
track = frontend.backend.library.lookup(uri)
|
||||
track = frontend.backend.library.lookup(uri).get()
|
||||
if track is None:
|
||||
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.get()):
|
||||
raise MpdArgError(u'Bad song index', command=u'addid')
|
||||
cp_track = frontend.backend.current_playlist.add(track,
|
||||
at_position=songpos)
|
||||
at_position=songpos).get()
|
||||
return ('Id', cp_track[0])
|
||||
|
||||
@handle_pattern(r'^delete "(?P<start>\d+):(?P<end>\d+)*"$')
|
||||
@ -72,8 +74,8 @@ def delete_range(frontend, start, end=None):
|
||||
if end is not None:
|
||||
end = int(end)
|
||||
else:
|
||||
end = len(frontend.backend.current_playlist.tracks)
|
||||
cp_tracks = frontend.backend.current_playlist.cp_tracks[start:end]
|
||||
end = len(frontend.backend.current_playlist.tracks.get())
|
||||
cp_tracks = frontend.backend.current_playlist.cp_tracks.get()[start:end]
|
||||
if not cp_tracks:
|
||||
raise MpdArgError(u'Bad song index', command=u'delete')
|
||||
for (cpid, _) in cp_tracks:
|
||||
@ -84,7 +86,7 @@ def delete_songpos(frontend, songpos):
|
||||
"""See :meth:`delete_range`"""
|
||||
try:
|
||||
songpos = int(songpos)
|
||||
(cpid, _) = frontend.backend.current_playlist.cp_tracks[songpos]
|
||||
(cpid, _) = frontend.backend.current_playlist.cp_tracks.get()[songpos]
|
||||
frontend.backend.current_playlist.remove(cpid=cpid)
|
||||
except IndexError:
|
||||
raise MpdArgError(u'Bad song index', command=u'delete')
|
||||
@ -100,9 +102,9 @@ def deleteid(frontend, cpid):
|
||||
"""
|
||||
try:
|
||||
cpid = int(cpid)
|
||||
if frontend.backend.playback.current_cpid == cpid:
|
||||
if frontend.backend.playback.current_cpid.get() == cpid:
|
||||
frontend.backend.playback.next()
|
||||
return frontend.backend.current_playlist.remove(cpid=cpid)
|
||||
return frontend.backend.current_playlist.remove(cpid=cpid).get()
|
||||
except LookupError:
|
||||
raise MpdNoExistError(u'No such song', command=u'deleteid')
|
||||
|
||||
@ -128,7 +130,7 @@ def move_range(frontend, start, to, end=None):
|
||||
``TO`` in the playlist.
|
||||
"""
|
||||
if end is None:
|
||||
end = len(frontend.backend.current_playlist.tracks)
|
||||
end = len(frontend.backend.current_playlist.tracks.get())
|
||||
start = int(start)
|
||||
end = int(end)
|
||||
to = int(to)
|
||||
@ -154,8 +156,9 @@ def moveid(frontend, cpid, to):
|
||||
"""
|
||||
cpid = int(cpid)
|
||||
to = int(to)
|
||||
cp_track = frontend.backend.current_playlist.get(cpid=cpid)
|
||||
position = frontend.backend.current_playlist.cp_tracks.index(cp_track)
|
||||
cp_track = frontend.backend.current_playlist.get(cpid=cpid).get()
|
||||
position = frontend.backend.current_playlist.cp_tracks.get().index(
|
||||
cp_track)
|
||||
frontend.backend.current_playlist.move(position, position + 1, to)
|
||||
|
||||
@handle_pattern(r'^playlist$')
|
||||
@ -189,9 +192,9 @@ def playlistfind(frontend, tag, needle):
|
||||
"""
|
||||
if tag == 'filename':
|
||||
try:
|
||||
cp_track = frontend.backend.current_playlist.get(uri=needle)
|
||||
cp_track = frontend.backend.current_playlist.get(uri=needle).get()
|
||||
(cpid, track) = cp_track
|
||||
position = frontend.backend.current_playlist.cp_tracks.index(
|
||||
position = frontend.backend.current_playlist.cp_tracks.get().index(
|
||||
cp_track)
|
||||
return track.mpd_format(cpid=cpid, position=position)
|
||||
except LookupError:
|
||||
@ -211,14 +214,17 @@ def playlistid(frontend, cpid=None):
|
||||
if cpid is not None:
|
||||
try:
|
||||
cpid = int(cpid)
|
||||
cp_track = frontend.backend.current_playlist.get(cpid=cpid)
|
||||
position = frontend.backend.current_playlist.cp_tracks.index(
|
||||
cp_track = frontend.backend.current_playlist.get(cpid=cpid).get()
|
||||
position = frontend.backend.current_playlist.cp_tracks.get().index(
|
||||
cp_track)
|
||||
return cp_track[1].mpd_format(position=position, cpid=cpid)
|
||||
except LookupError:
|
||||
raise MpdNoExistError(u'No such song', command=u'playlistid')
|
||||
else:
|
||||
return frontend.backend.current_playlist.mpd_format()
|
||||
cpids = [ct[0] for ct in
|
||||
frontend.backend.current_playlist.cp_tracks.get()]
|
||||
return tracks_to_mpd_format(
|
||||
frontend.backend.current_playlist.tracks.get(), cpids=cpids)
|
||||
|
||||
@handle_pattern(r'^playlistinfo$')
|
||||
@handle_pattern(r'^playlistinfo "(?P<songpos>-?\d+)"$')
|
||||
@ -248,18 +254,27 @@ def playlistinfo(frontend, songpos=None,
|
||||
end = songpos + 1
|
||||
if start == -1:
|
||||
end = None
|
||||
return frontend.backend.current_playlist.mpd_format(start, end)
|
||||
cpids = [ct[0] for ct in
|
||||
frontend.backend.current_playlist.cp_tracks.get()]
|
||||
return tracks_to_mpd_format(
|
||||
frontend.backend.current_playlist.tracks.get(),
|
||||
start, end, cpids=cpids)
|
||||
else:
|
||||
if start is None:
|
||||
start = 0
|
||||
start = int(start)
|
||||
if not (0 <= start <= len(frontend.backend.current_playlist.tracks)):
|
||||
if not (0 <= start <= len(
|
||||
frontend.backend.current_playlist.tracks.get())):
|
||||
raise MpdArgError(u'Bad song index', command=u'playlistinfo')
|
||||
if end is not None:
|
||||
end = int(end)
|
||||
if end > len(frontend.backend.current_playlist.tracks):
|
||||
if end > len(frontend.backend.current_playlist.tracks.get()):
|
||||
end = None
|
||||
return frontend.backend.current_playlist.mpd_format(start, end)
|
||||
cpids = [ct[0] for ct in
|
||||
frontend.backend.current_playlist.cp_tracks.get()]
|
||||
return tracks_to_mpd_format(
|
||||
frontend.backend.current_playlist.tracks.get(),
|
||||
start, end, cpids=cpids)
|
||||
|
||||
@handle_pattern(r'^playlistsearch "(?P<tag>[^"]+)" "(?P<needle>[^"]+)"$')
|
||||
@handle_pattern(r'^playlistsearch (?P<tag>\S+) "(?P<needle>[^"]+)"$')
|
||||
@ -298,7 +313,10 @@ def plchanges(frontend, version):
|
||||
"""
|
||||
# XXX Naive implementation that returns all tracks as changed
|
||||
if int(version) < frontend.backend.current_playlist.version:
|
||||
return frontend.backend.current_playlist.mpd_format()
|
||||
cpids = [ct[0] for ct in
|
||||
frontend.backend.current_playlist.cp_tracks.get()]
|
||||
return tracks_to_mpd_format(
|
||||
frontend.backend.current_playlist.tracks.get(), cpids=cpids)
|
||||
|
||||
@handle_pattern(r'^plchangesposid "(?P<version>\d+)"$')
|
||||
def plchangesposid(frontend, version):
|
||||
@ -315,10 +333,10 @@ def plchangesposid(frontend, version):
|
||||
``playlistlength`` returned by status command.
|
||||
"""
|
||||
# XXX Naive implementation that returns all tracks as changed
|
||||
if int(version) != frontend.backend.current_playlist.version:
|
||||
if int(version) != frontend.backend.current_playlist.version.get():
|
||||
result = []
|
||||
for (position, (cpid, _)) in enumerate(
|
||||
frontend.backend.current_playlist.cp_tracks):
|
||||
frontend.backend.current_playlist.cp_tracks.get()):
|
||||
result.append((u'cpos', position))
|
||||
result.append((u'Id', cpid))
|
||||
return result
|
||||
@ -351,7 +369,7 @@ def swap(frontend, songpos1, songpos2):
|
||||
"""
|
||||
songpos1 = int(songpos1)
|
||||
songpos2 = int(songpos2)
|
||||
tracks = frontend.backend.current_playlist.tracks
|
||||
tracks = frontend.backend.current_playlist.tracks.get()
|
||||
song1 = tracks[songpos1]
|
||||
song2 = tracks[songpos2]
|
||||
del tracks[songpos1]
|
||||
@ -372,8 +390,9 @@ def swapid(frontend, cpid1, cpid2):
|
||||
"""
|
||||
cpid1 = int(cpid1)
|
||||
cpid2 = int(cpid2)
|
||||
cp_track1 = frontend.backend.current_playlist.get(cpid=cpid1)
|
||||
cp_track2 = frontend.backend.current_playlist.get(cpid=cpid2)
|
||||
position1 = frontend.backend.current_playlist.cp_tracks.index(cp_track1)
|
||||
position2 = frontend.backend.current_playlist.cp_tracks.index(cp_track2)
|
||||
cp_track1 = frontend.backend.current_playlist.get(cpid=cpid1).get()
|
||||
cp_track2 = frontend.backend.current_playlist.get(cpid=cpid2).get()
|
||||
cp_tracks = frontend.backend.current_playlist.cp_tracks.get()
|
||||
position1 = cp_tracks.index(cp_track1)
|
||||
position2 = cp_tracks.index(cp_track2)
|
||||
swap(frontend, position1, position2)
|
||||
|
||||
@ -41,8 +41,8 @@ def count(frontend, tag, needle):
|
||||
return [('songs', 0), ('playtime', 0)] # TODO
|
||||
|
||||
@handle_pattern(r'^find '
|
||||
r'(?P<mpd_query>("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"?'
|
||||
' "[^"]+"\s?)+)$')
|
||||
r'(?P<mpd_query>("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|'
|
||||
r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$')
|
||||
def find(frontend, mpd_query):
|
||||
"""
|
||||
*musicpd.org, music database section:*
|
||||
@ -62,9 +62,13 @@ def find(frontend, mpd_query):
|
||||
|
||||
- does not add quotes around the field argument.
|
||||
- capitalizes the type argument.
|
||||
|
||||
*ncmpcpp:*
|
||||
|
||||
- also uses the search type "date".
|
||||
"""
|
||||
query = _build_query(mpd_query)
|
||||
return frontend.backend.library.find_exact(**query).mpd_format()
|
||||
return frontend.backend.library.find_exact(**query).get().mpd_format()
|
||||
|
||||
@handle_pattern(r'^findadd '
|
||||
r'(?P<query>("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? '
|
||||
@ -211,7 +215,7 @@ def _list_build_query(field, mpd_query):
|
||||
|
||||
def _list_artist(frontend, query):
|
||||
artists = set()
|
||||
playlist = frontend.backend.library.find_exact(**query)
|
||||
playlist = frontend.backend.library.find_exact(**query).get()
|
||||
for track in playlist.tracks:
|
||||
for artist in track.artists:
|
||||
artists.add((u'Artist', artist.name))
|
||||
@ -219,7 +223,7 @@ def _list_artist(frontend, query):
|
||||
|
||||
def _list_album(frontend, query):
|
||||
albums = set()
|
||||
playlist = frontend.backend.library.find_exact(**query)
|
||||
playlist = frontend.backend.library.find_exact(**query).get()
|
||||
for track in playlist.tracks:
|
||||
if track.album is not None:
|
||||
albums.add((u'Album', track.album.name))
|
||||
@ -227,7 +231,7 @@ def _list_album(frontend, query):
|
||||
|
||||
def _list_date(frontend, query):
|
||||
dates = set()
|
||||
playlist = frontend.backend.library.find_exact(**query)
|
||||
playlist = frontend.backend.library.find_exact(**query).get()
|
||||
for track in playlist.tracks:
|
||||
if track.date is not None:
|
||||
dates.add((u'Date', track.date.strftime('%Y-%m-%d')))
|
||||
@ -290,8 +294,8 @@ def rescan(frontend, uri=None):
|
||||
return update(frontend, uri, rescan_unmodified_files=True)
|
||||
|
||||
@handle_pattern(r'^search '
|
||||
r'(?P<mpd_query>("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"?'
|
||||
' "[^"]+"\s?)+)$')
|
||||
r'(?P<mpd_query>("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|'
|
||||
r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$')
|
||||
def search(frontend, mpd_query):
|
||||
"""
|
||||
*musicpd.org, music database section:*
|
||||
@ -314,9 +318,13 @@ def search(frontend, mpd_query):
|
||||
|
||||
- does not add quotes around the field argument.
|
||||
- capitalizes the field argument.
|
||||
|
||||
*ncmpcpp:*
|
||||
|
||||
- also uses the search type "date".
|
||||
"""
|
||||
query = _build_query(mpd_query)
|
||||
return frontend.backend.library.search(**query).mpd_format()
|
||||
return frontend.backend.library.search(**query).get().mpd_format()
|
||||
|
||||
@handle_pattern(r'^update( "(?P<uri>[^"]+)")*$')
|
||||
def update(frontend, uri=None, rescan_unmodified_files=False):
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from mopidy.backends.base import PlaybackController
|
||||
from mopidy.frontends.mpd.protocol import handle_pattern
|
||||
from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError,
|
||||
MpdNotImplemented)
|
||||
@ -86,7 +87,7 @@ def next_(frontend):
|
||||
order as the first time.
|
||||
|
||||
"""
|
||||
return frontend.backend.playback.next()
|
||||
return frontend.backend.playback.next().get()
|
||||
|
||||
@handle_pattern(r'^pause$')
|
||||
@handle_pattern(r'^pause "(?P<state>[01])"$')
|
||||
@ -103,11 +104,11 @@ def pause(frontend, state=None):
|
||||
- Calls ``pause`` without any arguments to toogle pause.
|
||||
"""
|
||||
if state is None:
|
||||
if (frontend.backend.playback.state ==
|
||||
frontend.backend.playback.PLAYING):
|
||||
if (frontend.backend.playback.state.get() ==
|
||||
PlaybackController.PLAYING):
|
||||
frontend.backend.playback.pause()
|
||||
elif (frontend.backend.playback.state ==
|
||||
frontend.backend.playback.PAUSED):
|
||||
elif (frontend.backend.playback.state.get() ==
|
||||
PlaybackController.PAUSED):
|
||||
frontend.backend.playback.resume()
|
||||
elif int(state):
|
||||
frontend.backend.playback.pause()
|
||||
@ -120,7 +121,7 @@ def play(frontend):
|
||||
The original MPD server resumes from the paused state on ``play``
|
||||
without arguments.
|
||||
"""
|
||||
return frontend.backend.playback.play()
|
||||
return frontend.backend.playback.play().get()
|
||||
|
||||
@handle_pattern(r'^playid "(?P<cpid>\d+)"$')
|
||||
@handle_pattern(r'^playid "(?P<cpid>-1)"$')
|
||||
@ -132,22 +133,21 @@ def playid(frontend, cpid):
|
||||
|
||||
Begins playing the playlist at song ``SONGID``.
|
||||
|
||||
*GMPC:*
|
||||
*Clarifications:*
|
||||
|
||||
- issues ``playid "-1"`` after playlist replacement to start playback
|
||||
at the first track.
|
||||
- ``playid "-1"`` when playing is ignored.
|
||||
- ``playid "-1"`` when paused resumes playback.
|
||||
- ``playid "-1"`` when stopped with a current track starts playback at the
|
||||
current track.
|
||||
- ``playid "-1"`` when stopped without a current track, e.g. after playlist
|
||||
replacement, starts playback at the first track.
|
||||
"""
|
||||
cpid = int(cpid)
|
||||
paused = (frontend.backend.playback.state ==
|
||||
frontend.backend.playback.PAUSED)
|
||||
if cpid == -1 and paused:
|
||||
return frontend.backend.playback.resume()
|
||||
if cpid == -1:
|
||||
return _play_minus_one(frontend)
|
||||
try:
|
||||
if cpid == -1:
|
||||
cp_track = _get_cp_track_for_play_minus_one(frontend)
|
||||
else:
|
||||
cp_track = frontend.backend.current_playlist.get(cpid=cpid)
|
||||
return frontend.backend.playback.play(cp_track)
|
||||
cp_track = frontend.backend.current_playlist.get(cpid=cpid).get()
|
||||
return frontend.backend.playback.play(cp_track).get()
|
||||
except LookupError:
|
||||
raise MpdNoExistError(u'No such song', command=u'playid')
|
||||
|
||||
@ -161,33 +161,41 @@ def playpos(frontend, songpos):
|
||||
|
||||
Begins playing the playlist at song number ``SONGPOS``.
|
||||
|
||||
*Many clients:*
|
||||
*Clarifications:*
|
||||
|
||||
- issue ``play "-1"`` after playlist replacement to start the current
|
||||
track. If the current track is not set, start playback at the first
|
||||
track.
|
||||
- ``playid "-1"`` when playing is ignored.
|
||||
- ``playid "-1"`` when paused resumes playback.
|
||||
- ``playid "-1"`` when stopped with a current track starts playback at the
|
||||
current track.
|
||||
- ``playid "-1"`` when stopped without a current track, e.g. after playlist
|
||||
replacement, starts playback at the first track.
|
||||
|
||||
*BitMPC:*
|
||||
|
||||
- issues ``play 6`` without quotes around the argument.
|
||||
"""
|
||||
songpos = int(songpos)
|
||||
if songpos == -1:
|
||||
return _play_minus_one(frontend)
|
||||
try:
|
||||
if songpos == -1:
|
||||
cp_track = _get_cp_track_for_play_minus_one(frontend)
|
||||
else:
|
||||
cp_track = frontend.backend.current_playlist.cp_tracks[songpos]
|
||||
return frontend.backend.playback.play(cp_track)
|
||||
cp_track = frontend.backend.current_playlist.cp_tracks.get()[songpos]
|
||||
return frontend.backend.playback.play(cp_track).get()
|
||||
except IndexError:
|
||||
raise MpdArgError(u'Bad song index', command=u'play')
|
||||
|
||||
def _get_cp_track_for_play_minus_one(frontend):
|
||||
if not frontend.backend.current_playlist.cp_tracks:
|
||||
def _play_minus_one(frontend):
|
||||
if (frontend.backend.playback.state.get() == PlaybackController.PLAYING):
|
||||
return # Nothing to do
|
||||
elif (frontend.backend.playback.state.get() == PlaybackController.PAUSED):
|
||||
return frontend.backend.playback.resume().get()
|
||||
elif frontend.backend.playback.current_cp_track.get() is not None:
|
||||
cp_track = frontend.backend.playback.current_cp_track.get()
|
||||
return frontend.backend.playback.play(cp_track).get()
|
||||
elif frontend.backend.current_playlist.cp_tracks.get():
|
||||
cp_track = frontend.backend.current_playlist.cp_tracks.get()[0]
|
||||
return frontend.backend.playback.play(cp_track).get()
|
||||
else:
|
||||
return # Fail silently
|
||||
cp_track = frontend.backend.playback.current_cp_track
|
||||
if cp_track is None:
|
||||
cp_track = frontend.backend.current_playlist.cp_tracks[0]
|
||||
return cp_track
|
||||
|
||||
@handle_pattern(r'^previous$')
|
||||
def previous(frontend):
|
||||
@ -233,7 +241,7 @@ def previous(frontend):
|
||||
``previous`` should do a seek to time position 0.
|
||||
|
||||
"""
|
||||
return frontend.backend.playback.previous()
|
||||
return frontend.backend.playback.previous().get()
|
||||
|
||||
@handle_pattern(r'^random (?P<state>[01])$')
|
||||
@handle_pattern(r'^random "(?P<state>[01])"$')
|
||||
@ -344,7 +352,7 @@ def setvol(frontend, volume):
|
||||
volume = 0
|
||||
if volume > 100:
|
||||
volume = 100
|
||||
frontend.backend.mixer.volume = volume
|
||||
frontend.mixer.volume = volume
|
||||
|
||||
@handle_pattern(r'^single (?P<state>[01])$')
|
||||
@handle_pattern(r'^single "(?P<state>[01])"$')
|
||||
|
||||
@ -9,9 +9,12 @@ def commands(frontend):
|
||||
``commands``
|
||||
|
||||
Shows which commands the current user has access to.
|
||||
|
||||
As permissions is not implemented, any user has access to all commands.
|
||||
"""
|
||||
# FIXME When password auth is turned on and the client is not
|
||||
# authenticated, 'commands' should list only the commands the client does
|
||||
# have access to. To implement this we need access to the session object to
|
||||
# check if the client is authenticated or not.
|
||||
|
||||
sorted_commands = sorted(list(mpd_commands))
|
||||
|
||||
# Not shown by MPD in its command list
|
||||
@ -51,9 +54,11 @@ def notcommands(frontend):
|
||||
``notcommands``
|
||||
|
||||
Shows which commands the current user does not have access to.
|
||||
|
||||
As permissions is not implemented, any user has access to all commands.
|
||||
"""
|
||||
# FIXME When password auth is turned on and the client is not
|
||||
# authenticated, 'notcommands' should list all the commands the client does
|
||||
# not have access to. To implement this we need access to the session
|
||||
# object to check if the client is authenticated or not.
|
||||
pass
|
||||
|
||||
@handle_pattern(r'^tagtypes$')
|
||||
@ -76,4 +81,4 @@ def urlhandlers(frontend):
|
||||
|
||||
Gets a list of available URL handlers.
|
||||
"""
|
||||
return [(u'handler', uri) for uri in frontend.backend.uri_handlers]
|
||||
return [(u'handler', uri) for uri in frontend.backend.uri_handlers.get()]
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from mopidy.backends.base import PlaybackController
|
||||
from mopidy.frontends.mpd.protocol import handle_pattern
|
||||
from mopidy.frontends.mpd.exceptions import MpdNotImplemented
|
||||
|
||||
@ -23,10 +24,11 @@ def currentsong(frontend):
|
||||
Displays the song info of the current song (same song that is
|
||||
identified in status).
|
||||
"""
|
||||
if frontend.backend.playback.current_track is not None:
|
||||
return frontend.backend.playback.current_track.mpd_format(
|
||||
position=frontend.backend.playback.current_playlist_position,
|
||||
cpid=frontend.backend.playback.current_cpid)
|
||||
current_cp_track = frontend.backend.playback.current_cp_track.get()
|
||||
if current_cp_track is not None:
|
||||
return current_cp_track[1].mpd_format(
|
||||
position=frontend.backend.playback.current_playlist_position.get(),
|
||||
cpid=current_cp_track[0])
|
||||
|
||||
@handle_pattern(r'^idle$')
|
||||
@handle_pattern(r'^idle (?P<subsystems>.+)$')
|
||||
@ -90,8 +92,7 @@ def stats(frontend):
|
||||
'artists': 0, # TODO
|
||||
'albums': 0, # TODO
|
||||
'songs': 0, # TODO
|
||||
# TODO Does not work after multiprocessing branch merge
|
||||
'uptime': 0, # frontend.session.stats_uptime(),
|
||||
'uptime': 0, # TODO
|
||||
'db_playtime': 0, # TODO
|
||||
'db_update': 0, # TODO
|
||||
'playtime': 0, # TODO
|
||||
@ -140,56 +141,59 @@ def status(frontend):
|
||||
('xfade', _status_xfade(frontend)),
|
||||
('state', _status_state(frontend)),
|
||||
]
|
||||
if frontend.backend.playback.current_track is not None:
|
||||
if frontend.backend.playback.current_track.get() is not None:
|
||||
result.append(('song', _status_songpos(frontend)))
|
||||
result.append(('songid', _status_songid(frontend)))
|
||||
if frontend.backend.playback.state in (frontend.backend.playback.PLAYING,
|
||||
frontend.backend.playback.PAUSED):
|
||||
if frontend.backend.playback.state.get() in (PlaybackController.PLAYING,
|
||||
PlaybackController.PAUSED):
|
||||
result.append(('time', _status_time(frontend)))
|
||||
result.append(('elapsed', _status_time_elapsed(frontend)))
|
||||
result.append(('bitrate', _status_bitrate(frontend)))
|
||||
return result
|
||||
|
||||
def _status_bitrate(frontend):
|
||||
if frontend.backend.playback.current_track is not None:
|
||||
return frontend.backend.playback.current_track.bitrate
|
||||
current_track = frontend.backend.playback.current_track.get()
|
||||
if current_track is not None:
|
||||
return current_track.bitrate
|
||||
|
||||
def _status_consume(frontend):
|
||||
if frontend.backend.playback.consume:
|
||||
if frontend.backend.playback.consume.get():
|
||||
return 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
def _status_playlist_length(frontend):
|
||||
return len(frontend.backend.current_playlist.tracks)
|
||||
return len(frontend.backend.current_playlist.tracks.get())
|
||||
|
||||
def _status_playlist_version(frontend):
|
||||
return frontend.backend.current_playlist.version
|
||||
return frontend.backend.current_playlist.version.get()
|
||||
|
||||
def _status_random(frontend):
|
||||
return int(frontend.backend.playback.random)
|
||||
return int(frontend.backend.playback.random.get())
|
||||
|
||||
def _status_repeat(frontend):
|
||||
return int(frontend.backend.playback.repeat)
|
||||
return int(frontend.backend.playback.repeat.get())
|
||||
|
||||
def _status_single(frontend):
|
||||
return int(frontend.backend.playback.single)
|
||||
return int(frontend.backend.playback.single.get())
|
||||
|
||||
def _status_songid(frontend):
|
||||
if frontend.backend.playback.current_cpid is not None:
|
||||
return frontend.backend.playback.current_cpid
|
||||
current_cpid = frontend.backend.playback.current_cpid.get()
|
||||
if current_cpid is not None:
|
||||
return current_cpid
|
||||
else:
|
||||
return _status_songpos(frontend)
|
||||
|
||||
def _status_songpos(frontend):
|
||||
return frontend.backend.playback.current_playlist_position
|
||||
return frontend.backend.playback.current_playlist_position.get()
|
||||
|
||||
def _status_state(frontend):
|
||||
if frontend.backend.playback.state == frontend.backend.playback.PLAYING:
|
||||
state = frontend.backend.playback.state.get()
|
||||
if state == PlaybackController.PLAYING:
|
||||
return u'play'
|
||||
elif frontend.backend.playback.state == frontend.backend.playback.STOPPED:
|
||||
elif state == PlaybackController.STOPPED:
|
||||
return u'stop'
|
||||
elif frontend.backend.playback.state == frontend.backend.playback.PAUSED:
|
||||
elif state == PlaybackController.PAUSED:
|
||||
return u'pause'
|
||||
|
||||
def _status_time(frontend):
|
||||
@ -197,19 +201,21 @@ def _status_time(frontend):
|
||||
_status_time_total(frontend) // 1000)
|
||||
|
||||
def _status_time_elapsed(frontend):
|
||||
return frontend.backend.playback.time_position
|
||||
return frontend.backend.playback.time_position.get()
|
||||
|
||||
def _status_time_total(frontend):
|
||||
if frontend.backend.playback.current_track is None:
|
||||
current_track = frontend.backend.playback.current_track.get()
|
||||
if current_track is None:
|
||||
return 0
|
||||
elif frontend.backend.playback.current_track.length is None:
|
||||
elif current_track.length is None:
|
||||
return 0
|
||||
else:
|
||||
return frontend.backend.playback.current_track.length
|
||||
return current_track.length
|
||||
|
||||
def _status_volume(frontend):
|
||||
if frontend.backend.mixer.volume is not None:
|
||||
return frontend.backend.mixer.volume
|
||||
volume = frontend.mixer.volume.get()
|
||||
if volume is not None:
|
||||
return volume
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
@ -19,8 +19,8 @@ def listplaylist(frontend, name):
|
||||
file: relative/path/to/file3.mp3
|
||||
"""
|
||||
try:
|
||||
return ['file: %s' % t.uri
|
||||
for t in frontend.backend.stored_playlists.get(name=name).tracks]
|
||||
playlist = frontend.backend.stored_playlists.get(name=name).get()
|
||||
return ['file: %s' % t.uri for t in playlist.tracks]
|
||||
except LookupError:
|
||||
raise MpdNoExistError(u'No such playlist', command=u'listplaylist')
|
||||
|
||||
@ -39,7 +39,8 @@ def listplaylistinfo(frontend, name):
|
||||
Album, Artist, Track
|
||||
"""
|
||||
try:
|
||||
return frontend.backend.stored_playlists.get(name=name).mpd_format()
|
||||
playlist = frontend.backend.stored_playlists.get(name=name).get()
|
||||
return playlist.mpd_format()
|
||||
except LookupError:
|
||||
raise MpdNoExistError(
|
||||
u'No such playlist', command=u'listplaylistinfo')
|
||||
@ -66,7 +67,7 @@ def listplaylists(frontend):
|
||||
Last-Modified: 2010-02-06T02:11:08Z
|
||||
"""
|
||||
result = []
|
||||
for playlist in frontend.backend.stored_playlists.playlists:
|
||||
for playlist in frontend.backend.stored_playlists.playlists.get():
|
||||
result.append((u'playlist', playlist.name))
|
||||
last_modified = (playlist.last_modified or
|
||||
dt.datetime.now()).isoformat()
|
||||
@ -92,7 +93,7 @@ def load(frontend, name):
|
||||
- ``load`` appends the given playlist to the current playlist.
|
||||
"""
|
||||
try:
|
||||
playlist = frontend.backend.stored_playlists.get(name=name)
|
||||
playlist = frontend.backend.stored_playlists.get(name=name).get()
|
||||
frontend.backend.current_playlist.append(playlist.tracks)
|
||||
except LookupError:
|
||||
raise MpdNoExistError(u'No such playlist', command=u'load')
|
||||
|
||||
@ -15,9 +15,8 @@ class MpdServer(asyncore.dispatcher):
|
||||
for each client connection.
|
||||
"""
|
||||
|
||||
def __init__(self, core_queue):
|
||||
def __init__(self):
|
||||
asyncore.dispatcher.__init__(self)
|
||||
self.core_queue = core_queue
|
||||
|
||||
def start(self):
|
||||
"""Start MPD server."""
|
||||
@ -47,8 +46,7 @@ class MpdServer(asyncore.dispatcher):
|
||||
(client_socket, client_socket_address) = self.accept()
|
||||
logger.info(u'MPD client connection from [%s]:%s',
|
||||
client_socket_address[0], client_socket_address[1])
|
||||
MpdSession(self, client_socket, client_socket_address,
|
||||
self.core_queue).start()
|
||||
MpdSession(self, client_socket, client_socket_address).start()
|
||||
|
||||
def handle_close(self):
|
||||
"""Handle end of client connection."""
|
||||
|
||||
@ -1,28 +1,28 @@
|
||||
import asynchat
|
||||
import logging
|
||||
import multiprocessing
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.frontends.mpd.dispatcher import MpdDispatcher
|
||||
from mopidy.frontends.mpd.protocol import ENCODING, LINE_TERMINATOR, VERSION
|
||||
from mopidy.utils.log import indent
|
||||
from mopidy.utils.process import pickle_connection
|
||||
|
||||
logger = logging.getLogger('mopidy.frontends.mpd.session')
|
||||
|
||||
class MpdSession(asynchat.async_chat):
|
||||
"""
|
||||
The MPD client session. Keeps track of a single client and passes its
|
||||
MPD requests to the dispatcher.
|
||||
The MPD client session. Keeps track of a single client session. Any
|
||||
requests from the client is passed on to the MPD request dispatcher.
|
||||
"""
|
||||
|
||||
def __init__(self, server, client_socket, client_socket_address,
|
||||
core_queue):
|
||||
def __init__(self, server, client_socket, client_socket_address):
|
||||
asynchat.async_chat.__init__(self, sock=client_socket)
|
||||
self.server = server
|
||||
self.client_address = client_socket_address[0]
|
||||
self.client_port = client_socket_address[1]
|
||||
self.core_queue = core_queue
|
||||
self.input_buffer = []
|
||||
self.authenticated = False
|
||||
self.set_terminator(LINE_TERMINATOR.encode(ENCODING))
|
||||
self.dispatcher = MpdDispatcher()
|
||||
|
||||
def start(self):
|
||||
"""Start a new client session."""
|
||||
@ -46,15 +46,12 @@ class MpdSession(asynchat.async_chat):
|
||||
|
||||
def handle_request(self, request):
|
||||
"""Handle request by sending it to the MPD frontend."""
|
||||
my_end, other_end = multiprocessing.Pipe()
|
||||
self.core_queue.put({
|
||||
'to': 'frontend',
|
||||
'command': 'mpd_request',
|
||||
'request': request,
|
||||
'reply_to': pickle_connection(other_end),
|
||||
})
|
||||
my_end.poll(None)
|
||||
response = my_end.recv()
|
||||
if not self.authenticated:
|
||||
(self.authenticated, response) = self.check_password(request)
|
||||
if response is not None:
|
||||
self.send_response(response)
|
||||
return
|
||||
response = self.dispatcher.handle_request(request)
|
||||
if response is not None:
|
||||
self.handle_response(response)
|
||||
|
||||
@ -69,3 +66,26 @@ class MpdSession(asynchat.async_chat):
|
||||
output = u'%s%s' % (output, LINE_TERMINATOR)
|
||||
data = output.encode(ENCODING)
|
||||
self.push(data)
|
||||
|
||||
def check_password(self, request):
|
||||
"""
|
||||
Takes any request and tries to authenticate the client using it.
|
||||
|
||||
:rtype: a two-tuple containing (is_authenticated, response_message). If
|
||||
the response_message is :class:`None`, normal processing should
|
||||
continue, even though the client may not be authenticated.
|
||||
"""
|
||||
if settings.MPD_SERVER_PASSWORD is None:
|
||||
return (True, None)
|
||||
command = request.split(' ')[0]
|
||||
if command == 'password':
|
||||
if request == 'password "%s"' % settings.MPD_SERVER_PASSWORD:
|
||||
return (True, u'OK')
|
||||
else:
|
||||
return (False, u'ACK [3@0] {password} incorrect password')
|
||||
if command in ('close', 'commands', 'notcommands', 'ping'):
|
||||
return (False, None)
|
||||
else:
|
||||
return (False,
|
||||
u'ACK [4@0] {%(c)s} you don\'t have permission for "%(c)s"' %
|
||||
{'c': command})
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
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()
|
||||
@ -84,8 +84,9 @@ def artists_to_mpd_format(artists):
|
||||
:type track: array of :class:`mopidy.models.Artist`
|
||||
:rtype: string
|
||||
"""
|
||||
artists = list(artists)
|
||||
artists.sort(key=lambda a: a.name)
|
||||
return u', '.join([a.name for a in artists])
|
||||
return u', '.join([a.name for a in artists if a.name])
|
||||
|
||||
def tracks_to_mpd_format(tracks, start=0, end=None, cpids=None):
|
||||
"""
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import alsaaudio
|
||||
import logging
|
||||
|
||||
from pykka.actor import ThreadingActor
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
|
||||
logger = logging.getLogger('mopidy.mixers.alsa')
|
||||
|
||||
class AlsaMixer(BaseMixer):
|
||||
class AlsaMixer(ThreadingActor, BaseMixer):
|
||||
"""
|
||||
Mixer which uses the Advanced Linux Sound Architecture (ALSA) to control
|
||||
volume.
|
||||
@ -20,8 +22,10 @@ class AlsaMixer(BaseMixer):
|
||||
- :attr:`mopidy.settings.MIXER_ALSA_CONTROL`
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(AlsaMixer, self).__init__(*args, **kwargs)
|
||||
def __init__(self):
|
||||
self._mixer = None
|
||||
|
||||
def on_start(self):
|
||||
self._mixer = alsaaudio.Mixer(self._get_mixer_control())
|
||||
assert self._mixer is not None
|
||||
|
||||
|
||||
@ -2,17 +2,12 @@ from mopidy import settings
|
||||
|
||||
class BaseMixer(object):
|
||||
"""
|
||||
:param backend: a backend instance
|
||||
:type backend: :class:`mopidy.backends.base.Backend`
|
||||
|
||||
**Settings:**
|
||||
|
||||
- :attr:`mopidy.settings.MIXER_MAX_VOLUME`
|
||||
"""
|
||||
|
||||
def __init__(self, backend, *args, **kwargs):
|
||||
self.backend = backend
|
||||
self.amplification_factor = settings.MIXER_MAX_VOLUME / 100.0
|
||||
amplification_factor = settings.MIXER_MAX_VOLUME / 100.0
|
||||
|
||||
@property
|
||||
def volume(self):
|
||||
@ -35,9 +30,6 @@ class BaseMixer(object):
|
||||
volume = 100
|
||||
self._set_volume(volume)
|
||||
|
||||
def destroy(self):
|
||||
pass
|
||||
|
||||
def _get_volume(self):
|
||||
"""
|
||||
Return volume as integer in range [0, 100]. :class:`None` if unknown.
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import logging
|
||||
from threading import Lock
|
||||
|
||||
from pykka.actor import ThreadingActor
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
|
||||
logger = logging.getLogger(u'mopidy.mixers.denon')
|
||||
|
||||
class DenonMixer(BaseMixer):
|
||||
class DenonMixer(ThreadingActor, BaseMixer):
|
||||
"""
|
||||
Mixer for controlling Denon amplifiers and receivers using the RS-232
|
||||
protocol.
|
||||
@ -25,27 +26,19 @@ class DenonMixer(BaseMixer):
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Connects using the serial specifications from Denon's RS-232 Protocol
|
||||
specification: 9600bps 8N1.
|
||||
"""
|
||||
super(DenonMixer, self).__init__(*args, **kwargs)
|
||||
device = kwargs.get('device', None)
|
||||
if device:
|
||||
self._device = device
|
||||
else:
|
||||
from serial import Serial
|
||||
self._device = Serial(port=settings.MIXER_EXT_PORT, timeout=0.2)
|
||||
self._device = kwargs.get('device', None)
|
||||
self._levels = ['99'] + ["%(#)02d" % {'#': v} for v in range(0, 99)]
|
||||
self._volume = 0
|
||||
self._lock = Lock()
|
||||
|
||||
def on_start(self):
|
||||
if self._device is None:
|
||||
from serial import Serial
|
||||
self._device = Serial(port=settings.MIXER_EXT_PORT, timeout=0.2)
|
||||
|
||||
def _get_volume(self):
|
||||
self._lock.acquire()
|
||||
self.ensure_open_device()
|
||||
self._ensure_open_device()
|
||||
self._device.write('MV?\r')
|
||||
vol = str(self._device.readline()[2:4])
|
||||
self._lock.release()
|
||||
logger.debug(u'_get_volume() = %s' % vol)
|
||||
return self._levels.index(vol)
|
||||
|
||||
@ -53,14 +46,12 @@ class DenonMixer(BaseMixer):
|
||||
# Clamp according to Denon-spec
|
||||
if volume > 99:
|
||||
volume = 99
|
||||
self._lock.acquire()
|
||||
self.ensure_open_device()
|
||||
self._ensure_open_device()
|
||||
self._device.write('MV%s\r'% self._levels[volume])
|
||||
vol = self._device.readline()[2:4]
|
||||
self._lock.release()
|
||||
self._volume = self._levels.index(vol)
|
||||
|
||||
def ensure_open_device(self):
|
||||
def _ensure_open_device(self):
|
||||
if not self._device.isOpen():
|
||||
logger.debug(u'(re)connecting to Denon device')
|
||||
self._device.open()
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
from pykka.actor import ThreadingActor
|
||||
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
|
||||
class DummyMixer(BaseMixer):
|
||||
class DummyMixer(ThreadingActor, BaseMixer):
|
||||
"""Mixer which just stores and reports the chosen volume."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(DummyMixer, self).__init__(*args, **kwargs)
|
||||
def __init__(self):
|
||||
self._volume = None
|
||||
|
||||
def _get_volume(self):
|
||||
|
||||
@ -1,13 +1,22 @@
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
from pykka.actor import ThreadingActor
|
||||
from pykka.registry import ActorRegistry
|
||||
|
||||
class GStreamerSoftwareMixer(BaseMixer):
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
from mopidy.outputs.base import BaseOutput
|
||||
|
||||
class GStreamerSoftwareMixer(ThreadingActor, BaseMixer):
|
||||
"""Mixer which uses GStreamer to control volume in software."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(GStreamerSoftwareMixer, self).__init__(*args, **kwargs)
|
||||
def __init__(self):
|
||||
self.output = None
|
||||
|
||||
def on_start(self):
|
||||
output_refs = ActorRegistry.get_by_class(BaseOutput)
|
||||
assert len(output_refs) == 1, 'Expected exactly one running output.'
|
||||
self.output = output_refs[0].proxy()
|
||||
|
||||
def _get_volume(self):
|
||||
return self.backend.output.get_volume()
|
||||
return self.output.get_volume().get()
|
||||
|
||||
def _set_volume(self, volume):
|
||||
self.backend.output.set_volume(volume)
|
||||
self.output.set_volume(volume).get()
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import logging
|
||||
from serial import Serial
|
||||
from multiprocessing import Pipe
|
||||
import serial
|
||||
|
||||
from pykka.actor import ThreadingActor
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
from mopidy.utils.process import BaseThread
|
||||
|
||||
logger = logging.getLogger('mopidy.mixers.nad')
|
||||
|
||||
class NadMixer(BaseMixer):
|
||||
class NadMixer(ThreadingActor, BaseMixer):
|
||||
"""
|
||||
Mixer for controlling NAD amplifiers and receivers using the NAD RS-232
|
||||
protocol.
|
||||
@ -36,21 +36,19 @@ class NadMixer(BaseMixer):
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(NadMixer, self).__init__(*args, **kwargs)
|
||||
self._volume = None
|
||||
self._pipe, other_end = Pipe()
|
||||
NadTalker(self.backend.core_queue, pipe=other_end).start()
|
||||
def __init__(self):
|
||||
self._volume_cache = None
|
||||
self._nad_talker = NadTalker.start().proxy()
|
||||
|
||||
def _get_volume(self):
|
||||
return self._volume
|
||||
return self._volume_cache
|
||||
|
||||
def _set_volume(self, volume):
|
||||
self._volume = volume
|
||||
self._pipe.send({'command': 'set_volume', 'volume': volume})
|
||||
self._volume_cache = volume
|
||||
self._nad_talker.set_volume(volume)
|
||||
|
||||
|
||||
class NadTalker(BaseThread):
|
||||
class NadTalker(ThreadingActor):
|
||||
"""
|
||||
Independent process which does the communication with the NAD device.
|
||||
|
||||
@ -72,29 +70,20 @@ class NadTalker(BaseThread):
|
||||
# Volume in range 0..VOLUME_LEVELS. :class:`None` before calibration.
|
||||
_nad_volume = None
|
||||
|
||||
def __init__(self, core_queue, pipe=None):
|
||||
super(NadTalker, self).__init__(core_queue)
|
||||
self.name = u'NadTalker'
|
||||
self.pipe = pipe
|
||||
def __init__(self):
|
||||
self._device = None
|
||||
|
||||
def run_inside_try(self):
|
||||
def on_start(self):
|
||||
self._open_connection()
|
||||
self._set_device_to_known_state()
|
||||
while self.pipe.poll(None):
|
||||
message = self.pipe.recv()
|
||||
if message['command'] == 'set_volume':
|
||||
self._set_volume(message['volume'])
|
||||
elif message['command'] == 'reset_device':
|
||||
self._set_device_to_known_state()
|
||||
|
||||
def _open_connection(self):
|
||||
# Opens serial connection to the device.
|
||||
# Communication settings: 115200 bps 8N1
|
||||
logger.info(u'Connecting to serial device "%s"',
|
||||
settings.MIXER_EXT_PORT)
|
||||
self._device = Serial(port=settings.MIXER_EXT_PORT, baudrate=115200,
|
||||
timeout=self.TIMEOUT)
|
||||
self._device = serial.Serial(port=settings.MIXER_EXT_PORT,
|
||||
baudrate=115200, timeout=self.TIMEOUT)
|
||||
self._get_device_model()
|
||||
|
||||
def _set_device_to_known_state(self):
|
||||
@ -147,6 +136,8 @@ class NadTalker(BaseThread):
|
||||
return self._readline().replace('%s=' % key, '')
|
||||
|
||||
def _command_device(self, key, value):
|
||||
if type(value) == unicode:
|
||||
value = value.encode('utf-8')
|
||||
self._write('%s=%s' % (key, value))
|
||||
self._readline()
|
||||
|
||||
@ -162,7 +153,7 @@ class NadTalker(BaseThread):
|
||||
self._nad_volume = 0
|
||||
logger.info(u'Done calibrating NAD amplifier')
|
||||
|
||||
def _set_volume(self, volume):
|
||||
def set_volume(self, volume):
|
||||
# Increase or decrease the amplifier volume until it matches the given
|
||||
# target volume.
|
||||
logger.debug(u'Setting volume to %d' % volume)
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
from subprocess import Popen, PIPE
|
||||
import time
|
||||
|
||||
from pykka.actor import ThreadingActor
|
||||
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
|
||||
class OsaMixer(BaseMixer):
|
||||
class OsaMixer(ThreadingActor, BaseMixer):
|
||||
"""
|
||||
Mixer which uses ``osascript`` on OS X to control volume.
|
||||
|
||||
@ -14,7 +16,6 @@ class OsaMixer(BaseMixer):
|
||||
**Settings:**
|
||||
|
||||
- None
|
||||
|
||||
"""
|
||||
|
||||
CACHE_TTL = 30
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
from copy import copy
|
||||
|
||||
from mopidy.frontends.mpd import translator
|
||||
|
||||
class ImmutableObject(object):
|
||||
@ -23,6 +21,17 @@ class ImmutableObject(object):
|
||||
return super(ImmutableObject, self).__setattr__(name, value)
|
||||
raise AttributeError('Object is immutable.')
|
||||
|
||||
def __repr__(self):
|
||||
kwarg_pairs = []
|
||||
for (key, value) in sorted(self.__dict__.items()):
|
||||
if isinstance(value, (frozenset, tuple)):
|
||||
value = list(value)
|
||||
kwarg_pairs.append('%s=%s' % (key, repr(value)))
|
||||
return '%(classname)s(%(kwargs)s)' % {
|
||||
'classname': self.__class__.__name__,
|
||||
'kwargs': ', '.join(kwarg_pairs),
|
||||
}
|
||||
|
||||
def __hash__(self):
|
||||
hash_sum = 0
|
||||
for key, value in self.__dict__.items():
|
||||
@ -65,6 +74,7 @@ class ImmutableObject(object):
|
||||
% key)
|
||||
return self.__class__(**data)
|
||||
|
||||
|
||||
class Artist(ImmutableObject):
|
||||
"""
|
||||
:param uri: artist URI
|
||||
@ -105,6 +115,9 @@ class Album(ImmutableObject):
|
||||
#: The album name. Read-only.
|
||||
name = None
|
||||
|
||||
#: A set of album artists. Read-only.
|
||||
artists = frozenset()
|
||||
|
||||
#: The number of tracks in the album. Read-only.
|
||||
num_tracks = 0
|
||||
|
||||
@ -112,14 +125,9 @@ class Album(ImmutableObject):
|
||||
musicbrainz_id = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._artists = frozenset(kwargs.pop('artists', []))
|
||||
self.__dict__['artists'] = frozenset(kwargs.pop('artists', []))
|
||||
super(Album, self).__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def artists(self):
|
||||
"""List of :class:`Artist` elements. Read-only."""
|
||||
return list(self._artists)
|
||||
|
||||
|
||||
class Track(ImmutableObject):
|
||||
"""
|
||||
@ -149,6 +157,9 @@ class Track(ImmutableObject):
|
||||
#: The track name. Read-only.
|
||||
name = None
|
||||
|
||||
#: A set of track artists. Read-only.
|
||||
artists = frozenset()
|
||||
|
||||
#: The track :class:`Album`. Read-only.
|
||||
album = None
|
||||
|
||||
@ -168,14 +179,9 @@ class Track(ImmutableObject):
|
||||
musicbrainz_id = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._artists = frozenset(kwargs.pop('artists', []))
|
||||
self.__dict__['artists'] = frozenset(kwargs.pop('artists', []))
|
||||
super(Track, self).__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def artists(self):
|
||||
"""List of :class:`Artist`. Read-only."""
|
||||
return list(self._artists)
|
||||
|
||||
def mpd_format(self, *args, **kwargs):
|
||||
return translator.track_to_mpd_format(self, *args, **kwargs)
|
||||
|
||||
@ -198,24 +204,22 @@ class Playlist(ImmutableObject):
|
||||
#: The playlist name. Read-only.
|
||||
name = None
|
||||
|
||||
#: The playlist's tracks. Read-only.
|
||||
tracks = tuple()
|
||||
|
||||
#: The playlist modification time. Read-only.
|
||||
#:
|
||||
#: :class:`datetime.datetime`, or :class:`None` if unknown.
|
||||
last_modified = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._tracks = kwargs.pop('tracks', [])
|
||||
self.__dict__['tracks'] = tuple(kwargs.pop('tracks', []))
|
||||
super(Playlist, self).__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def tracks(self):
|
||||
"""List of :class:`Track` elements. Read-only."""
|
||||
return copy(self._tracks)
|
||||
|
||||
@property
|
||||
def length(self):
|
||||
"""The number of tracks in the playlist. Read-only."""
|
||||
return len(self._tracks)
|
||||
return len(self.tracks)
|
||||
|
||||
def mpd_format(self, *args, **kwargs):
|
||||
return translator.playlist_to_mpd_format(self, *args, **kwargs)
|
||||
|
||||
@ -3,33 +3,6 @@ class BaseOutput(object):
|
||||
Base class for audio outputs.
|
||||
"""
|
||||
|
||||
def __init__(self, core_queue):
|
||||
self.core_queue = core_queue
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Start the output.
|
||||
|
||||
*MAY be implemented by subclasses.*
|
||||
"""
|
||||
pass
|
||||
|
||||
def destroy(self):
|
||||
"""
|
||||
Destroy the output.
|
||||
|
||||
*MAY be implemented by subclasses.*
|
||||
"""
|
||||
pass
|
||||
|
||||
def process_message(self, message):
|
||||
"""
|
||||
Process messages with the output as destination.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def play_uri(self, uri):
|
||||
"""
|
||||
Play URI.
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
from pykka.actor import ThreadingActor
|
||||
|
||||
from mopidy.outputs.base import BaseOutput
|
||||
|
||||
class DummyOutput(BaseOutput):
|
||||
class DummyOutput(ThreadingActor, BaseOutput):
|
||||
"""
|
||||
Audio output used for testing.
|
||||
"""
|
||||
@ -8,15 +10,6 @@ class DummyOutput(BaseOutput):
|
||||
# pylint: disable = R0902
|
||||
# Too many instance attributes (9/7)
|
||||
|
||||
#: 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
|
||||
|
||||
@ -40,15 +33,6 @@ class DummyOutput(BaseOutput):
|
||||
#: 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
|
||||
|
||||
@ -3,113 +3,39 @@ pygst.require('0.10')
|
||||
import gst
|
||||
|
||||
import logging
|
||||
import multiprocessing
|
||||
|
||||
from pykka.actor import ThreadingActor
|
||||
from pykka.registry import ActorRegistry
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.backends.base import Backend
|
||||
from mopidy.outputs.base import BaseOutput
|
||||
from mopidy.utils.process import (BaseThread, pickle_connection,
|
||||
unpickle_connection)
|
||||
|
||||
logger = logging.getLogger('mopidy.outputs.gstreamer')
|
||||
|
||||
class GStreamerOutput(BaseOutput):
|
||||
class GStreamerOutput(ThreadingActor, BaseOutput):
|
||||
"""
|
||||
Audio output through GStreamer.
|
||||
|
||||
Starts :class:`GStreamerMessagesThread` and :class:`GStreamerPlayerThread`.
|
||||
Audio output through `GStreamer <http://gstreamer.freedesktop.org/>`_.
|
||||
|
||||
**Settings:**
|
||||
|
||||
- :attr:`mopidy.settings.GSTREAMER_AUDIO_SINK`
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(GStreamerOutput, self).__init__(*args, **kwargs)
|
||||
self.output_queue = multiprocessing.Queue()
|
||||
self.player_thread = GStreamerPlayerThread(self.core_queue,
|
||||
self.output_queue)
|
||||
|
||||
def start(self):
|
||||
self.player_thread.start()
|
||||
|
||||
def destroy(self):
|
||||
self.player_thread.destroy()
|
||||
|
||||
def process_message(self, message):
|
||||
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 GStreamerPlayerThread(BaseThread):
|
||||
"""
|
||||
A process for all work related to GStreamer.
|
||||
|
||||
The main loop processes events from both Mopidy and GStreamer.
|
||||
|
||||
This thread requires :class:`mopidy.utils.process.GObjectEventThread` to be
|
||||
running too. This is not enforced in any way by the code.
|
||||
|
||||
Make sure this subprocess is started by the MainThread in the top-most
|
||||
parent process, and not some other thread. If not, we can get into the
|
||||
problems described at
|
||||
http://jameswestby.net/weblog/tech/14-caution-python-multiprocessing-and-glib-dont-mix.html.
|
||||
"""
|
||||
|
||||
def __init__(self, core_queue, output_queue):
|
||||
super(GStreamerPlayerThread, self).__init__(core_queue)
|
||||
self.name = u'GStreamerPlayerThread'
|
||||
self.output_queue = output_queue
|
||||
def __init__(self):
|
||||
self.gst_pipeline = None
|
||||
|
||||
def run_inside_try(self):
|
||||
self.setup()
|
||||
while True:
|
||||
message = self.output_queue.get()
|
||||
self.process_mopidy_message(message)
|
||||
def on_start(self):
|
||||
self._setup_gstreamer()
|
||||
|
||||
def _setup_gstreamer(self):
|
||||
"""
|
||||
**Warning:** :class:`GStreamerOutput` requires
|
||||
:class:`mopidy.utils.process.GObjectEventThread` to be running. This is
|
||||
not enforced by :class:`GStreamerOutput` itself.
|
||||
"""
|
||||
|
||||
def setup(self):
|
||||
logger.debug(u'Setting up GStreamer pipeline')
|
||||
|
||||
self.gst_pipeline = gst.parse_launch(' ! '.join([
|
||||
@ -122,7 +48,7 @@ class GStreamerPlayerThread(BaseThread):
|
||||
|
||||
if settings.BACKENDS[0] == 'mopidy.backends.local.LocalBackend':
|
||||
uri_bin = gst.element_factory_make('uridecodebin', 'uri')
|
||||
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)
|
||||
else:
|
||||
app_src = gst.element_factory_make('appsrc', 'appsrc')
|
||||
@ -141,57 +67,29 @@ class GStreamerPlayerThread(BaseThread):
|
||||
# Setup bus and message processor
|
||||
gst_bus = self.gst_pipeline.get_bus()
|
||||
gst_bus.add_signal_watch()
|
||||
gst_bus.connect('message', self.process_gst_message)
|
||||
gst_bus.connect('message', self._process_gstreamer_message)
|
||||
|
||||
def process_new_pad(self, source, pad, target_pad):
|
||||
def _process_new_pad(self, source, pad, target_pad):
|
||||
pad.link(target_pad)
|
||||
|
||||
def process_mopidy_message(self, message):
|
||||
"""Process messages from the rest of Mopidy."""
|
||||
if message['command'] == 'play_uri':
|
||||
response = self.play_uri(message['uri'])
|
||||
connection = unpickle_connection(message['reply_to'])
|
||||
connection.send(response)
|
||||
elif message['command'] == 'deliver_data':
|
||||
self.deliver_data(message['caps'], message['data'])
|
||||
elif message['command'] == 'end_of_data_stream':
|
||||
self.end_of_data_stream()
|
||||
elif message['command'] == 'set_state':
|
||||
response = self.set_state(message['state'])
|
||||
connection = unpickle_connection(message['reply_to'])
|
||||
connection.send(response)
|
||||
elif message['command'] == 'get_volume':
|
||||
volume = self.get_volume()
|
||||
connection = unpickle_connection(message['reply_to'])
|
||||
connection.send(volume)
|
||||
elif message['command'] == 'set_volume':
|
||||
response = self.set_volume(message['volume'])
|
||||
connection = unpickle_connection(message['reply_to'])
|
||||
connection.send(response)
|
||||
elif message['command'] == 'set_position':
|
||||
response = self.set_position(message['position'])
|
||||
connection = unpickle_connection(message['reply_to'])
|
||||
connection.send(response)
|
||||
elif message['command'] == 'get_position':
|
||||
response = self.get_position()
|
||||
connection = unpickle_connection(message['reply_to'])
|
||||
connection.send(response)
|
||||
else:
|
||||
logger.warning(u'Cannot handle message: %s', message)
|
||||
|
||||
def process_gst_message(self, bus, message):
|
||||
def _process_gstreamer_message(self, bus, message):
|
||||
"""Process messages from GStreamer."""
|
||||
if message.type == gst.MESSAGE_EOS:
|
||||
logger.debug(u'GStreamer signalled end-of-stream. '
|
||||
'Sending end_of_track to core_queue ...')
|
||||
self.core_queue.put({'command': 'end_of_track'})
|
||||
'Telling backend ...')
|
||||
self._get_backend().playback.on_end_of_track()
|
||||
elif message.type == gst.MESSAGE_ERROR:
|
||||
self.set_state('NULL')
|
||||
error, debug = message.parse_error()
|
||||
logger.error(u'%s %s', error, debug)
|
||||
# FIXME Should we send 'stop_playback' to core here? Can we
|
||||
# FIXME Should we send 'stop_playback' to the backend here? Can we
|
||||
# differentiate on how serious the error is?
|
||||
|
||||
def _get_backend(self):
|
||||
backend_refs = ActorRegistry.get_by_class(Backend)
|
||||
assert len(backend_refs) == 1, 'Expected exactly one running backend.'
|
||||
return backend_refs[0].proxy()
|
||||
|
||||
def play_uri(self, uri):
|
||||
"""Play audio at URI"""
|
||||
self.set_state('READY')
|
||||
@ -216,6 +114,21 @@ class GStreamerPlayerThread(BaseThread):
|
||||
"""
|
||||
self.gst_pipeline.get_by_name('appsrc').emit('end-of-stream')
|
||||
|
||||
def get_position(self):
|
||||
try:
|
||||
position = self.gst_pipeline.query_position(gst.FORMAT_TIME)[0]
|
||||
return position // gst.MSECOND
|
||||
except gst.QueryError, e:
|
||||
logger.error('time_position failed: %s', e)
|
||||
return 0
|
||||
|
||||
def set_position(self, position):
|
||||
self.gst_pipeline.get_state() # block until state changes are done
|
||||
handeled = self.gst_pipeline.seek_simple(gst.Format(gst.FORMAT_TIME),
|
||||
gst.SEEK_FLAG_FLUSH, position * gst.MSECOND)
|
||||
self.gst_pipeline.get_state() # block until seek is done
|
||||
return handeled
|
||||
|
||||
def set_state(self, state_name):
|
||||
"""
|
||||
Set the GStreamer state. Returns :class:`True` if successful.
|
||||
|
||||
@ -164,6 +164,11 @@ OUTPUT = u'mopidy.outputs.gstreamer.GStreamerOutput'
|
||||
#: Listens on all interfaces, both IPv4 and IPv6.
|
||||
MPD_SERVER_HOSTNAME = u'127.0.0.1'
|
||||
|
||||
#: The password required for connecting to the MPD server.
|
||||
#:
|
||||
#: Default: :class:`None`, which means no password required.
|
||||
MPD_SERVER_PASSWORD = None
|
||||
|
||||
#: Which TCP port Mopidy's MPD server should listen to.
|
||||
#:
|
||||
#: Default: 6600
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import logging
|
||||
import logging.handlers
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy import get_version, settings
|
||||
|
||||
def setup_logging(verbosity_level, save_debug_log):
|
||||
setup_root_logger()
|
||||
setup_console_logging(verbosity_level)
|
||||
if save_debug_log:
|
||||
setup_debug_logging_to_file()
|
||||
logger = logging.getLogger('mopidy.utils.log')
|
||||
logger.info(u'-- Starting Mopidy %s --', get_version())
|
||||
|
||||
def setup_root_logger():
|
||||
root = logging.getLogger('')
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
import logging
|
||||
import multiprocessing
|
||||
import multiprocessing.dummy
|
||||
from multiprocessing.reduction import reduce_connection
|
||||
import pickle
|
||||
import threading
|
||||
|
||||
import gobject
|
||||
gobject.threads_init()
|
||||
@ -11,52 +8,10 @@ from mopidy import SettingsError
|
||||
|
||||
logger = logging.getLogger('mopidy.utils.process')
|
||||
|
||||
def pickle_connection(connection):
|
||||
return pickle.dumps(reduce_connection(connection))
|
||||
|
||||
def unpickle_connection(pickled_connection):
|
||||
# From http://stackoverflow.com/questions/1446004
|
||||
(func, args) = pickle.loads(pickled_connection)
|
||||
return func(*args)
|
||||
|
||||
class BaseProcess(multiprocessing.Process):
|
||||
def __init__(self, core_queue):
|
||||
super(BaseProcess, self).__init__()
|
||||
self.core_queue = core_queue
|
||||
|
||||
def run(self):
|
||||
logger.debug(u'%s: Starting process', 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):
|
||||
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):
|
||||
class BaseThread(threading.Thread):
|
||||
def __init__(self):
|
||||
super(BaseThread, self).__init__()
|
||||
self.core_queue = core_queue
|
||||
# No thread should block process from exiting
|
||||
self.daemon = True
|
||||
|
||||
@ -84,8 +39,6 @@ class BaseThread(multiprocessing.dummy.Process):
|
||||
pass
|
||||
|
||||
def exit(self, status=0, reason=None):
|
||||
self.core_queue.put({'to': 'core', 'command': 'exit',
|
||||
'status': status, 'reason': reason})
|
||||
self.destroy()
|
||||
|
||||
|
||||
@ -98,8 +51,8 @@ class GObjectEventThread(BaseThread):
|
||||
:mod:`mopidy.output.gstreamer`, :mod:`mopidy.frontend.mpris`, etc.
|
||||
"""
|
||||
|
||||
def __init__(self, core_queue):
|
||||
super(GObjectEventThread, self).__init__(core_queue)
|
||||
def __init__(self):
|
||||
super(GObjectEventThread, self).__init__()
|
||||
self.name = u'GObjectEventThread'
|
||||
self.loop = None
|
||||
|
||||
|
||||
@ -49,7 +49,7 @@ class SettingsProxy(object):
|
||||
if attr not in self.current:
|
||||
raise SettingsError(u'Setting "%s" is not set.' % attr)
|
||||
value = self.current[attr]
|
||||
if type(value) != bool and not value:
|
||||
if isinstance(value, basestring) and len(value) == 0:
|
||||
raise SettingsError(u'Setting "%s" is empty.' % attr)
|
||||
if attr.endswith('_PATH') or attr.endswith('_FILE'):
|
||||
value = os.path.expanduser(value)
|
||||
@ -141,8 +141,7 @@ def list_settings_optparse_callback(*args):
|
||||
lines = []
|
||||
for (key, value) in sorted(settings.current.iteritems()):
|
||||
default_value = settings.default.get(key)
|
||||
if key.endswith('PASSWORD') and len(value):
|
||||
value = u'********'
|
||||
value = mask_value_if_secret(key, value)
|
||||
lines.append(u'%s:' % key)
|
||||
lines.append(u' Value: %s' % repr(value))
|
||||
if value != default_value and default_value is not None:
|
||||
@ -151,3 +150,9 @@ def list_settings_optparse_callback(*args):
|
||||
lines.append(u' Error: %s' % errors[key])
|
||||
print u'Settings: %s' % indent('\n'.join(lines), places=2)
|
||||
sys.exit(0)
|
||||
|
||||
def mask_value_if_secret(key, value):
|
||||
if key.endswith('PASSWORD') and value:
|
||||
return u'********'
|
||||
else:
|
||||
return value
|
||||
|
||||
1
requirements/core.txt
Normal file
1
requirements/core.txt
Normal file
@ -0,0 +1 @@
|
||||
Pykka >= 0.12
|
||||
@ -1 +1 @@
|
||||
pylast >= 0.5
|
||||
pylast >= 0.5.7
|
||||
|
||||
@ -1,2 +1,4 @@
|
||||
coverage
|
||||
mock
|
||||
nose
|
||||
tox
|
||||
|
||||
5
setup.py
5
setup.py
@ -69,7 +69,10 @@ for dirpath, dirnames, filenames in os.walk(project_dir):
|
||||
data_files.append([dirpath,
|
||||
[os.path.join(dirpath, f) for f in filenames]])
|
||||
|
||||
data_files.append(('/usr/local/share/applications', ['data/mopidy.desktop']))
|
||||
if os.geteuid() == 0:
|
||||
# Only try to install this file if we are root
|
||||
data_files.append(
|
||||
('/usr/local/share/applications', ['data/mopidy.desktop']))
|
||||
|
||||
setup(
|
||||
name='Mopidy',
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import os
|
||||
|
||||
try: # 2.7
|
||||
# pylint: disable = E0611,F0401
|
||||
from unittest.case import SkipTest
|
||||
# pylint: enable = E0611,F0401
|
||||
except ImportError:
|
||||
try: # Nose
|
||||
from nose.plugins.skip import SkipTest
|
||||
@ -14,9 +16,9 @@ from mopidy import settings
|
||||
# Nuke any local settings to ensure same test env all over
|
||||
settings.local.clear()
|
||||
|
||||
def data_folder(name):
|
||||
folder = os.path.dirname(__file__)
|
||||
folder = os.path.join(folder, 'data')
|
||||
folder = os.path.abspath(folder)
|
||||
return os.path.join(folder, name)
|
||||
def path_to_data_dir(name):
|
||||
path = os.path.dirname(__file__)
|
||||
path = os.path.join(path, 'data')
|
||||
path = os.path.abspath(path)
|
||||
return os.path.join(path, name)
|
||||
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
import mock
|
||||
import multiprocessing
|
||||
import random
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.mixers.dummy import DummyMixer
|
||||
from mopidy.models import Playlist, Track
|
||||
from mopidy.outputs.dummy import DummyOutput
|
||||
from mopidy.utils import get_class
|
||||
from mopidy.outputs.base import BaseOutput
|
||||
|
||||
from tests.backends.base import populate_playlist
|
||||
|
||||
@ -13,19 +11,13 @@ class CurrentPlaylistControllerTest(object):
|
||||
tracks = []
|
||||
|
||||
def setUp(self):
|
||||
self.core_queue = multiprocessing.Queue()
|
||||
self.output = DummyOutput(self.core_queue)
|
||||
self.backend = self.backend_class(
|
||||
self.core_queue, self.output, DummyMixer)
|
||||
self.backend = self.backend_class()
|
||||
self.backend.output = mock.Mock(spec=BaseOutput)
|
||||
self.controller = self.backend.current_playlist
|
||||
self.playback = self.backend.playback
|
||||
|
||||
assert len(self.tracks) == 3, 'Need three tracks to run tests.'
|
||||
|
||||
def tearDown(self):
|
||||
self.backend.destroy()
|
||||
self.output.destroy()
|
||||
|
||||
def test_add(self):
|
||||
for track in self.tracks:
|
||||
cp_track = self.controller.add(track)
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
from mopidy.mixers.dummy import DummyMixer
|
||||
from mopidy.models import Playlist, Track, Album, Artist
|
||||
|
||||
from tests import SkipTest, data_folder
|
||||
from tests import SkipTest, path_to_data_dir
|
||||
|
||||
class LibraryControllerTest(object):
|
||||
artists = [Artist(name='artist1'), Artist(name='artist2'), Artist()]
|
||||
@ -9,18 +8,15 @@ class LibraryControllerTest(object):
|
||||
Album(name='album2', artists=artists[1:2]),
|
||||
Album()]
|
||||
tracks = [Track(name='track1', length=4000, artists=artists[:1],
|
||||
album=albums[0], uri='file://' + data_folder('uri1')),
|
||||
album=albums[0], uri='file://' + path_to_data_dir('uri1')),
|
||||
Track(name='track2', length=4000, artists=artists[1:2],
|
||||
album=albums[1], uri='file://' + data_folder('uri2')),
|
||||
album=albums[1], uri='file://' + path_to_data_dir('uri2')),
|
||||
Track()]
|
||||
|
||||
def setUp(self):
|
||||
self.backend = self.backend_class(mixer_class=DummyMixer)
|
||||
self.backend = self.backend_class()
|
||||
self.library = self.backend.library
|
||||
|
||||
def tearDown(self):
|
||||
self.backend.destroy()
|
||||
|
||||
def test_refresh(self):
|
||||
self.library.refresh()
|
||||
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
import mock
|
||||
import multiprocessing
|
||||
import random
|
||||
import time
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.mixers.dummy import DummyMixer
|
||||
from mopidy.models import Track
|
||||
from mopidy.outputs.dummy import DummyOutput
|
||||
from mopidy.utils import get_class
|
||||
from mopidy.outputs.base import BaseOutput
|
||||
|
||||
from tests import SkipTest
|
||||
from tests.backends.base import populate_playlist
|
||||
@ -17,10 +15,8 @@ class PlaybackControllerTest(object):
|
||||
tracks = []
|
||||
|
||||
def setUp(self):
|
||||
self.core_queue = multiprocessing.Queue()
|
||||
self.output = DummyOutput(self.core_queue)
|
||||
self.backend = self.backend_class(
|
||||
self.core_queue, self.output, DummyMixer)
|
||||
self.backend = self.backend_class()
|
||||
self.backend.output = mock.Mock(spec=BaseOutput)
|
||||
self.playback = self.backend.playback
|
||||
self.current_playlist = self.backend.current_playlist
|
||||
|
||||
@ -29,10 +25,6 @@ class PlaybackControllerTest(object):
|
||||
assert self.tracks[0].length >= 2000, \
|
||||
'First song needs to be at least 2000 miliseconds'
|
||||
|
||||
def tearDown(self):
|
||||
self.backend.destroy()
|
||||
self.output.destroy()
|
||||
|
||||
def test_initial_state_is_stopped(self):
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
|
||||
@ -212,7 +204,7 @@ class PlaybackControllerTest(object):
|
||||
def test_next_until_end_of_playlist_and_play_from_start(self):
|
||||
self.playback.play()
|
||||
|
||||
for track in self.tracks:
|
||||
for _ in self.tracks:
|
||||
self.playback.next()
|
||||
|
||||
self.assertEqual(self.playback.current_track, None)
|
||||
@ -258,7 +250,7 @@ class PlaybackControllerTest(object):
|
||||
@populate_playlist
|
||||
def test_next_track_at_end_of_playlist(self):
|
||||
self.playback.play()
|
||||
for track in self.current_playlist.cp_tracks[1:]:
|
||||
for _ in self.current_playlist.cp_tracks[1:]:
|
||||
self.playback.next()
|
||||
self.assertEqual(self.playback.track_at_next, None)
|
||||
|
||||
@ -266,7 +258,7 @@ class PlaybackControllerTest(object):
|
||||
def test_next_track_at_end_of_playlist_with_repeat(self):
|
||||
self.playback.repeat = True
|
||||
self.playback.play()
|
||||
for track in self.tracks[1:]:
|
||||
for _ in self.tracks[1:]:
|
||||
self.playback.next()
|
||||
self.assertEqual(self.playback.track_at_next, self.tracks[0])
|
||||
|
||||
@ -348,7 +340,7 @@ class PlaybackControllerTest(object):
|
||||
def test_end_of_track_until_end_of_playlist_and_play_from_start(self):
|
||||
self.playback.play()
|
||||
|
||||
for track in self.tracks:
|
||||
for _ in self.tracks:
|
||||
self.playback.on_end_of_track()
|
||||
|
||||
self.assertEqual(self.playback.current_track, None)
|
||||
@ -394,7 +386,7 @@ class PlaybackControllerTest(object):
|
||||
@populate_playlist
|
||||
def test_end_of_track_track_at_end_of_playlist(self):
|
||||
self.playback.play()
|
||||
for track in self.current_playlist.cp_tracks[1:]:
|
||||
for _ in self.current_playlist.cp_tracks[1:]:
|
||||
self.playback.on_end_of_track()
|
||||
self.assertEqual(self.playback.track_at_next, None)
|
||||
|
||||
@ -402,7 +394,7 @@ class PlaybackControllerTest(object):
|
||||
def test_end_of_track_track_at_end_of_playlist_with_repeat(self):
|
||||
self.playback.repeat = True
|
||||
self.playback.play()
|
||||
for track in self.tracks[1:]:
|
||||
for _ in self.tracks[1:]:
|
||||
self.playback.on_end_of_track()
|
||||
self.assertEqual(self.playback.track_at_next, self.tracks[0])
|
||||
|
||||
@ -466,7 +458,7 @@ class PlaybackControllerTest(object):
|
||||
@populate_playlist
|
||||
def test_previous_track_with_consume(self):
|
||||
self.playback.consume = True
|
||||
for track in self.tracks:
|
||||
for _ in self.tracks:
|
||||
self.playback.next()
|
||||
self.assertEqual(self.playback.track_at_previous,
|
||||
self.playback.current_track)
|
||||
@ -474,7 +466,7 @@ class PlaybackControllerTest(object):
|
||||
@populate_playlist
|
||||
def test_previous_track_with_random(self):
|
||||
self.playback.random = True
|
||||
for track in self.tracks:
|
||||
for _ in self.tracks:
|
||||
self.playback.next()
|
||||
self.assertEqual(self.playback.track_at_previous,
|
||||
self.playback.current_track)
|
||||
@ -547,7 +539,6 @@ class PlaybackControllerTest(object):
|
||||
|
||||
@populate_playlist
|
||||
def test_on_current_playlist_change_when_stopped(self):
|
||||
current_track = self.playback.current_track
|
||||
self.backend.current_playlist.append([self.tracks[2]])
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.current_track, None)
|
||||
@ -677,9 +668,10 @@ class PlaybackControllerTest(object):
|
||||
self.playback.seek(0)
|
||||
self.assertEqual(self.playback.state, self.playback.PLAYING)
|
||||
|
||||
@SkipTest
|
||||
@populate_playlist
|
||||
def test_seek_beyond_end_of_song(self):
|
||||
raise SkipTest # FIXME need to decide return value
|
||||
# FIXME need to decide return value
|
||||
self.playback.play()
|
||||
result = self.playback.seek(self.tracks[0].length*100)
|
||||
self.assert_(not result, 'Seek return value was %s' % result)
|
||||
@ -696,9 +688,10 @@ class PlaybackControllerTest(object):
|
||||
self.playback.seek(self.current_playlist.tracks[-1].length * 100)
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
|
||||
@SkipTest
|
||||
@populate_playlist
|
||||
def test_seek_beyond_start_of_song(self):
|
||||
raise SkipTest # FIXME need to decide return value
|
||||
# FIXME need to decide return value
|
||||
self.playback.play()
|
||||
result = self.playback.seek(-1000)
|
||||
self.assert_(not result, 'Seek return value was %s' % result)
|
||||
@ -734,10 +727,18 @@ class PlaybackControllerTest(object):
|
||||
self.assertEqual(self.playback.stop(), None)
|
||||
|
||||
def test_time_position_when_stopped(self):
|
||||
future = mock.Mock()
|
||||
future.get = mock.Mock(return_value=0)
|
||||
self.backend.output.get_position = mock.Mock(return_value=future)
|
||||
|
||||
self.assertEqual(self.playback.time_position, 0)
|
||||
|
||||
@populate_playlist
|
||||
def test_time_position_when_stopped_with_playlist(self):
|
||||
future = mock.Mock()
|
||||
future.get = mock.Mock(return_value=0)
|
||||
self.backend.output.get_position = mock.Mock(return_value=future)
|
||||
|
||||
self.assertEqual(self.playback.time_position, 0)
|
||||
|
||||
@SkipTest # Uses sleep and does not work with LocalBackend+DummyOutput
|
||||
@ -770,7 +771,7 @@ class PlaybackControllerTest(object):
|
||||
def test_playlist_is_empty_after_all_tracks_are_played_with_consume(self):
|
||||
self.playback.consume = True
|
||||
self.playback.play()
|
||||
for i in range(len(self.backend.current_playlist.tracks)):
|
||||
for _ in range(len(self.backend.current_playlist.tracks)):
|
||||
self.playback.on_end_of_track()
|
||||
self.assertEqual(len(self.backend.current_playlist.tracks), 0)
|
||||
|
||||
@ -824,14 +825,14 @@ class PlaybackControllerTest(object):
|
||||
def test_random_until_end_of_playlist(self):
|
||||
self.playback.random = True
|
||||
self.playback.play()
|
||||
for track in self.tracks[1:]:
|
||||
for _ in self.tracks[1:]:
|
||||
self.playback.next()
|
||||
self.assertEqual(self.playback.track_at_next, None)
|
||||
|
||||
@populate_playlist
|
||||
def test_random_until_end_of_playlist_and_play_from_start(self):
|
||||
self.playback.repeat = True
|
||||
for track in self.tracks:
|
||||
for _ in self.tracks:
|
||||
self.playback.next()
|
||||
self.assertNotEqual(self.playback.track_at_next, None)
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
@ -843,7 +844,7 @@ class PlaybackControllerTest(object):
|
||||
self.playback.repeat = True
|
||||
self.playback.random = True
|
||||
self.playback.play()
|
||||
for track in self.tracks:
|
||||
for _ in self.tracks:
|
||||
self.playback.next()
|
||||
self.assertNotEqual(self.playback.track_at_next, None)
|
||||
|
||||
@ -852,7 +853,7 @@ class PlaybackControllerTest(object):
|
||||
self.playback.random = True
|
||||
self.playback.play()
|
||||
played = []
|
||||
for track in self.tracks:
|
||||
for _ in self.tracks:
|
||||
self.assert_(self.playback.current_track not in played)
|
||||
played.append(self.playback.current_track)
|
||||
self.playback.next()
|
||||
|
||||
@ -3,23 +3,20 @@ import shutil
|
||||
import tempfile
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.mixers.dummy import DummyMixer
|
||||
from mopidy.models import Playlist
|
||||
|
||||
from tests import SkipTest, data_folder
|
||||
from tests import SkipTest, path_to_data_dir
|
||||
|
||||
class StoredPlaylistsControllerTest(object):
|
||||
def setUp(self):
|
||||
settings.LOCAL_PLAYLIST_PATH = tempfile.mkdtemp()
|
||||
settings.LOCAL_TAG_CACHE_FILE = data_folder('library_tag_cache')
|
||||
settings.LOCAL_MUSIC_PATH = data_folder('')
|
||||
settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('library_tag_cache')
|
||||
settings.LOCAL_MUSIC_PATH = path_to_data_dir('')
|
||||
|
||||
self.backend = self.backend_class(mixer_class=DummyMixer)
|
||||
self.backend = self.backend_class()
|
||||
self.stored = self.backend.stored_playlists
|
||||
|
||||
def tearDown(self):
|
||||
self.backend.destroy()
|
||||
|
||||
if os.path.exists(settings.LOCAL_PLAYLIST_PATH):
|
||||
shutil.rmtree(settings.LOCAL_PLAYLIST_PATH)
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from mopidy.utils.path import path_to_uri
|
||||
|
||||
from tests import data_folder
|
||||
from tests import path_to_data_dir
|
||||
|
||||
song = data_folder('song%s.wav')
|
||||
song = path_to_data_dir('song%s.wav')
|
||||
generate_song = lambda i: path_to_uri(song % i)
|
||||
|
||||
@ -9,7 +9,7 @@ if sys.platform == 'win32':
|
||||
from mopidy import settings
|
||||
from mopidy.backends.local import LocalBackend
|
||||
|
||||
from tests import data_folder
|
||||
from tests import path_to_data_dir
|
||||
from tests.backends.base.library import LibraryControllerTest
|
||||
|
||||
class LocalLibraryControllerTest(LibraryControllerTest, unittest.TestCase):
|
||||
@ -17,8 +17,8 @@ class LocalLibraryControllerTest(LibraryControllerTest, unittest.TestCase):
|
||||
backend_class = LocalBackend
|
||||
|
||||
def setUp(self):
|
||||
settings.LOCAL_TAG_CACHE_FILE = data_folder('library_tag_cache')
|
||||
settings.LOCAL_MUSIC_PATH = data_folder('')
|
||||
settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('library_tag_cache')
|
||||
settings.LOCAL_MUSIC_PATH = path_to_data_dir('')
|
||||
|
||||
super(LocalLibraryControllerTest, self).setUp()
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ from mopidy.backends.local import LocalBackend
|
||||
from mopidy.models import Track
|
||||
from mopidy.utils.path import path_to_uri
|
||||
|
||||
from tests import data_folder
|
||||
from tests import path_to_data_dir
|
||||
from tests.backends.base.playback import PlaybackControllerTest
|
||||
from tests.backends.local import generate_song
|
||||
|
||||
@ -32,7 +32,7 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase):
|
||||
settings.runtime.clear()
|
||||
|
||||
def add_track(self, path):
|
||||
uri = path_to_uri(data_folder(path))
|
||||
uri = path_to_uri(path_to_data_dir(path))
|
||||
track = Track(uri=uri, length=4464)
|
||||
self.backend.current_playlist.add(track)
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ from mopidy.mixers.dummy import DummyMixer
|
||||
from mopidy.models import Playlist, Track
|
||||
from mopidy.utils.path import path_to_uri
|
||||
|
||||
from tests import data_folder
|
||||
from tests import path_to_data_dir
|
||||
from tests.backends.base.stored_playlists import \
|
||||
StoredPlaylistsControllerTest
|
||||
from tests.backends.local import generate_song
|
||||
@ -65,13 +65,12 @@ class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest,
|
||||
self.assertEqual(uri, contents.strip())
|
||||
|
||||
def test_playlists_are_loaded_at_startup(self):
|
||||
track = Track(uri=path_to_uri(data_folder('uri2')))
|
||||
track = Track(uri=path_to_uri(path_to_data_dir('uri2')))
|
||||
playlist = Playlist(tracks=[track], name='test')
|
||||
|
||||
self.stored.save(playlist)
|
||||
|
||||
self.backend.destroy()
|
||||
self.backend = self.backend_class(mixer_class=DummyMixer)
|
||||
self.backend = self.backend_class()
|
||||
self.stored = self.backend.stored_playlists
|
||||
|
||||
self.assert_(self.stored.playlists)
|
||||
|
||||
@ -8,26 +8,26 @@ from mopidy.utils.path import path_to_uri
|
||||
from mopidy.backends.local.translator import parse_m3u, parse_mpd_tag_cache
|
||||
from mopidy.models import Track, Artist, Album
|
||||
|
||||
from tests import SkipTest, data_folder
|
||||
from tests import SkipTest, path_to_data_dir
|
||||
|
||||
song1_path = data_folder('song1.mp3')
|
||||
song2_path = data_folder('song2.mp3')
|
||||
encoded_path = data_folder(u'æøå.mp3')
|
||||
song1_path = path_to_data_dir('song1.mp3')
|
||||
song2_path = path_to_data_dir('song2.mp3')
|
||||
encoded_path = path_to_data_dir(u'æøå.mp3')
|
||||
song1_uri = path_to_uri(song1_path)
|
||||
song2_uri = path_to_uri(song2_path)
|
||||
encoded_uri = path_to_uri(encoded_path)
|
||||
|
||||
class M3UToUriTest(unittest.TestCase):
|
||||
def test_empty_file(self):
|
||||
uris = parse_m3u(data_folder('empty.m3u'))
|
||||
uris = parse_m3u(path_to_data_dir('empty.m3u'))
|
||||
self.assertEqual([], uris)
|
||||
|
||||
def test_basic_file(self):
|
||||
uris = parse_m3u(data_folder('one.m3u'))
|
||||
uris = parse_m3u(path_to_data_dir('one.m3u'))
|
||||
self.assertEqual([song1_uri], uris)
|
||||
|
||||
def test_file_with_comment(self):
|
||||
uris = parse_m3u(data_folder('comment.m3u'))
|
||||
uris = parse_m3u(path_to_data_dir('comment.m3u'))
|
||||
self.assertEqual([song1_uri], uris)
|
||||
|
||||
def test_file_with_absolute_files(self):
|
||||
@ -64,11 +64,11 @@ class M3UToUriTest(unittest.TestCase):
|
||||
os.remove(tmp.name)
|
||||
|
||||
def test_encoding_is_latin1(self):
|
||||
uris = parse_m3u(data_folder('encoding.m3u'))
|
||||
uris = parse_m3u(path_to_data_dir('encoding.m3u'))
|
||||
self.assertEqual([encoded_uri], uris)
|
||||
|
||||
def test_open_missing_file(self):
|
||||
uris = parse_m3u(data_folder('non-existant.m3u'))
|
||||
uris = parse_m3u(path_to_data_dir('non-existant.m3u'))
|
||||
self.assertEqual([], uris)
|
||||
|
||||
|
||||
@ -81,7 +81,7 @@ expected_albums = [Album(name='albumname', artists=expected_artists,
|
||||
expected_tracks = []
|
||||
|
||||
def generate_track(path, ident):
|
||||
uri = path_to_uri(data_folder(path))
|
||||
uri = path_to_uri(path_to_data_dir(path))
|
||||
track = Track(name='trackname', artists=expected_artists, track_no=1,
|
||||
album=expected_albums[0], length=4000, uri=uri)
|
||||
expected_tracks.append(track)
|
||||
@ -98,28 +98,28 @@ generate_track('subdir1/subsubdir/song9.mp3', 1)
|
||||
|
||||
class MPDTagCacheToTracksTest(unittest.TestCase):
|
||||
def test_emtpy_cache(self):
|
||||
tracks = parse_mpd_tag_cache(data_folder('empty_tag_cache'),
|
||||
data_folder(''))
|
||||
tracks = parse_mpd_tag_cache(path_to_data_dir('empty_tag_cache'),
|
||||
path_to_data_dir(''))
|
||||
self.assertEqual(set(), tracks)
|
||||
|
||||
def test_simple_cache(self):
|
||||
tracks = parse_mpd_tag_cache(data_folder('simple_tag_cache'),
|
||||
data_folder(''))
|
||||
uri = path_to_uri(data_folder('song1.mp3'))
|
||||
tracks = parse_mpd_tag_cache(path_to_data_dir('simple_tag_cache'),
|
||||
path_to_data_dir(''))
|
||||
uri = path_to_uri(path_to_data_dir('song1.mp3'))
|
||||
track = Track(name='trackname', artists=expected_artists, track_no=1,
|
||||
album=expected_albums[0], length=4000, uri=uri)
|
||||
self.assertEqual(set([track]), tracks)
|
||||
|
||||
def test_advanced_cache(self):
|
||||
tracks = parse_mpd_tag_cache(data_folder('advanced_tag_cache'),
|
||||
data_folder(''))
|
||||
tracks = parse_mpd_tag_cache(path_to_data_dir('advanced_tag_cache'),
|
||||
path_to_data_dir(''))
|
||||
self.assertEqual(set(expected_tracks), tracks)
|
||||
|
||||
def test_unicode_cache(self):
|
||||
tracks = parse_mpd_tag_cache(data_folder('utf8_tag_cache'),
|
||||
data_folder(''))
|
||||
tracks = parse_mpd_tag_cache(path_to_data_dir('utf8_tag_cache'),
|
||||
path_to_data_dir(''))
|
||||
|
||||
uri = path_to_uri(data_folder('song1.mp3'))
|
||||
uri = path_to_uri(path_to_data_dir('song1.mp3'))
|
||||
artists = [Artist(name=u'æøå')]
|
||||
album = Album(name=u'æøå', artists=artists)
|
||||
track = Track(uri=uri, name=u'æøå', artists=artists,
|
||||
@ -132,14 +132,14 @@ class MPDTagCacheToTracksTest(unittest.TestCase):
|
||||
raise SkipTest
|
||||
|
||||
def test_cache_with_blank_track_info(self):
|
||||
tracks = parse_mpd_tag_cache(data_folder('blank_tag_cache'),
|
||||
data_folder(''))
|
||||
uri = path_to_uri(data_folder('song1.mp3'))
|
||||
tracks = parse_mpd_tag_cache(path_to_data_dir('blank_tag_cache'),
|
||||
path_to_data_dir(''))
|
||||
uri = path_to_uri(path_to_data_dir('song1.mp3'))
|
||||
self.assertEqual(set([Track(uri=uri, length=4000)]), tracks)
|
||||
|
||||
def test_musicbrainz_tagcache(self):
|
||||
tracks = parse_mpd_tag_cache(data_folder('musicbrainz_tag_cache'),
|
||||
data_folder(''))
|
||||
tracks = parse_mpd_tag_cache(path_to_data_dir('musicbrainz_tag_cache'),
|
||||
path_to_data_dir(''))
|
||||
artist = list(expected_tracks[0].artists)[0].copy(
|
||||
musicbrainz_id='7364dea6-ca9a-48e3-be01-b44ad0d19897')
|
||||
albumartist = list(expected_tracks[0].artists)[0].copy(
|
||||
@ -153,9 +153,9 @@ class MPDTagCacheToTracksTest(unittest.TestCase):
|
||||
self.assertEqual(track, list(tracks)[0])
|
||||
|
||||
def test_albumartist_tag_cache(self):
|
||||
tracks = parse_mpd_tag_cache(data_folder('albumartist_tag_cache'),
|
||||
data_folder(''))
|
||||
uri = path_to_uri(data_folder('song1.mp3'))
|
||||
tracks = parse_mpd_tag_cache(path_to_data_dir('albumartist_tag_cache'),
|
||||
path_to_data_dir(''))
|
||||
uri = path_to_uri(path_to_data_dir('song1.mp3'))
|
||||
artist = Artist(name='albumartistname')
|
||||
album = expected_albums[0].copy(artists=[artist])
|
||||
track = Track(name='trackname', artists=expected_artists, track_no=1,
|
||||
|
||||
@ -1 +0,0 @@
|
||||
../sample.mp3
|
||||
BIN
tests/data/scanner/advanced/song1.mp3
Normal file
BIN
tests/data/scanner/advanced/song1.mp3
Normal file
Binary file not shown.
@ -1 +0,0 @@
|
||||
../sample.mp3
|
||||
BIN
tests/data/scanner/advanced/song2.mp3
Normal file
BIN
tests/data/scanner/advanced/song2.mp3
Normal file
Binary file not shown.
@ -1 +0,0 @@
|
||||
../sample.mp3
|
||||
BIN
tests/data/scanner/advanced/song3.mp3
Normal file
BIN
tests/data/scanner/advanced/song3.mp3
Normal file
Binary file not shown.
@ -1 +0,0 @@
|
||||
../../sample.mp3
|
||||
BIN
tests/data/scanner/advanced/subdir1/song4.mp3
Normal file
BIN
tests/data/scanner/advanced/subdir1/song4.mp3
Normal file
Binary file not shown.
@ -1 +0,0 @@
|
||||
../../sample.mp3
|
||||
BIN
tests/data/scanner/advanced/subdir1/song5.mp3
Normal file
BIN
tests/data/scanner/advanced/subdir1/song5.mp3
Normal file
Binary file not shown.
@ -1 +0,0 @@
|
||||
../../../sample.mp3
|
||||
BIN
tests/data/scanner/advanced/subdir1/subsubdir/song8.mp3
Normal file
BIN
tests/data/scanner/advanced/subdir1/subsubdir/song8.mp3
Normal file
Binary file not shown.
@ -1 +0,0 @@
|
||||
../../../sample.mp3
|
||||
BIN
tests/data/scanner/advanced/subdir1/subsubdir/song9.mp3
Normal file
BIN
tests/data/scanner/advanced/subdir1/subsubdir/song9.mp3
Normal file
Binary file not shown.
@ -1 +0,0 @@
|
||||
../../sample.mp3
|
||||
BIN
tests/data/scanner/advanced/subdir2/song6.mp3
Normal file
BIN
tests/data/scanner/advanced/subdir2/song6.mp3
Normal file
Binary file not shown.
@ -1 +0,0 @@
|
||||
../../sample.mp3
|
||||
BIN
tests/data/scanner/advanced/subdir2/song7.mp3
Normal file
BIN
tests/data/scanner/advanced/subdir2/song7.mp3
Normal file
Binary file not shown.
@ -1 +0,0 @@
|
||||
../sample.mp3
|
||||
BIN
tests/data/scanner/simple/song1.mp3
Normal file
BIN
tests/data/scanner/simple/song1.mp3
Normal file
Binary file not shown.
@ -1 +0,0 @@
|
||||
blank.flac
|
||||
BIN
tests/data/song1.flac
Normal file
BIN
tests/data/song1.flac
Normal file
Binary file not shown.
@ -1 +0,0 @@
|
||||
blank.mp3
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user