Merge branch 'develop' into feature/dump-thread-tracebacks

Conflicts:
	mopidy/__main__.py
	mopidy/utils/process.py
This commit is contained in:
Thomas Adamcik 2012-11-08 22:46:41 +01:00
commit b37e6a9ded
166 changed files with 6327 additions and 4347 deletions

View File

@ -10,3 +10,12 @@ before_script:
- "rm $VIRTUAL_ENV/lib/python$TRAVIS_PYTHON_VERSION/no-global-site-packages.txt"
script: nosetests
notifications:
irc:
channels:
- "irc.freenode.org#mopidy"
on_success: change
on_failure: change
use_notice: true
skip_join: true

View File

@ -4,17 +4,22 @@ Mopidy
.. image:: https://secure.travis-ci.org/mopidy/mopidy.png?branch=develop
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
Mopidy is a music server which can play music both from your local hard drive
and from Spotify. Searches returns results from both your local hard drive and
from Spotify, and you can mix tracks from both sources in your play queue. Your
Spotify playlists are also available for use, though we don't support modifying
them yet.
To control your music server, you can use the Ubuntu Sound Menu on the machine
running Mopidy, any device on the same network which can control UPnP
MediaRenderers, or any MPD client. MPD clients are available for most
platforms, including Windows, Mac OS X, Linux, Android and iOS.
To install Mopidy, check out
`the installation docs <http://docs.mopidy.com/en/latest/installation/>`_.
To get started with Mopidy, check out `the docs <http://docs.mopidy.com/>`_.
- `Documentation <http://docs.mopidy.com/>`_
- `Source code <http://github.com/mopidy/mopidy>`_
- `Issue tracker <http://github.com/mopidy/mopidy/issues>`_
- `CI server <http://travis-ci.org/mopidy/mopidy>`_
- IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
- `Download development snapshot <http://github.com/mopidy/mopidy/tarball/develop#egg=mopidy-dev>`_

BIN
docs/_static/mpd-client-gmpc.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

BIN
docs/_static/mpd-client-mpad.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
docs/_static/mpd-client-mpdroid.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
docs/_static/mpd-client-mpod.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
docs/_static/mpd-client-ncmpcpp.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
docs/_static/mpd-client-sonata.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
docs/_static/ubuntu-sound-menu.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View File

@ -4,16 +4,27 @@
Audio API
*********
.. module:: mopidy.audio
:synopsis: Thin wrapper around the parts of GStreamer we use
The audio API is the interface we have built around GStreamer to support our
specific use cases. Most backends should be able to get by with simply setting
the URI of the resource they want to play, for these cases the default playback
provider should be used.
For more advanced cases such as when the raw audio data is delivered outside of
GStreamer or the backend needs to add metadata to the currently playing resource,
developers should sub-class the base playback provider and implement the extra
behaviour that is needed through the following API:
GStreamer or the backend needs to add metadata to the currently playing
resource, developers should sub-class the base playback provider and implement
the extra behaviour that is needed through the following API:
.. autoclass:: mopidy.audio.Audio
:members:
Audio listener
==============
.. autoclass:: mopidy.audio.AudioListener
:members:

View File

@ -4,6 +4,9 @@
Backend API
***********
.. module:: mopidy.backends.base
:synopsis: The API implemented by backends
The backend API is the interface that must be implemented when you create a
backend. If you are working on a frontend and need to access the backend, see
the :ref:`core-api`.
@ -30,6 +33,8 @@ Library provider
:members:
.. _backend-implementations:
Backend implementations
=======================

View File

@ -1,29 +1,99 @@
.. _concepts:
**********************************************
The backend, controller, and provider concepts
**********************************************
*************************
Architecture and concepts
*************************
Backend:
The backend is mostly for convenience. It is a container that holds
references to all the controllers.
Controllers:
Each controller has responsibility for a given part of the backend
functionality. Most, but not all, controllers delegates some work to one or
more providers. The controllers are responsible for choosing the right
provider for any given task based upon i.e. the track's URI. See
:ref:`core-api` for more details.
Providers:
Anything specific to i.e. Spotify integration or local storage is contained
in the providers. To integrate with new music sources, you just add new
providers. See :ref:`backend-api` for more details.
The overall architecture of Mopidy is organized around multiple frontends and
backends. The frontends use the core API. The core actor makes multiple backends
work as one. The backends connect to various music sources. Both the core actor
and the backends use the audio actor to play audio and control audio volume.
.. digraph:: backend_relations
.. digraph:: overall_architecture
Backend -> "Current\nplaylist\ncontroller"
Backend -> "Library\ncontroller"
"Library\ncontroller" -> "Library\nproviders"
Backend -> "Playback\ncontroller"
"Playback\ncontroller" -> "Playback\nproviders"
Backend -> "Stored\nplaylists\ncontroller"
"Stored\nplaylists\ncontroller" -> "Stored\nplaylist\nproviders"
"Multiple frontends" -> Core
Core -> "Multiple backends"
Core -> Audio
"Multiple backends" -> Audio
Frontends
=========
Frontends expose Mopidy to the external world. They can implement servers for
protocols like MPD and MPRIS, and they can be used to update other services
when something happens in Mopidy, like the Last.fm scrobbler frontend does. See
:ref:`frontend-api` for more details.
.. digraph:: frontend_architecture
"MPD\nfrontend" -> Core
"MPRIS\nfrontend" -> Core
"Last.fm\nfrontend" -> Core
Core
====
The core is organized as a set of controllers with responsiblity for separate
sets of functionality.
The core is the single actor that the frontends send their requests to. For
every request from a frontend it calls out to one or more backends which does
the real work, and when the backends respond, the core actor is responsible for
combining the responses into a single response to the requesting frontend.
The core actor also keeps track of the current playlist, since it doesn't
belong to a specific backend.
See :ref:`core-api` for more details.
.. digraph:: core_architecture
Core -> "Current\nplaylist\ncontroller"
Core -> "Library\ncontroller"
Core -> "Playback\ncontroller"
Core -> "Stored\nplaylists\ncontroller"
"Library\ncontroller" -> "Local backend"
"Library\ncontroller" -> "Spotify backend"
"Playback\ncontroller" -> "Local backend"
"Playback\ncontroller" -> "Spotify backend"
"Playback\ncontroller" -> Audio
"Stored\nplaylists\ncontroller" -> "Local backend"
"Stored\nplaylists\ncontroller" -> "Spotify backend"
Backends
========
The backends are organized as a set of providers with responsiblity for
separate sets of functionality, similar to the core actor.
Anything specific to i.e. Spotify integration or local storage is contained in
the backends. To integrate with new music sources, you just add a new backend.
See :ref:`backend-api` for more details.
.. digraph:: backend_architecture
"Local backend" -> "Local\nlibrary\nprovider" -> "Local disk"
"Local backend" -> "Local\nplayback\nprovider" -> "Local disk"
"Local backend" -> "Local\nstored\nplaylists\nprovider" -> "Local disk"
"Local\nplayback\nprovider" -> Audio
"Spotify backend" -> "Spotify\nlibrary\nprovider" -> "Spotify service"
"Spotify backend" -> "Spotify\nplayback\nprovider" -> "Spotify service"
"Spotify backend" -> "Spotify\nstored\nplaylists\nprovider" -> "Spotify service"
"Spotify\nplayback\nprovider" -> Audio
Audio
=====
The audio actor is a thin wrapper around the parts of the GStreamer library we
use. In addition to playback, it's responsible for volume control through both
GStreamer's own volume mixers, and mixers we've created ourselves. If you
implement an advanced backend, you may need to implement your own playback
provider using the :ref:`audio-api`.

View File

@ -4,6 +4,9 @@
Core API
********
.. module:: mopidy.core
:synopsis: Core API for use by frontends
The core API is the interface that is used by frontends like
:mod:`mopidy.frontends.mpd`. The core layer is inbetween the frontends and the
@ -48,3 +51,10 @@ Manages the music library, e.g. searching for tracks to be added to a playlist.
.. autoclass:: mopidy.core.LibraryController
:members:
Core listener
=============
.. autoclass:: mopidy.core.CoreListener
:members:

View File

@ -1,3 +1,5 @@
.. _frontend-api:
************
Frontend API
************
@ -6,22 +8,38 @@ The following requirements applies to any frontend implementation:
- A frontend MAY do mostly whatever it wants to, including creating threads,
opening TCP ports and exposing Mopidy for a group of clients.
- A frontend MUST implement at least one `Pykka
<http://pykka.readthedocs.org/>`_ actor, called the "main actor" from here
on.
- The main actor MUST accept a constructor argument ``core``, which will be an
:class:`ActorProxy <pykka.proxy.ActorProxy>` for the core actor. This object
gives access to the full :ref:`core-api`.
- It MAY use additional actors to implement whatever it does, and using actors
in frontend implementations is encouraged.
- The frontend is activated by including its main actor in the
:attr:`mopidy.settings.FRONTENDS` setting.
- The main actor MUST be able to start and stop the frontend when the main
actor is started and stopped.
- The frontend MAY require additional settings to be set for it to
work.
- Such settings MUST be documented.
- The main actor MUST stop itself if the defined settings are not adequate for
the frontend to work properly.
- Any actor which is part of the frontend MAY implement any listener interface
from :mod:`mopidy.listeners` to receive notification of the specified events.
- Any actor which is part of the frontend MAY implement the
:class:`mopidy.core.CoreListener` interface to receive notification of the
specified events.
.. _frontend-implementations:
Frontend implementations
========================

View File

@ -11,4 +11,3 @@ API reference
core
audio
frontends
listeners

View File

@ -1,7 +0,0 @@
************
Listener API
************
.. automodule:: mopidy.listeners
:synopsis: Listener API
:members:

View File

@ -4,8 +4,127 @@ Changes
This change log is used to track all major changes to Mopidy.
v0.8 (in development)
=====================
v0.9.0 (in development)
=======================
**Multiple backends support**
Support for using the local and Spotify backends simultaneously have for a very
long time been our most requested feature. Finally, it's here!
- Both the local backend and the Spotify backend are now turned on by default.
The local backend is listed first in the :attr:`mopidy.settings.BACKENDS`
setting, and are thus given the highest priority in e.g. search results,
meaning that we're listing search hits from the local backend first. If you
want to prioritize the backends in another way, simply set ``BACKENDS`` in
your own settings file and reorder the backends.
There are no other setting changes related to the local and Spotify backends.
As always, see :mod:`mopidy.settings` for the full list of available
settings.
Internally, Mopidy have seen a lot of changes to pave the way for multiple
backends:
- A new layer and actor, "core", has been added to our stack, inbetween the
frontends and the backends. The responsibility of the core layer and actor is
to take requests from the frontends, pass them on to one or more backends,
and combining the response from the backends into a single response to the
requesting frontend.
Frontends no longer know anything about the backends. They just use the
:ref:`core-api`.
- The base playback provider has been updated with sane default behavior
instead of empty functions. By default, the playback provider now lets
GStreamer keep track of the current track's time position. The local backend
simply uses the base playback provider without any changes. The same applies
to any future backend that just needs GStreamer to play an URI for it.
- The dependency graph between the core controllers and the backend providers
have been straightened out, so that we don't have any circular dependencies.
The frontend, core, backend, and audio layers are now strictly separate. The
frontend layer calls on the core layer, and the core layer calls on the
backend layer. Both the core layer and the backends are allowed to call on
the audio layer. Any data flow in the opposite direction is done by
broadcasting of events to listeners, through e.g.
:class:`mopidy.core.CoreListener` and :class:`mopidy.audio.AudioListener`.
- All dependencies are now explicitly passed to the constructors of the
frontends, core, and the backends. This makes testing each layer with
dummy/mocked lower layers easier than with the old variant, where
dependencies where looked up in Pykka's actor registry.
- The stored playlists part of the core API has been revised to be more focused
around the playlist URI, and some redundant functionality has been removed:
- :attr:`mopidy.core.StoredPlaylistsController.playlists` no longer supports
assignment to it. The `playlists` property on the backend layer still does,
and all functionality is maintained by assigning to the playlists
collections at the backend level.
- :meth:`mopidy.core.StoredPlaylistsController.delete` now accepts an URI,
and not a playlist object.
- :meth:`mopidy.core.StoredPlaylistsController.save` now returns the saved
playlist. The returned playlist may differ from the saved playlist, and
should thus be used instead of the playlist passed to ``save()``.
- :meth:`mopidy.core.StoredPlaylistsController.rename` has been removed,
since renaming can be done with ``save()``.
**Changes**
- Made the :mod:`NAD mixer <mopidy.audio.mixers.nad>` responsive to interrupts
during amplifier calibration. It will now quit immediately, while previously
it completed the calibration first, and then quit, which could take more than
15 seconds.
- Added :attr:`mopidy.models.Album.date` attribute. It has the same format as
the existing :attr:`mopidy.models.Track.date`.
- The Spotify backend now includes release year and artist on albums.
- Added support for search by filename to local backend.
**Bug fixes**
- :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now
accepts unquotes playlist names if they don't contain spaces.
- The MPD command ``plchanges`` always returned the entire playlist. It now
returns an empty response when the client has seen the latest version.
v0.8.1 (2012-10-30)
===================
A small maintenance release to fix a bug introduced in 0.8.0 and update Mopidy
to work with Pykka 1.0.
**Dependencies**
- Pykka >= 1.0 is now required.
**Bug fixes**
- :issue:`213`: Fix "streaming task paused, reason not-negotiated" errors
observed by some users on some Spotify tracks due to a change introduced in
0.8.0. See the issue for a patch that applies to 0.8.0.
- :issue:`216`: Volume returned by the MPD command `status` contained a
floating point ``.0`` suffix. This bug was introduced with the large audio
output and mixer changes in v0.8.0 and broke the MPDroid Android client. It
now returns an integer again.
v0.8.0 (2012-09-20)
===================
This release does not include any major new features. We've done a major
cleanup of how audio outputs and audio mixers work, and on the way we've
resolved a bunch of related issues.
**Audio output and mixer changes**
@ -23,7 +142,7 @@ v0.8 (in development)
mixer that will work on your system. If this picks the wrong mixer you can of
course override it. Setting the mixer to :class:`None` is also supported. MPD
protocol support for volume has also been updated to return -1 when we have
no mixer set.
no mixer set. ``software`` can be used to force software mixing.
- Removed the Denon hardware mixer, as it is not maintained.
@ -63,23 +182,44 @@ v0.8 (in development)
- Support tracks with only release year, and not a full release date, like e.g.
Spotify tracks.
- Default value of ``LOCAL_MUSIC_PATH`` has been updated to be
``$XDG_MUSIC_DIR``, which on most systems this is set to ``$HOME``. Users of
local backend that relied on the old default ``~/music`` need to update their
settings. Note that the code responsible for finding this music now also
ignores UNIX hidden files and folders.
- File and path settings now support ``$XDG_CACHE_DIR``, ``$XDG_DATA_DIR`` and
``$XDG_MUSIC_DIR`` substitution. Defaults for such settings have been updated
to use this instead of hidden away defaults.
- Playback is now done using ``playbin2`` from GStreamer instead of rolling our
own. This is the first step towards resolving :issue:`171`.
**Bug fixes**
- :issue:`72`: Created a Spotify track proxy that will switch to using loaded
data as soon as it becomes available.
- :issue:`162`: Fixed bug when the MPD command ``playlistinfo`` is used with a
track position. Track position and CPID was intermixed, so it would cause a
crash if a CPID matching the track position didn't exist.
- :issue:`150`: Fix bug which caused some clients to block Mopidy completely.
The bug was caused by some clients sending ``close`` and then shutting down
the connection right away. This trigged a situation in which the connection
cleanup code would wait for an response that would never come inside the
event loop, blocking everything else.
- :issue:`162`: Fixed bug when the MPD command ``playlistinfo`` is used with a
track position. Track position and CPID was intermixed, so it would cause a
crash if a CPID matching the track position didn't exist.
- Fixed crash on lookup of unknown path when using local backend.
- :issue:`189`: ``LOCAL_MUSIC_PATH`` and path handling in rest of settings has
been updated so all of the code now uses the correct value.
- Fixed incorrect track URIs generated by M3U playlist parsing code. Generated
tracks are now relative to ``LOCAL_MUSIC_PATH``.
- :issue:`203`: Re-add support for software mixing.
v0.7.3 (2012-08-11)
===================
@ -266,7 +406,7 @@ Please note that 0.5.0 requires some updated dependencies, as listed under
- If you use the Spotify backend, you *must* upgrade to libspotify 0.0.8 and
pyspotify 1.3. 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/`.
instructions at :ref:`installation`.
- If you have explicitly set the :attr:`mopidy.settings.SPOTIFY_HIGH_BITRATE`
setting, you must update your settings file. The new setting is named
@ -407,8 +547,7 @@ loading from Mopidy 0.3.0 is still present.
- 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/`.
installing from APT, follow the instructions at :ref:`installation`.
**Changes**
@ -520,7 +659,7 @@ to this problem.
- 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/`.
:ref:`installation`.
- 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.
@ -547,7 +686,7 @@ to this problem.
- Local backend:
- Add :command:`mopidy-scan` command to generate ``tag_cache`` files without
any help from the original MPD server. See :ref:`generating_a_tag_cache`
any help from the original MPD server. See :ref:`generating-a-tag-cache`
for instructions on how to use it.
- Fix support for UTF-8 encoding in tag caches.
@ -556,7 +695,7 @@ to this problem.
- 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:
: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
@ -675,10 +814,10 @@ We've worked a bit on OS X support, but not all issues are completely solved
yet. :issue:`25` is the one that is currently blocking OS X support. Any help
solving it will be greatly appreciated!
Finally, please :ref:`update your pyspotify installation
<pyspotify_installation>` when upgrading to Mopidy 0.2.0. The latest pyspotify
got a fix for the segmentation fault that occurred when playing music and
searching at the same time, thanks to Valentin David.
Finally, please :ref:`update your pyspotify installation <installation>` when
upgrading to Mopidy 0.2.0. The latest pyspotify got a fix for the segmentation
fault that occurred when playing music and searching at the same time, thanks
to Valentin David.
**Important changes**
@ -743,12 +882,11 @@ fixing the OS X issues for a future release. You can track the progress at
**Important changes**
- License changed from GPLv2 to Apache License, version 2.0.
- GStreamer is now a required dependency. See our :doc:`GStreamer installation
docs <installation/gstreamer>`.
- GStreamer is now a required dependency. See our :ref:`GStreamer installation
docs <installation>`.
- :mod:`mopidy.backends.libspotify` is now the default backend.
:mod:`mopidy.backends.despotify` is no longer available. This means that you
need to install the :doc:`dependencies for libspotify
<installation/libspotify>`.
need to install the :ref:`dependencies for libspotify <installation>`.
- If you used :mod:`mopidy.backends.libspotify` previously, pyspotify must be
updated when updating to this release, to get working seek functionality.
- :attr:`mopidy.settings.SERVER_HOSTNAME` and
@ -1003,7 +1141,7 @@ Mopidy is working and usable. 0.1.0a0 is an alpha release, which basicly means
we will still change APIs, add features, etc. before the final 0.1.0 release.
But the software is usable as is, so we release it. Please give it a try and
give us feedback, either at our IRC channel or through the `issue tracker
<http://github.com/mopidy/mopidy/issues>`_. Thanks!
<https://github.com/mopidy/mopidy/issues>`_. Thanks!
**Changes**

View File

@ -1,92 +1,25 @@
************************
MPD client compatability
************************
.. _mpd-clients:
***********
MPD clients
***********
This is a list of MPD clients we either know works well with Mopidy, or that we
know won't work well. For a more exhaustive list of MPD clients, see
http://mpd.wikia.com/wiki/Clients.
Console clients
===============
mpc
---
A command line client. Version 0.14 had some issues with Mopidy (see
:issue:`5`), but 0.16 seems to work nicely.
.. contents:: Contents
:local:
ncmpc
-----
Test procedure
==============
A console client. Works with Mopidy 0.6 and upwards. Uses the ``idle`` MPD
command, but in a resource inefficient way.
ncmpcpp
-------
A console client that generally works well with Mopidy, and is regularly used
by Mopidy developers.
Search only works in two of the three search modes:
- "Match if tag contains search phrase (regexes supported)" -- Does not work.
The client tries to fetch all known metadata and do the search client side.
- "Match if tag contains searched phrase (no regexes)" -- Works.
- "Match only if both values are the same" -- Works.
Graphical clients
=================
GMPC
----
`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
------
`Sonata <http://sonata.berlios.de/>`_ is a graphical MPD client (GTK+).
It generally works well with Mopidy, except for search.
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 four MPD clients we could find for Android with Mopidy 0.7.3 on
a Samsung Galaxy Nexus with Android 4.1.1, using the following test procedure:
In some cases, we've used the following test procedure to compare the feature
completeness of clients:
#. Connect to Mopidy
#. Search for ``foo``, with search type "any" if it can be selected
#. 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
@ -107,38 +40,138 @@ a Samsung Galaxy Nexus with Android 4.1.1, using the following test procedure:
#. Check if the app got support for single mode and consume mode
#. Kill Mopidy and confirm that the app handles it without crashing
We found that all four apps crashed on Android 4.1.1.
Combining what we managed to find before the apps crashed with our experience
from an older version of this review, using Android 2.1, we can say that:
- PMix can be ignored, because it is unmaintained and its fork MPDroid is
better on all fronts.
Console clients
===============
- Droid MPD Client was to buggy to get an impression from. Unclear if the bugs
are due to the app or that it hasn't been updated for Android 4.x.
ncmpcpp
-------
- BitMPC is in our experience feature complete, but ugly.
A console client that works well with Mopidy, and is regularly used by Mopidy
developers.
- MPDroid, now that search is in place, is probably feature complete as well,
and looks nicer than BitMPC.
.. image:: /_static/mpd-client-ncmpcpp.png
:width: 575
:height: 426
In conclusion: MPD clients on Android 4.x is a sad affair. If you want to try
anyway, try BitMPC and MPDroid.
Search does not work in the "Match if tag contains search phrase (regexes
supported)" mode because the client tries to fetch all known metadata and do
the search on the client side. The two other search modes works nicely, so this
is not a problem.
ncmpc
-----
A console client. Works with Mopidy 0.6 and upwards. Uses the ``idle`` MPD
command, but in a resource inefficient way.
mpc
---
A command line client. Version 0.16 and upwards seems to work nicely with
Mopidy.
Graphical clients
=================
GMPC
----
`GMPC <http://gmpc.wikia.com>`_ is a graphical MPD client (GTK+) which works
well with Mopidy.
.. image:: /_static/mpd-client-gmpc.png
:width: 1000
:height: 565
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
------
`Sonata <http://sonata.berlios.de/>`_ is a graphical MPD client (GTK+).
It generally works well with Mopidy, except for search.
.. image:: /_static/mpd-client-sonata.png
:width: 475
:height: 424
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 closed `Sonata bug`_
for details.
.. _Sonata bug: http://developer.berlios.de/feature/?func=detailfeature&feature_id=5038&group_id=7323
Theremin
--------
`Theremin <https://github.com/pweiskircher/Theremin>`_ is a graphical MPD
client for OS X. It is unmaintained, but generally works well with Mopidy.
.. _android_mpd_clients:
Android clients
===============
We've tested all five MPD clients we could find for Android with Mopidy 0.8.1
on a Samsung Galaxy Nexus with Android 4.1.2, using our standard test
procedure.
MPDroid
-------
Test date:
2012-11-06
Tested version:
1.03.1 (released 2012-10-16)
.. image:: /_static/mpd-client-mpdroid.jpg
:width: 288
:height: 512
You can get `MPDroid from Google Play
<https://play.google.com/store/apps/details?id=com.namelessdev.mpdroid>`_.
- MPDroid started out as a fork of PMix, and is now much better.
- MPDroid's user interface looks nice.
- Everything in the test procedure works.
- In contrast to all other Android clients, MPDroid does support single mode or
consume mode.
- When Mopidy is killed, MPDroid handles it gracefully and asks if you want to
try to reconnect.
MPDroid is a good MPD client, and really the only one we can recommend.
BitMPC
------
Test date:
2012-09-12
2012-11-06
Tested version:
1.0.0 (released 2010-04-12)
Downloads:
5,000+
Rating:
3.7 stars from about 100 ratings
You can get `BitMPC from Google Play
<https://play.google.com/store/apps/details?id=bitendian.bitmpc>`_.
- The user interface lacks some finishing touches. E.g. you can't enter a
hostname for the server. Only IPv4 addresses are allowed.
@ -152,8 +185,8 @@ Rating:
- BitMPC crashed if Mopidy was killed or crashed.
- When we tried to test using Android 4.1.1, BitMPC started and connected to
Mopidy without problems, but the app crashed as soon as fire off our search,
and continued to crash on startup after that.
Mopidy without problems, but the app crashed as soon as we fired off our
search, and continued to crash on startup after that.
In conclusion, BitMPC is usable if you got an older Android phone and don't
care about looks. For newer Android versions, BitMPC will probably not work as
@ -164,13 +197,12 @@ Droid MPD Client
----------------
Test date:
2012-09-12
2012-11-06
Tested version:
1.4.0 (released 2011-12-20)
Downloads:
10,000+
Rating:
4.2 stars from 400+ ratings
You can get `Droid MPD Client from Google Play
<https://play.google.com/store/apps/details?id=com.soreha.droidmpdclient>`_.
- No intutive way to ask the app to connect to the server after adding the
server hostname to the settings.
@ -187,11 +219,6 @@ Rating:
- Searching for "foo" did nothing. No request was sent to the server.
- Once, I managed to get a list of stored playlists in the "Search" tab, but I
never managed to reproduce this. Opening the stored playlists doesn't work,
because Mopidy haven't implemented ``lsinfo "Playlist name"`` (see
:issue:`193`).
- Droid MPD client does not support single mode or consume mode.
- Not able to complete the test procedure, due to the above problems.
@ -199,71 +226,34 @@ Rating:
In conclusion, not a client we can recommend.
MPDroid
-------
Test date:
2012-09-12
Tested version:
0.7 (released 2011-06-19)
Downloads:
10,000+
Rating:
4.5 stars from ~500 ratings
- MPDroid started out as a fork of PMix.
- First of all, MPDroid's user interface looks nice.
- Last time we tested MPDroid (v0.6.9), we couldn't find any search
functionality. Now we found it, and it worked.
- Last time we tested MPDroid (v0.6.9) 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.
- When using Android 4.1.1, MPDroid crashes here and there, e.g. when having an
empty current playlist and pressing play.
Disregarding Android 4.x problems, MPDroid is a good MPD client.
PMix
----
Test date:
2012-09-12
2012-11-06
Tested version:
0.4.0 (released 2010-03-06)
Downloads:
10,000+
Rating:
3.8 stars from >200 ratings
- Using Android 4.1.1, PMix, which haven't been updated for 2.5 years, crashes
as soon as it connects to Mopidy.
You can get `PMix from Google Play
<https://play.google.com/store/apps/details?id=org.pmix.ui>`_.
- Last time we tested the same version of PMix using Android 2.1, we found
that:
PMix haven't been updated for 2.5 years, and has less working features than
it's fork MPDroid. Ignore PMix and use MPDroid instead.
- PMix does not support search.
- I could not find stored playlists.
MPD Remote
----------
- Other than that, I was able to complete the test procedure.
Test date:
2012-11-06
Tested version:
1.0 (released 2012-05-01)
- PMix crashed once during testing.
You can get `MPD Remote from Google Play
<https://play.google.com/store/apps/details?id=fr.mildlyusefulsoftware.mpdremote>`_.
- PMix 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.
This app looks terrible in the screen shots, got just 100+ downloads, and got a
terrible rating. I honestly didn't take the time to test it.
.. _ios_mpd_clients:
@ -271,63 +261,60 @@ All in all, PMix works but can do less than MPDroid. Use MPDroid instead.
iOS clients
===========
MPod
MPoD
----
Test date:
2011-01-19
2012-11-06
Tested version:
1.5.1
1.7.1
.. image:: /_static/mpd-client-mpod.jpg
:width: 320
:height: 480
The `MPoD <http://www.katoemba.net/makesnosenseatall/mpod/>`_ iPhone/iPod Touch
app can be installed from the `iTunes Store
<http://itunes.apple.com/us/app/mpod/id285063020>`_.
app can be installed from `MPoD at iTunes Store
<https://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:
- The user interface looks nice.
- **Works:** Playback control generally works, including stop, play, pause,
previous, next, repeat, random, seek, and volume control.
- All features exercised in the test procedure worked with MPaD, except seek,
which I didn't figure out to do.
- **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.
- Search only works in the "Browse" tab, and not under in the "Artist",
"Album", or "Song" tabs. For the tabs where search doesn't work, no queries
are sent to Mopidy when searching.
- **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`.
- Single mode and consume mode is supported.
MPaD
----
The `MPaD <http://www.katoemba.net/makesnosenseatall/mpad/>`_ iPad app works
with Mopidy. A complete review may appear here in the future.
Test date:
2012-11-06
Tested version:
1.7.1
.. image:: /_static/mpd-client-mpad.jpg
:width: 480
:height: 360
The `MPaD <http://www.katoemba.net/makesnosenseatall/mpad/>`_ iPad app can be
purchased from `MPaD at iTunes Store
<https://itunes.apple.com/us/app/mpad/id423097706>`_
- The user interface looks nice, though I would like to be able to view the
current playlist in the large part of the split view.
- All features exercised in the test procedure worked with MPaD.
- Search only works in the "Browse" tab, and not under in the "Artist",
"Album", or "Song" tabs. For the tabs where search doesn't work, no queries
are sent to Mopidy when searching.
- Single mode and consume mode is supported.
- The server menu can be very slow top open, and there is no visible feedback
when waiting for the connection to a server to succeed.

66
docs/clients/mpris.rst Normal file
View File

@ -0,0 +1,66 @@
.. _mpris-clients:
*************
MPRIS clients
*************
`MPRIS <http://www.mpris.org/>`_ is short for Media Player Remote Interfacing
Specification. It's a spec that describes a standard D-Bus interface for making
media players available to other applications on the same system.
Mopidy's :ref:`MPRIS frontend <mpris-frontend>` currently implements all
required parts of the MPRIS spec, but not the optional playlist interface. For
tracking the development of the playlist interface, see :issue:`229`.
.. _ubuntu-sound-menu:
Ubuntu Sound Menu
=================
The `Ubuntu Sound Menu <https://wiki.ubuntu.com/SoundMenu>`_ is the default
sound menu in Ubuntu since 10.10 or 11.04. By default, it only includes the
Rhytmbox music player, but many other players can integrate with the sound
menu, including the official Spotify player and Mopidy.
.. image:: /_static/ubuntu-sound-menu.png
:height: 480
:width: 955
If you install Mopidy from apt.mopidy.com, the sound menu should work out of
the box. If you install Mopidy in any other way, you need to make sure that the
file located at ``data/mopidy.desktop`` in the Mopidy git repo is installed as
``/usr/share/applications/mopidy.desktop``, and that the properties ``TryExec``
and ``Exec`` in the file points to an existing executable file, preferably your
Mopidy executable. If this isn't in place, the sound menu will not detect that
Mopidy is running.
Next, Mopidy's MPRIS frontend must be running for the sound menu to be able to
control Mopidy. The frontend is activated by default, so unless you've changed
the :attr:`mopidy.settings.FRONTENDS` setting, you should be good to go. Keep
an eye out for warnings or errors from the MPRIS frontend when you start
Mopidy, since it may fail because of missing dependencies or because Mopidy is
started outside of X; the frontend won't work if ``$DISPLAY`` isn't set when
Mopidy is started.
Under normal use, if Mopidy isn't running and you open the menu and click on
"Mopidy Music Server", a terminal window will open and automatically start
Mopidy. If Mopidy is already running, you'll see that Mopidy is marked with an
arrow to the left of its name, like in the screen shot above, and the player
controls will be visible. Mopidy doesn't support the MPRIS spec's optional
playlist interface yet, so you'll not be able to select what track to play from
the sound menu. If you use an MPD client to queue a playlist, you can use the
sound menu to check what you're currently playing, pause, resume, and skip to
the next and previous track.
In summary, Mopidy's sound menu integration is currently not a full featured
client, but it's a convenient addition to an MPD client since it's always
easily available on Unity's menu bar.
Rygel
=====
Rygel is an application that will translate between Mopidy's MPRIS interface
and UPnP, and thus make Mopidy controllable from devices compatible with UPnP
and/or DLNA. To read more about this, see :ref:`upnp-clients`.

117
docs/clients/upnp.rst Normal file
View File

@ -0,0 +1,117 @@
.. _upnp-clients:
************
UPnP clients
************
`UPnP <http://en.wikipedia.org/wiki/Universal_Plug_and_Play>`_ is a set of
specifications for media sharing, playing, remote control, etc, across a home
network. The specs are supported by a lot of consumer devices (like
smartphones, TVs, Xbox, and PlayStation) that are often labeled as being `DLNA
<http://en.wikipedia.org/wiki/DLNA>`_ compatible or certified.
The DLNA guidelines and UPnP specifications defines several device roles, of
which Mopidy may play two:
DLNA Digital Media Server (DMS) / UPnP AV MediaServer:
A MediaServer provides a library of media and is capable of streaming that
media to a MediaRenderer. If Mopidy was a MediaServer, you could browse and
play Mopidy's music on a TV, smartphone, or tablet supporting UPnP. Mopidy
does not currently support this, but we may in the future. :issue:`52` is
the relevant wishlist issue.
DLNA Digital Media Renderer (DMR) / UPnP AV MediaRenderer:
A MediaRenderer is asked by some remote controller to play some
given media, typically served by a MediaServer. If Mopidy was a
MediaRenderer, you could use e.g. your smartphone or tablet to make Mopidy
play media. Mopidy *does already* have experimental support for being a
MediaRenderer with the help of Rygel, as you can read more about below.
.. _rygel:
How to make Mopidy available as an UPnP MediaRenderer
=====================================================
With the help of `the Rygel project <https://live.gnome.org/Rygel>`_ Mopidy can
be made available as an UPnP MediaRenderer. Rygel will interface with Mopidy's
:ref:`MPRIS frontend <mpris-frontend>`, and make Mopidy available as a
MediaRenderer on the local network. Since this depends on the MPRIS frontend,
which again depends on D-Bus being available, this will only work on Linux, and
not OS X. MPRIS/D-Bus is only available to other applications on the same host,
so Rygel must be running on the same machine as Mopidy.
1. Start Mopidy and make sure the :ref:`MPRIS frontend <mpris-frontend>` is
working. It is activated by default, but you may miss dependencies or be
using OS X, in which case it will not work. Check the console output when
Mopidy is started for any errors related to the MPRIS frontend. If you're
unsure it is working, there are instructions for how to test it on the
:ref:`MPRIS frontend <mpris-frontend>` page.
2. Install Rygel. On Debian/Ubuntu::
sudo apt-get install rygel
3. Enable Rygel's MPRIS plugin. On Debian/Ubuntu, edit ``/etc/rygel.conf``,
find the ``[MPRIS]`` section, and change ``enabled=false`` to
``enabled=true``.
4. Start Rygel by running::
rygel
Example output::
$ rygel
Rygel-Message: New plugin 'MediaExport' available
Rygel-Message: New plugin 'org.mpris.MediaPlayer2.spotify' available
Rygel-Message: New plugin 'org.mpris.MediaPlayer2.mopidy' available
Note that in the above example, both the official Spotify client and Mopidy
is running and made available through Rygel.
The UPnP-Inspector client
=========================
`UPnP-Inspector <http://coherence.beebits.net/wiki/UPnP-Inspector>`_ is a
graphical analyzer and debugging tool for UPnP services. It will detect any
UPnP devices on your network, and show these in a tree structure. This is not a
tool for your everyday music listening while relaxing on the couch, but it may
be of use for testing that your setup works correctly.
1. Install UPnP-Inspector. On Debian/Ubuntu::
sudo apt-get install upnp-inspector
2. Run it::
upnp-inspector
3. Assuming that Mopidy is running with a working MPRIS frontend, and that
Rygel is running on the same machine, Mopidy should now appear in
UPnP-Inspector's device list.
4. If you expand the tree item saying ``Mopidy
(MediaRenderer:2)`` or similiar, and then the sub element named
``AVTransport:2`` or similar, you'll find a list of commands you can invoke.
E.g. if you double-click the ``Pause`` command, you'll get a new window
where you can press an ``Invoke`` button, and then Mopidy should be paused.
Note that if you have a firewall on the host running Mopidy and Rygel, and you
want this to be exposed to the rest of your local network, you need to open up
your firewall for UPnP traffic. UPnP use UDP port 1900 as well as some
dynamically assigned ports. I've only verified that this procedure works across
the network by temporarily disabling the firewall on the the two hosts
involved, so I'll leave any firewall configuration as an exercise to the
reader.
Other clients
=============
For a long list of UPnP clients for all possible platforms, see Wikipedia's
`List of UPnP AV media servers and clients
<http://en.wikipedia.org/wiki/List_of_UPnP_AV_media_servers_and_clients>`_.

View File

@ -3,7 +3,8 @@
# Mopidy documentation build configuration file, created by
# sphinx-quickstart on Fri Feb 5 22:19:08 2010.
#
# This file is execfile()d with the current directory set to its containing dir.
# This file is execfile()d with the current directory set to its containing
# dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
@ -12,9 +13,9 @@
# serve to show the default.
import os
import re
import sys
class Mock(object):
def __init__(self, *args, **kwargs):
pass
@ -34,6 +35,7 @@ class Mock(object):
else:
return Mock()
MOCK_MODULES = [
'dbus',
'dbus.mainloop',
@ -63,12 +65,16 @@ sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + '/../'))
# the string True.
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
# -- General configuration -----------------------------------------------------
# -- General configuration ----------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = ['sphinx.ext.autodoc', 'sphinx.ext.graphviz',
'sphinx.ext.extlinks', 'sphinx.ext.viewcode']
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.extlinks',
'sphinx.ext.graphviz',
'sphinx.ext.viewcode',
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
@ -91,7 +97,7 @@ copyright = u'2010-2012, Stein Magnus Jodal and contributors'
# built documents.
#
# The full version, including alpha/beta/rc tags.
from mopidy import get_version
from mopidy.utils.versioning import get_version
release = get_version()
# The short X.Y version.
version = '.'.join(release.split('.')[:2])
@ -114,7 +120,8 @@ version = '.'.join(release.split('.')[:2])
# for source files.
exclude_trees = ['_build']
# The reST default role (used for this markup: `text`) to use for all documents.
# The reST default role (used for this markup: `text`) to use for all
# documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
@ -135,7 +142,7 @@ pygments_style = 'sphinx'
modindex_common_prefix = ['mopidy.']
# -- Options for HTML output ---------------------------------------------------
# -- Options for HTML output --------------------------------------------------
# The theme to use for HTML and HTML Help pages. Major themes that come with
# Sphinx are currently 'default' and 'sphinxdoc'.
@ -210,7 +217,7 @@ html_static_path = ['_static']
htmlhelp_basename = 'Mopidydoc'
# -- Options for LaTeX output --------------------------------------------------
# -- Options for LaTeX output -------------------------------------------------
# The paper size ('letter' or 'a4').
#latex_paper_size = 'letter'
@ -218,11 +225,16 @@ htmlhelp_basename = 'Mopidydoc'
# The font size ('10pt', '11pt' or '12pt').
#latex_font_size = '10pt'
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]).
# Grouping the document tree into LaTeX files. List of tuples (source start
# file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
('index', 'Mopidy.tex', u'Mopidy Documentation',
u'Stein Magnus Jodal', 'manual'),
(
'index',
'Mopidy.tex',
u'Mopidy Documentation',
u'Stein Magnus Jodal',
'manual'
),
]
# The name of an image file (relative to this directory) to place at the top of

View File

@ -3,7 +3,7 @@ Development
***********
Development of Mopidy is coordinated through the IRC channel ``#mopidy`` at
``irc.freenode.net`` and through `GitHub <http://github.com/>`_.
``irc.freenode.net`` and through `GitHub <https://github.com/>`_.
Release schedule
@ -37,13 +37,58 @@ implemented, and you may add new wishlist issues if your ideas are not already
represented.
.. _run-from-git:
Run Mopidy from Git repo
========================
If you want to contribute to the development of Mopidy, you should run Mopidy
directly from the Git repo.
#. First of all, install Mopidy in the recommended way for your OS and/or
distribution, like described at :ref:`installation`. You can have a
system-wide installation of the last Mopidy release in addition to the Git
repo which you run from when you code on Mopidy.
#. Then install Git, if haven't already. For Ubuntu/Debian::
sudo apt-get install git-core
On OS X using Homebrew::
sudo brew install git
#. Clone the official Mopidy repository::
git clone git://github.com/mopidy/mopidy.git
or your own fork of it::
git clone git@github.com:mygithubuser/mopidy.git
#. You can then run Mopidy directly from the Git repository::
cd mopidy/ # Move into the Git repo dir
python mopidy # Run python on the mopidy source code dir
How you update your clone depends on whether you cloned the official Mopidy
repository or your own fork, whether you have made any changes to the clone
or not, and whether you are currently working on a feature branch or not. In
other words, you'll need to learn Git.
For an introduction to Git, please visit `git-scm.com <http://git-scm.com/>`_.
Also, please read the rest of our developer documentation before you start
contributing.
Code style
==========
- Follow :pep:`8` unless otherwise noted. `pep8.py
<http://pypi.python.org/pypi/pep8/>`_ can be used to check your code against
the guidelines, however remember that matching the style of the surrounding
code is also important.
<http://pypi.python.org/pypi/pep8/>`_ or `flake8
<http://pypi.python.org/pypi/flake8>`_ can be used to check your code
against the guidelines, however remember that matching the style of the
surrounding code is also important.
- Use four spaces for indentation, *never* tabs.
@ -89,7 +134,8 @@ Code style
Commit guidelines
=================
- We follow the development process described at http://nvie.com/git-model.
- We follow the development process described at
`nvie.com <http://nvie.com/posts/a-successful-git-branching-model/>`_.
- Keep commits small and on topic.
@ -118,27 +164,35 @@ Then, to run all tests, go to the project directory and run::
For example::
$ nosetests
......................................................................
......................................................................
......................................................................
.......
----------------------------------------------------------------------
Ran 217 tests in 0.267s
.............................................................................
.............................................................................
.............................................................................
.............................................................................
.............................................................................
.............................................................................
.............................................................................
.............................................................................
.............................................................................
.............................................................................
.............................................................................
.............................................................................
.............................................................................
.............................................................
-----------------------------------------------------------------------------
1062 tests run in 7.4 seconds (1062 tests passed)
OK
To run tests with test coverage statistics, remember to specify the tests dir::
To run tests with test coverage statistics::
nosetests --with-coverage
nosetests --with-coverage tests/
For more documentation on testing, check out the `nose documentation
<http://somethingaboutorange.com/mrl/projects/nose/>`_.
<http://nose.readthedocs.org/>`_.
Continuous integration
======================
Mopidy uses the free service `Travis CI <http://travis-ci.org/#mopidy/mopidy>`_
Mopidy uses the free service `Travis CI <https://travis-ci.org/mopidy/mopidy>`_
for automatically running the test suite when code is pushed to GitHub. This
works both for the main Mopidy repo, but also for any forks. This way, any
contributions to Mopidy through GitHub will automatically be tested by Travis
@ -196,14 +250,44 @@ of writing. See ``--help`` for available options. Sample session::
+ACK [2@0] {listallinfo} incorrect arguments
To ensure that Mopidy and MPD have comparable state it is suggested you setup
both to use ``tests/data/library_tag_cache`` for their tag cache and
``tests/data`` for music/playlist folders.
both to use ``tests/data/advanced_tag_cache`` for their tag cache and
``tests/data/scanner/advanced/`` for the music folder and ``tests/data`` for
playlists.
Setting profiles during development
===================================
While developing Mopidy switching settings back and forth can become an all too
frequent occurrence. As a quick hack to get around this you can structure your
settings file in the following way::
import os
profile = os.environ.get('PROFILE', '').split(',')
if 'spotify' in profile:
BACKENDS = (u'mopidy.backends.spotify.SpotifyBackend',)
elif 'local' in profile:
BACKENDS = (u'mopidy.backends.local.LocalBackend',)
LOCAL_MUSIC_PATH = u'~/music'
if 'shoutcast' in profile:
OUTPUT = u'lame ! shout2send mount="/stream"'
elif 'silent' in profile:
OUTPUT = u'fakesink'
MIXER = None
SPOTIFY_USERNAME = u'xxxxx'
SPOTIFY_PASSWORD = u'xxxxx'
Using this setup you can now run Mopidy with ``PROFILE=silent,spotify mopidy``
if you for instance want to test Spotify without any actual audio output.
Writing documentation
=====================
To write documentation, we use `Sphinx <http://sphinx.pocoo.org/>`_. See their
To write documentation, we use `Sphinx <http://sphinx-doc.org/>`_. See their
site for lots of documentation on how to use Sphinx. To generate HTML or LaTeX
from the documentation files, you need some additional dependencies.

View File

@ -2,26 +2,33 @@
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.
Mopidy is a music server which can play music both from your :ref:`local hard
drive <local-backend>` and from :ref:`Spotify <spotify-backend>`. Searches
returns results from both your local hard drive and from Spotify, and you can
mix tracks from both sources in your play queue. Your Spotify playlists are
also available for use, though we don't support modifying them yet.
To install Mopidy, start out by reading :ref:`installation`.
To control your music server, you can use the :ref:`Ubuntu Sound Menu
<ubuntu-sound-menu>` on the machine running Mopidy, any device on the same
network which can control UPnP MediaRenderers (see :ref:`upnp-clients`), or any
:ref:`MPD client <mpd-clients>`. MPD clients are available for most platforms,
including Windows, Mac OS X, Linux, Android, and iOS.
To install Mopidy, start 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>`_.
<https://github.com/mopidy/mopidy/issues>`_.
Project resources
=================
- `Documentation <http://docs.mopidy.com/>`_
- `Source code <http://github.com/mopidy/mopidy>`_
- `Issue tracker <http://github.com/mopidy/mopidy/issues>`_
- `Source code <https://github.com/mopidy/mopidy>`_
- `Issue tracker <https://github.com/mopidy/mopidy/issues>`_
- `CI server <https://travis-ci.org/mopidy/mopidy>`_
- IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
@ -32,6 +39,7 @@ User documentation
:maxdepth: 3
installation/index
installation/raspberrypi
settings
running
clients/index
@ -39,6 +47,7 @@ User documentation
licenses
changes
Reference documentation
=======================
@ -48,6 +57,7 @@ Reference documentation
api/index
modules/index
Development documentation
=========================
@ -56,10 +66,10 @@ Development documentation
development
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

View File

@ -1,113 +0,0 @@
**********************
GStreamer installation
**********************
To use Mopidy, you first need to install GStreamer and the GStreamer Python
bindings.
Installing GStreamer on Linux
=============================
GStreamer is packaged for most popular Linux distributions. Search for
GStreamer in your package manager, and make sure to install the Python
bindings, and the "good" and "ugly" plugin sets.
Debian/Ubuntu
-------------
If you use Debian/Ubuntu you can install GStreamer like this::
sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \
gstreamer0.10-plugins-ugly
If you install Mopidy from our APT archive, you don't need to install GStreamer
yourself. The Mopidy Debian package will handle it for you.
Arch Linux
----------
If you use Arch Linux, install the following packages from the official
repository::
sudo pacman -S gstreamer0.10-python gstreamer0.10-good-plugins \
gstreamer0.10-ugly-plugins
Installing GStreamer on OS X
============================
.. note::
We have been working with `Homebrew <https://github.com/mxcl/homebrew>`_ to
make all the GStreamer packages easily installable on OS X using Homebrew.
We've gotten most of our packages included, but the Homebrew guys aren't
very happy to include Python specific packages into Homebrew, even though
they are not installable by pip. If you're interested, see the discussion
in `Homebrew's issue #1612
<https://github.com/mxcl/homebrew/issues/issue/1612>`_ for details.
The following is currently the shortest path to installing GStreamer with
Python bindings on OS X using Homebrew.
#. Install `Homebrew <https://github.com/mxcl/homebrew>`_.
#. Download our Homebrew formula for ``gst-python``::
curl -o $(brew --prefix)/Library/Formula/gst-python.rb \
https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/gst-python.rb
#. Install the required packages::
brew install gst-python gst-plugins-good gst-plugins-ugly
#. Make sure to include Homebrew's Python ``site-packages`` directory in your
``PYTHONPATH``. If you don't include this, Mopidy will not find GStreamer
and crash.
You can either amend your ``PYTHONPATH`` permanently, by adding the
following statement to your shell's init file, e.g. ``~/.bashrc``::
export PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages:$PYTHONPATH
Or, you can prefix the Mopidy command every time you run it::
PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages mopidy
Note that you need to replace ``python2.7`` with ``python2.6`` if that's
the Python version you are using. To find your Python version, run::
python --version
Testing the installation
========================
If you now run the ``gst-inspect-0.10`` command (the version number may vary),
you should see a long listing of installed plugins, ending in a summary line::
$ gst-inspect-0.10
... long list of installed plugins ...
Total count: 218 plugins (1 blacklist entry not shown), 1031 features
You should be able to produce a audible tone by running::
gst-launch-0.10 audiotestsrc ! autoaudiosink
If you cannot hear any sound when running this command, you won't hear any
sound from Mopidy either, as Mopidy uses GStreamer's ``autoaudiosink`` to play
audio. Thus, make this work before you continue installing Mopidy.
Using a custom audio sink
=========================
If you for some reason want to use some other GStreamer audio sink than
``autoaudiosink``, you can set :attr:`mopidy.settings.OUTPUT` to a partial
GStreamer pipeline description describing the GStreamer sink you want to use.
Example of ``settings.py`` for OSS4::
OUTPUT = u'oss4sink'

View File

@ -4,60 +4,21 @@
Installation
************
There are several ways to install Mopidy. What way is best depends upon your
setup and whether you want to use stable releases or less stable development
versions.
There are several ways to install Mopidy. What way is best depends upon your OS
and/or distribution. If you want to contribute to the development of Mopidy,
you should first read this page, then have a look at :ref:`run-from-git`.
.. contents:: Installation guides
:local:
Requirements
============
.. toctree::
:hidden:
gstreamer
libspotify
If you install Mopidy from the APT archive, as described below, APT will take
care of all the dependencies for you. Otherwise, make sure you got the required
dependencies installed.
- Hard dependencies:
- Python >= 2.6, < 3
- Pykka >= 0.12.3::
sudo pip install -U pykka
- GStreamer 0.10.x, with Python bindings. See :doc:`gstreamer`.
- Optional dependencies:
- For Spotify support, you need libspotify and pyspotify. See
:doc:`libspotify`.
- To scrobble your played tracks to Last.fm, you need pylast::
sudo pip install -U pylast
- To use MPRIS, e.g. for controlling Mopidy from the Ubuntu Sound Menu, you
need some additional requirements::
sudo apt-get install python-dbus python-indicate
Install latest stable release
=============================
From APT archive
----------------
Debian/Ubuntu: Install from apt.mopidy.com
==========================================
If you run a Debian based Linux distribution, like Ubuntu, the easiest way to
install Mopidy is from the Mopidy APT archive. When installing from the APT
archive, you will automatically get updates to Mopidy in the same way as you
get updates to the rest of your distribution.
install Mopidy is from the `Mopidy APT archive <http://apt.mopidy.com/>`_. When
installing from the APT archive, you will automatically get updates to Mopidy
in the same way as you get updates to the rest of your distribution.
#. Add the archive's GPG key::
@ -71,119 +32,48 @@ get updates to the rest of your distribution.
deb http://apt.mopidy.com/ stable main contrib non-free
deb-src http://apt.mopidy.com/ stable main contrib non-free
For the lazy, you can simply run the following command to create
``/etc/apt/sources.list.d/mopidy.list``::
sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list
#. Install Mopidy and all dependencies::
sudo apt-get update
sudo apt-get install mopidy
#. Next, you need to set a couple of :doc:`settings </settings>`, and then
#. Finally, you need to set a couple of :doc:`settings </settings>`, and then
you're ready to :doc:`run Mopidy </running>`.
When a new release is out, and you can't wait for you system to figure it out
for itself, run the following to force an upgrade::
When a new release of Mopidy is out, and you can't wait for you system to
figure it out for itself, run the following to upgrade right away::
sudo apt-get update
sudo apt-get dist-upgrade
From PyPI using Pip
-------------------
Raspberry Pi running Debian
---------------------------
If you are on OS X or on Linux, but can't install from the APT archive, you can
install Mopidy from PyPI using Pip.
#. When you install using Pip, you first need to ensure that all of Mopidy's
dependencies have been installed. See the section on dependencies above.
#. Then, you need to install Pip::
sudo apt-get install python-setuptools python-pip # On Ubuntu/Debian
sudo easy_install pip # On OS X
#. To install the currently latest stable release of Mopidy::
sudo pip install -U Mopidy
To upgrade Mopidy to future releases, just rerun this command.
#. Next, you need to set a couple of :doc:`settings </settings>`, and then
you're ready to :doc:`run Mopidy </running>`.
Fred Hatfull has created a guide for installing a Raspberry Pi from scratch
with Debian and Mopidy. See :ref:`raspberrypi-installation`.
Install development version
===========================
Vagrant virtual machine running Ubuntu
--------------------------------------
If you want to follow the development of Mopidy closer, you may install a
development version of Mopidy. These are not as stable as the releases, but
you'll get access to new features earlier and may help us by reporting issues.
Paul Sturgess has created a Vagrant and Chef setup that automatically creates
and sets up a virtual machine which runs Mopidy. Check out
https://github.com/paulsturgess/mopidy-vagrant if you're interested in trying
it out.
From snapshot using Pip
-----------------------
Arch Linux: Install from AUR
============================
If you want to follow Mopidy development closer, you may install a snapshot of
Mopidy's ``develop`` branch.
#. When you install using Pip, you first need to ensure that all of Mopidy's
dependencies have been installed. See the section on dependencies above.
#. Then, you need to install Pip::
sudo apt-get install python-setuptools python-pip # On Ubuntu/Debian
sudo easy_install pip # On OS X
#. To install the latest snapshot of Mopidy, run::
sudo pip install mopidy==dev
To upgrade Mopidy to future releases, just rerun this command.
#. Next, you need to set a couple of :doc:`settings </settings>`, and then
you're ready to :doc:`run Mopidy </running>`.
From Git
--------
If you want to contribute to Mopidy, you should install Mopidy using Git.
#. When you install from Git, you first need to ensure that all of Mopidy's
dependencies have been installed. See the section on dependencies above.
#. Then install Git, if haven't already::
sudo apt-get install git-core # On Ubuntu/Debian
sudo brew install git # On OS X using Homebrew
#. Clone the official Mopidy repository, or your own fork of it::
git clone git://github.com/mopidy/mopidy.git
#. Next, you need to set a couple of :doc:`settings </settings>`.
#. You can then run Mopidy directly from the Git repository::
cd mopidy/ # Move into the Git repo dir
python mopidy # Run python on the mopidy source code dir
#. Later, to get the latest changes to Mopidy::
cd mopidy/
git pull
For an introduction to ``git``, please visit `git-scm.com
<http://git-scm.com/>`_. Also, please read our :doc:`developer documentation
</development>`.
From AUR on ArchLinux
---------------------
If you are running ArchLinux, you can install a development snapshot of Mopidy
using the package found at http://aur.archlinux.org/packages.php?ID=44026.
#. First, you should consider installing any optional dependencies not included
by the AUR package, like required for e.g. Last.fm scrobbling.
If you are running Arch Linux, you can install a development snapshot of Mopidy
using the `mopidy-git <https://aur.archlinux.org/packages/mopidy-git/>`_
package found in AUR.
#. To install Mopidy with GStreamer, libspotify and pyspotify, you can use
``packer``, ``yaourt``, or do it by hand like this::
@ -195,5 +85,161 @@ using the package found at http://aur.archlinux.org/packages.php?ID=44026.
To upgrade Mopidy to future releases, just rerun ``makepkg``.
#. Next, you need to set a couple of :doc:`settings </settings>`, and then
#. Optional: If you want to scrobble your played tracks to Last.fm, you need to
install `python2-pylast
<https://aur.archlinux.org/packages/python2-pylast/>`_ from AUR.
#. Finally, you need to set a couple of :doc:`settings </settings>`, and then
you're ready to :doc:`run Mopidy </running>`.
OS X: Install from Homebrew and Pip
===================================
If you are running OS X, you can install everything needed with Homebrew and
Pip.
#. Install `Homebrew <https://github.com/mxcl/homebrew>`_.
If you are already using Homebrew, make sure your installation is up to
date before you continue::
brew update
brew upgrade
#. Install the required packages from Homebrew::
brew install gst-python gst-plugins-good gst-plugins-ugly libspotify
#. Make sure to include Homebrew's Python ``site-packages`` directory in your
``PYTHONPATH``. If you don't include this, Mopidy will not find GStreamer
and crash.
You can either amend your ``PYTHONPATH`` permanently, by adding the
following statement to your shell's init file, e.g. ``~/.bashrc``::
export PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages:$PYTHONPATH
Or, you can prefix the Mopidy command every time you run it::
PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages mopidy
Note that you need to replace ``python2.7`` with ``python2.6`` in the above
``PYTHONPATH`` examples if you are using Python 2.6. To find your Python
version, run::
python --version
#. Next up, you need to install some Python packages. To do so, we use Pip. If
you don't have the ``pip`` command, you can install it now::
sudo easy_install pip
#. Then get, build, and install the latest releast of pyspotify, pylast, pykka,
and Mopidy using Pip::
sudo pip install -U pyspotify pylast pykka mopidy
#. Finally, you need to set a couple of :doc:`settings </settings>`, and then
you're ready to :doc:`run Mopidy </running>`.
Otherwise: Install from source using Pip
========================================
If you are on on Linux, but can't install from the APT archive or from AUR, you
can install Mopidy from PyPI using Pip.
#. First of all, you need Python >= 2.6, < 3. Check if you have Python and what
version by running::
python --version
#. When you install using Pip, you need to make sure you have Pip. You'll also
need a C compiler and the Python development headers to build pyspotify
later.
This is how you install it on Debian/Ubuntu::
sudo apt-get install build-essential python-dev python-pip
And on Arch Linux from the official repository::
sudo pacman -S base-devel python2-pip
#. Then you'll need to install all of Mopidy's hard dependencies:
- Pykka >= 1.0::
sudo pip install -U pykka
- GStreamer 0.10.x, with Python bindings. GStreamer is packaged for most
popular Linux distributions. Search for GStreamer in your package manager,
and make sure to install the Python bindings, and the "good" and "ugly"
plugin sets.
If you use Debian/Ubuntu you can install GStreamer like this::
sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \
gstreamer0.10-plugins-ugly gstreamer0.10-tools
If you use Arch Linux, install the following packages from the official
repository::
sudo pacman -S gstreamer0.10-python gstreamer0.10-good-plugins \
gstreamer0.10-ugly-plugins
#. Optional: If you want Spotify support in Mopidy, you'll need to install
libspotify and the Python bindings, pyspotify.
#. First, check `pyspotify's changelog <http://pyspotify.mopidy.com/>`_ to
see what's the latest version of libspotify which it supports. The
versions of libspotify and pyspotify are tightly coupled, so you'll need
to get this right.
#. Download and install the appropriate version of libspotify for your OS and
CPU architecture from `Spotify
<https://developer.spotify.com/technologies/libspotify/>`_.
For libspotify 12.1.51 for 64-bit Linux the process is as follows::
wget https://developer.spotify.com/download/libspotify/libspotify-12.1.51-Linux-x86_64-release.tar.gz
tar zxfv libspotify-12.1.51-Linux-x86_64-release.tar.gz
cd libspotify-12.1.51-Linux-x86_64-release/
sudo make install prefix=/usr/local
sudo ldconfig
Remember to adjust the above example for the latest libspotify version
supported by pyspotify, your OS, and your CPU architecture.
#. Then get, build, and install the latest release of pyspotify using Pip::
sudo pip install -U pyspotify
#. Optional: If you want to scrobble your played tracks to Last.fm, you need
pylast::
sudo pip install -U pylast
#. Optional: To use MPRIS, e.g. for controlling Mopidy from the Ubuntu Sound
Menu or from an UPnP client via Rygel, you need some additional
dependencies: the Python bindings for libindicate, and the Python bindings
for libdbus, the reference D-Bus library.
On Debian/Ubuntu::
sudo apt-get install python-dbus python-indicate
#. Then, to install the latest release of Mopidy::
sudo pip install -U mopidy
To upgrade Mopidy to future releases, just rerun this command.
Alternatively, if you want to track Mopidy development closer, you may
install a snapshot of Mopidy's ``develop`` Git branch using Pip::
sudo pip install mopidy==dev
#. Finally, you need to set a couple of :doc:`settings </settings>`, and then
you're ready to :doc:`run Mopidy </running>`.

View File

@ -1,112 +0,0 @@
***********************
libspotify installation
***********************
Mopidy uses `libspotify
<http://developer.spotify.com/en/libspotify/overview/>`_ for playing music from
the Spotify music service. To use :mod:`mopidy.backends.spotify` you must
install libspotify and `pyspotify <http://pyspotify.mopidy.com/>`_.
.. note::
This backend requires a paid `Spotify premium account
<http://www.spotify.com/no/get-spotify/premium/>`_.
Installing libspotify
=====================
On Linux from APT archive
-------------------------
If you install from APT, jump directly to :ref:`pyspotify_installation` below.
On Linux from source
--------------------
First, check pyspotify's changelog to see what's the latest version of
libspotify which is supported. The versions of libspotify and pyspotify are
tightly coupled.
Download and install the appropriate version of libspotify for your OS and CPU
architecture from https://developer.spotify.com/en/libspotify/.
For libspotify 0.0.8 for 64-bit Linux the process is as follows::
wget http://developer.spotify.com/download/libspotify/libspotify-0.0.8-linux6-x86_64.tar.gz
tar zxfv libspotify-0.0.8-linux6-x86_64.tar.gz
cd libspotify-0.0.8-linux6-x86_64/
sudo make install prefix=/usr/local
sudo ldconfig
Remember to adjust for the latest libspotify version supported by pyspotify,
your OS and your CPU architecture.
When libspotify has been installed, continue with
:ref:`pyspotify_installation`.
On OS X from Homebrew
---------------------
In OS X you need to have `XCode <http://developer.apple.com/tools/xcode/>`_ and
`Homebrew <http://mxcl.github.com/homebrew/>`_ installed. Then, to install
libspotify::
brew install libspotify
To update your existing libspotify installation using Homebrew::
brew update
brew upgrade
When libspotify has been installed, continue with
:ref:`pyspotify_installation`.
.. _pyspotify_installation:
Installing pyspotify
====================
When you've installed libspotify, it's time for making it available from Python
by installing pyspotify.
On Linux from APT archive
-------------------------
If you run a Debian based Linux distribution, like Ubuntu, see
http://apt.mopidy.com/ for how to use the Mopidy APT archive as a software
source on your system. Then, simply run::
sudo apt-get install python-spotify
This command will install both libspotify and pyspotify for you.
On Linux from source
-------------------------
If you have have already installed libspotify, you can continue with installing
the libspotify Python bindings, called pyspotify.
On Linux, you need to get the Python development files installed. On
Debian/Ubuntu systems run::
sudo apt-get install python-dev
Then get, build, and install the latest releast of pyspotify using ``pip``::
sudo pip install -U pyspotify
On OS X from source
-------------------
If you have already installed libspotify, you can get, build, and install the
latest releast of pyspotify using ``pip``::
sudo pip install -U pyspotify

View File

@ -0,0 +1,264 @@
.. _raspberrypi-installation:
****************************
Installation on Raspberry Pi
****************************
As of early August, 2012, running Mopidy on a `Raspberry Pi
<http://www.raspberrypi.org/>`_ is possible, although there are a few
significant drawbacks to doing so. This document is intended to help you get
Mopidy running on your Raspberry Pi and to document the progress made and
issues surrounding running Mopidy on the Raspberry Pi.
Mopidy will not currently run with Spotify support on the foundation-provided
`Raspbian <http://www.raspbian.org>`_ distribution. See :ref:`not-raspbian` for
details. However, Mopidy should run with Spotify support on any ARM Debian
image that has hardware floating-point support **disabled**.
.. image:: /_static/raspberry-pi-by-jwrodgers.jpg
:width: 640
:height: 427
.. _raspi-squeeze:
How to for Debian 6 (Squeeze)
=============================
The following guide illustrates how to get Mopidy running on a minimal Debian
squeeze distribution.
1. The image used can be downloaded at
http://www.linuxsystems.it/2012/06/debian-wheezy-raspberry-pi-minimal-image/.
This image is a very minimal distribution and does not include many common
packages you might be used to having access to. If you find yourself trying
to complete instructions here and getting ``command not found``, try using
``apt-get`` to install the relevant packages!
2. Flash the OS image to your SD card. See
http://elinux.org/RPi_Easy_SD_Card_Setup for help.
3. If you have an SD card that's >2 GB, resize the disk image to use some more
space (we'll need a bit more to install some packages and stuff). See
http://elinux.org/RPi_Resize_Flash_Partitions#Manually_resizing_the_SD_card_on_Raspberry_Pi
for help.
4. To even get to the point where we can start installing software let's create
a new user and give it sudo access.
- Install ``sudo``::
apt-get install sudo
- Create a user account::
adduser <username>
- Give the user sudo access by adding it to the ``sudo`` group so we don't
have to do everything on the ``root`` account::
adduser <username> sudo
- While we're at it, give your user access to the sound card by adding it to
the audio group::
adduser <username> audio
- Log in to your Raspberry Pi again with your new user account instead of
the ``root`` account.
5. Enable the Raspberry Pi's sound drivers:
- To enable the Raspberry Pi's sound driver::
sudo modprobe snd_bcm2835
- To load the sound driver at boot time::
echo "snd_bcm2835" | sudo tee /etc/modules
6. Let's get the Raspberry Pi up-to-date:
- Get some tools that we need to download and run the ``rpi-update``
script::
sudo apt-get install ca-certificates git-core binutils
- Download ``rpi-update`` from Github::
sudo wget https://raw.github.com/Hexxeh/rpi-update/master/rpi-update
- Move ``rpi-update`` to an appropriate location::
sudo mv rpi-update /usr/local/bin/rpi-update
- Make ``rpi-update`` executable::
sudo chmod +x /usr/local/bin/rpi-update
- Finally! Update your firmware::
sudo rpi-update
- After firmware updating finishes, reboot your Raspberry Pi::
sudo reboot
7. To avoid a couple of potential problems with Mopidy, turn on IPv6 support:
- Load the IPv6 kernel module now::
sudo modprobe ipv6
- Add ``ipv6`` to ``/etc/modules`` to ensure the IPv6 kernel module is
loaded on boot::
echo ipv6 | sudo tee /etc/modules
8. Installing Mopidy and its dependencies from `apt.mopidy.com
<http://apt.mopidy.com/>`_, as described in :ref:`installation`. In short::
wget -q -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add -
sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list
sudo apt-get update
sudo apt-get install mopidy
9. jackd2, which should be installed at this point, seems to cause some
problems. Let's install jackd1, as it seems to work a little bit better::
sudo apt-get install jackd1
You may encounter some issues with your audio configuration where sound does
not play. If that happens, edit your ``/etc/asound.conf`` to read something
like::
pcm.mmap0 {
type mmap_emul;
slave {
pcm "hw:0,0";
}
}
pcm.!default {
type plug;
slave {
pcm mmap0;
}
}
.. _raspi-wheezy:
How to for Debian 7 (Wheezy)
============================
This is a very similar system to Debian 6.0 above, but with a bit newer
software packages, as Wheezy is going to be the next release of Debian.
1. Download the latest wheezy disk image from
http://downloads.raspberrypi.org/images/debian/7/. I used the one dated
2012-08-08.
2. Flash the OS image to your SD card. See
http://elinux.org/RPi_Easy_SD_Card_Setup for help.
3. If you have an SD card that's >2 GB, you don't have to resize the file
systems on another computer. Just boot up your Raspberry Pi with the
unaltered partions, and it will boot right into the ``raspi-config`` tool,
which will let you grow the root file system to fill the SD card. This tool
will also allow you do other useful stuff, like turning on the SSH server.
4. As opposed to on Squeeze, ``sudo`` comes preinstalled. You can login to the
default user using username ``pi`` and password ``raspberry``. To become
root, just enter ``sudo -i``.
Opposed to on Squeeze, there is no need to add your user to the ``audio``
group, as the ``pi`` user already is a member of that group.
5. As opposed to on Squeeze, the correct sound driver comes preinstalled.
6. As opposed to on Squeeze, your kernel and GPU firmware is rather up to date
when running Wheezy.
7. To avoid a couple of potential problems with Mopidy, turn on IPv6 support:
- Load the IPv6 kernel module now::
sudo modprobe ipv6
- Add ``ipv6`` to ``/etc/modules`` to ensure the IPv6 kernel module is
loaded on boot::
echo ipv6 | sudo tee /etc/modules
8. Installing Mopidy and its dependencies from `apt.mopidy.com
<http://apt.mopidy.com/>`_, as described in :ref:`installation`. In short::
wget -q -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add -
sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list
sudo apt-get update
sudo apt-get install mopidy
9. Since I have a HDMI cable connected, but want the sound on the analog sound
connector, I have to run::
amixer cset numid=3 1
to force it to use analog output. ``1`` means analog, ``0`` means auto, and
is the default, while ``2`` means HDMI. You can test sound output
independent of Mopidy by running::
aplay /usr/share/sounds/alsa/Front_Center.wav
To make the change to analog output stick, you can add the ``amixer`` command
to e.g. ``/etc/rc.local``, which will be executed when the system is
booting.
Known Issues
============
Audio Quality
-------------
The Raspberry Pi's audio quality can be sub-par through the analog output. This
is known and unlikely to be fixed as including any higher-quality hardware
would increase the cost of the board. If you experience crackling/hissing or
skipping audio, you may want to try a USB sound card. Additionally, you could
lower your default ALSA sampling rate to 22KHz, though this will lead to a
substantial decrease in sound quality.
.. _not-raspbian:
Why Not Raspbian?
-----------------
Mopidy with Spotify support is currently unavailable on the recommended
`Raspbian <http://www.raspbian.org>`_ Debian distribution that the Raspberry Pi
foundation has made available. This is due to Raspbian's hardware
floating-point support. The Raspberry Pi comes with a co-processor designed
specifically for floating-point computations (commonly called an FPU). Taking
advantage of the FPU can speed up many computations significantly over
software-emulated floating point routines. Most of Mopidy's dependencies are
open-source and have been (or can be) compiled to support the ``armhf``
architecture. However, there is one component of Mopidy's stack which is
closed-source and crucial to Mopidy's Spotify support: libspotify.
The ARM distributions of libspotify available on `Spotify's developer website
<http://developer.spotify.com>`_ are compiled for the ``armel`` architecture,
which has software floating-point support. ``armel`` and ``armhf`` software
cannot be mixed, and pyspotify links with libspotify as C extensions. Thus,
Mopidy will not run with Spotify support on ``armhf`` distributions.
If the Spotify folks ever release builds of libspotify with ``armhf`` support,
Mopidy *should* work on Raspbian.
Support
=======
If you had trouble with the above or got Mopidy working a different way on
Raspberry Pi, please send us a pull request to update this page with your new
information. As usual, the folks at ``#mopidy`` on ``irc.freenode.net`` may be
able to help with any problems encountered.

View File

@ -0,0 +1,6 @@
*********************************************
:mod:`mopidy.audio.mixers.auto` -- Auto mixer
*********************************************
.. automodule:: mopidy.audio.mixers.auto
:synopsis: Mixer element which automatically selects the real mixer to use

View File

@ -0,0 +1,6 @@
*********************************************
:mod:`mopidy.audio.mixers.fake` -- Fake mixer
*********************************************
.. automodule:: mopidy.audio.mixers.fake
:synopsis: Fake mixer for use in tests

View File

@ -0,0 +1,6 @@
*********************************************
:mod:`mopidy.audio.mixers.nad` -- NAD mixer
*********************************************
.. automodule:: mopidy.audio.mixers.nad
:synopsis: Mixer element for controlling volume on NAD amplifiers

View File

@ -1,7 +1,8 @@
.. _local-backend:
*********************************************
:mod:`mopidy.backends.local` -- Local backend
*********************************************
.. automodule:: mopidy.backends.local
:synopsis: Backend for playing music files on local storage
:members:

View File

@ -1,7 +1,8 @@
.. _spotify-backend:
*************************************************
:mod:`mopidy.backends.spotify` -- Spotify backend
*************************************************
.. automodule:: mopidy.backends.spotify
:synopsis: Backend for the Spotify music streaming service
:members:

View File

@ -4,4 +4,3 @@
.. automodule:: mopidy.frontends.lastfm
:synopsis: Last.fm scrobbler frontend
:members:

View File

@ -4,7 +4,6 @@
.. automodule:: mopidy.frontends.mpd
:synopsis: MPD server frontend
:members:
MPD dispatcher
@ -27,6 +26,7 @@ Audio output
------------
.. automodule:: mopidy.frontends.mpd.protocol.audio_output
:synopsis: MPD protocol: audio output
:members:
@ -34,6 +34,7 @@ Command list
------------
.. automodule:: mopidy.frontends.mpd.protocol.command_list
:synopsis: MPD protocol: command list
:members:
@ -41,6 +42,7 @@ Connection
----------
.. automodule:: mopidy.frontends.mpd.protocol.connection
:synopsis: MPD protocol: connection
:members:
@ -48,12 +50,15 @@ Current playlist
----------------
.. automodule:: mopidy.frontends.mpd.protocol.current_playlist
:synopsis: MPD protocol: current playlist
:members:
Music database
--------------
.. automodule:: mopidy.frontends.mpd.protocol.music_db
:synopsis: MPD protocol: music database
:members:
@ -61,6 +66,7 @@ Playback
--------
.. automodule:: mopidy.frontends.mpd.protocol.playback
:synopsis: MPD protocol: playback
:members:
@ -68,6 +74,7 @@ Reflection
----------
.. automodule:: mopidy.frontends.mpd.protocol.reflection
:synopsis: MPD protocol: reflection
:members:
@ -75,6 +82,7 @@ Status
------
.. automodule:: mopidy.frontends.mpd.protocol.status
:synopsis: MPD protocol: status
:members:
@ -82,6 +90,7 @@ Stickers
--------
.. automodule:: mopidy.frontends.mpd.protocol.stickers
:synopsis: MPD protocol: stickers
:members:
@ -89,4 +98,5 @@ Stored playlists
----------------
.. automodule:: mopidy.frontends.mpd.protocol.stored_playlists
:synopsis: MPD protocol: stored playlists
:members:

View File

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

View File

@ -19,8 +19,8 @@ You can either create the settings file yourself, or run the ``mopidy``
command, and it will create an empty settings file for you.
When you have created the settings file, open it in a text editor, and add
settings you want to change. If you want to keep the default value for setting,
you should *not* redefine it in your own settings file.
settings you want to change. If you want to keep the default value for a
setting, you should *not* redefine it in your own settings file.
A complete ``~/.config/mopidy/settings.py`` may look as simple as this::
@ -29,6 +29,8 @@ A complete ``~/.config/mopidy/settings.py`` may look as simple as this::
SPOTIFY_PASSWORD = u'mysecret'
.. _music-from-spotify:
Music from Spotify
==================
@ -39,34 +41,26 @@ Premium account's username and password into the file, like this::
SPOTIFY_PASSWORD = u'mysecret'
.. _music-from-local-storage:
Music from local storage
========================
If you want use Mopidy to play music you have locally at your machine instead
of using Spotify, you need to change the backend from the default to
:mod:`mopidy.backends.local` by adding the following line to your settings
file::
BACKENDS = (u'mopidy.backends.local.LocalBackend',)
You may also want to change some of the ``LOCAL_*`` settings. See
:mod:`mopidy.settings`, for a full list of available settings.
.. note::
Currently, Mopidy supports using Spotify *or* local storage as a music
source. We're working on using both sources simultaneously, and will
hopefully have support for this in the 0.6 release.
of or in addition to using Spotify, you need to review and maybe change some of
the ``LOCAL_*`` settings. See :mod:`mopidy.settings`, for a full list of
available settings. Then you need to generate a tag cache for your local
music...
.. _generating_a_tag_cache:
.. _generating-a-tag-cache:
Generating a tag cache
----------------------
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
:command:`mopidy-scan` was created. The program will scan your current
:attr:`mopidy.settings.LOCAL_MUSIC_PATH` and build a MPD compatible
``tag_cache``.
@ -90,7 +84,7 @@ To make a ``tag_cache`` of your local music available for Mopidy:
#. Start Mopidy, find the music library in a client, and play some local music!
.. _use_mpd_on_a_network:
.. _use-mpd-on-a-network:
Connecting from other machines on the network
=============================================
@ -119,7 +113,7 @@ file::
LASTFM_PASSWORD = u'mysecret'
.. _install_desktop_file:
.. _install-desktop-file:
Controlling Mopidy through the Ubuntu Sound Menu
================================================
@ -146,6 +140,41 @@ requirements of the `MPRIS specification <http://www.mpris.org/>`_. The
``TrackList`` and the ``Playlists`` interfaces of the spec are not supported.
Using a custom audio sink
=========================
If you have successfully installed GStreamer, and then run the ``gst-inspect``
or ``gst-inspect-0.10`` command, you should see a long listing of installed
plugins, ending in a summary line::
$ gst-inspect-0.10
... long list of installed plugins ...
Total count: 254 plugins (1 blacklist entry not shown), 1156 features
Next, you should be able to produce a audible tone by running::
gst-launch-0.10 audiotestsrc ! sudioresample ! autoaudiosink
If you cannot hear any sound when running this command, you won't hear any
sound from Mopidy either, as Mopidy by default uses GStreamer's
``autoaudiosink`` to play audio. Thus, make this work before you file a bug
against Mopidy.
If you for some reason want to use some other GStreamer audio sink than
``autoaudiosink``, you can set the setting :attr:`mopidy.settings.OUTPUT` to a
partial GStreamer pipeline description describing the GStreamer sink you want
to use.
Example of ``settings.py`` for using OSS4::
OUTPUT = u'oss4sink'
Again, this is the equivalent of the following ``gst-inspect`` command, so make
this work first::
gst-launch-0.10 audiotestsrc ! audioresample ! oss4sink
Streaming audio through a SHOUTcast/Icecast server
==================================================

View File

@ -1,62 +1,28 @@
# pylint: disable = E0611,F0401
from distutils.version import StrictVersion as SV
# pylint: enable = E0611,F0401
import sys
import warnings
import pykka
if not (2, 6) <= sys.version_info < (3,):
sys.exit(u'Mopidy requires Python >= 2.6, < 3')
sys.exit(
u'Mopidy requires Python >= 2.6, < 3, but found %s' %
'.'.join(map(str, sys.version_info[:3])))
import os
import platform
from subprocess import PIPE, Popen
if (isinstance(pykka.__version__, basestring)
and not SV('1.0') <= SV(pykka.__version__) < SV('2.0')):
sys.exit(
u'Mopidy requires Pykka >= 1.0, < 2, but found %s' % pykka.__version__)
import glib
__version__ = '0.7.3'
warnings.filterwarnings('ignore', 'could not open display')
DATA_PATH = os.path.join(str(glib.get_user_data_dir()), 'mopidy')
CACHE_PATH = os.path.join(str(glib.get_user_cache_dir()), 'mopidy')
SETTINGS_PATH = os.path.join(str(glib.get_user_config_dir()), 'mopidy')
SETTINGS_FILE = os.path.join(SETTINGS_PATH, 'settings.py')
def get_version():
try:
return get_git_version()
except EnvironmentError:
return __version__
__version__ = '0.8.1'
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_platform():
return platform.platform()
def get_python():
implementation = platform.python_implementation()
version = platform.python_version()
return u' '.join([implementation, version])
class MopidyException(Exception):
def __init__(self, message, *args, **kwargs):
super(MopidyException, self).__init__(message, *args, **kwargs)
self._message = message
@property
def message(self):
"""Reimplement message field that was deprecated in Python 2.6"""
return self._message
@message.setter
def message(self, message):
self._message = message
class SettingsError(MopidyException):
pass
class OptionalDependencyError(MopidyException):
pass
from mopidy import settings as default_settings_module
from mopidy.utils.settings import SettingsProxy

View File

@ -24,78 +24,85 @@ sys.argv[1:] = gstreamer_args
# Add ../ to the path so we can run Mopidy from a Git checkout without
# installing it on the system.
sys.path.insert(0,
os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
sys.path.insert(
0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
from mopidy import (get_version, settings, OptionalDependencyError,
SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE)
from mopidy import exceptions, settings
from mopidy.audio import Audio
from mopidy.utils import get_class
from mopidy.utils.deps import list_deps_optparse_callback
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 (exit_handler, stop_remaining_actors,
stop_actors_by_class, DebugThread)
from mopidy.utils.settings import list_settings_optparse_callback
from mopidy.core import Core
from mopidy.utils import (
deps, importing, log, path, process, settings as settings_utils,
versioning)
logger = logging.getLogger('mopidy.main')
def main():
debug_thread = DebugThread()
debug_thread = process.DebugThread()
debug_thread.start()
signal.signal(signal.SIGUSR1, debug_thread.handler)
signal.signal(signal.SIGTERM, exit_handler)
signal.signal(signal.SIGTERM, process.exit_handler)
loop = gobject.MainLoop()
options = parse_options()
try:
options = parse_options()
setup_logging(options.verbosity_level, options.save_debug_log)
log.setup_logging(options.verbosity_level, options.save_debug_log)
check_old_folders()
setup_settings(options.interactive)
setup_audio()
setup_backend()
setup_frontends()
audio = setup_audio()
backends = setup_backends(audio)
core = setup_core(audio, backends)
setup_frontends(core)
loop.run()
except SettingsError as e:
logger.error(e.message)
except exceptions.SettingsError as ex:
logger.error(ex.message)
except KeyboardInterrupt:
logger.info(u'Interrupted. Exiting...')
except Exception as e:
logger.exception(e)
except Exception as ex:
logger.exception(ex)
finally:
loop.quit()
stop_frontends()
stop_backend()
stop_core()
stop_backends()
stop_audio()
stop_remaining_actors()
process.stop_remaining_actors()
def parse_options():
parser = optparse.OptionParser(version=u'Mopidy %s' % get_version())
parser.add_option('--help-gst',
parser = optparse.OptionParser(
version=u'Mopidy %s' % versioning.get_version())
parser.add_option(
'--help-gst',
action='store_true', dest='help_gst',
help='show GStreamer help options')
parser.add_option('-i', '--interactive',
parser.add_option(
'-i', '--interactive',
action='store_true', dest='interactive',
help='ask interactively for required settings which are missing')
parser.add_option('-q', '--quiet',
parser.add_option(
'-q', '--quiet',
action='store_const', const=0, dest='verbosity_level',
help='less output (warning level)')
parser.add_option('-v', '--verbose',
parser.add_option(
'-v', '--verbose',
action='count', default=1, dest='verbosity_level',
help='more output (debug level)')
parser.add_option('--save-debug-log',
parser.add_option(
'--save-debug-log',
action='store_true', dest='save_debug_log',
help='save debug log to "./mopidy.log"')
parser.add_option('--list-settings',
action='callback', callback=list_settings_optparse_callback,
parser.add_option(
'--list-settings',
action='callback',
callback=settings_utils.list_settings_optparse_callback,
help='list current settings')
parser.add_option('--list-deps',
action='callback', callback=list_deps_optparse_callback,
parser.add_option(
'--list-deps',
action='callback', callback=deps.list_deps_optparse_callback,
help='list dependencies and their versions')
return parser.parse_args(args=mopidy_args)[0]
@ -106,50 +113,67 @@ def check_old_folders():
if not os.path.isdir(old_settings_folder):
return
logger.warning(u'Old settings folder found at %s, settings.py should be '
'moved to %s, any cache data should be deleted. See release notes '
'for further instructions.', old_settings_folder, SETTINGS_PATH)
logger.warning(
u'Old settings folder found at %s, settings.py should be moved '
u'to %s, any cache data should be deleted. See release notes for '
u'further instructions.', old_settings_folder, path.SETTINGS_PATH)
def setup_settings(interactive):
get_or_create_folder(SETTINGS_PATH)
get_or_create_folder(DATA_PATH)
get_or_create_file(SETTINGS_FILE)
path.get_or_create_folder(path.SETTINGS_PATH)
path.get_or_create_folder(path.DATA_PATH)
path.get_or_create_file(path.SETTINGS_FILE)
try:
settings.validate(interactive)
except SettingsError, e:
logger.error(e.message)
except exceptions.SettingsError as ex:
logger.error(ex.message)
sys.exit(1)
def setup_audio():
Audio.start()
return Audio.start().proxy()
def stop_audio():
stop_actors_by_class(Audio)
def setup_backend():
get_class(settings.BACKENDS[0]).start()
process.stop_actors_by_class(Audio)
def stop_backend():
stop_actors_by_class(get_class(settings.BACKENDS[0]))
def setup_backends(audio):
backends = []
for backend_class_name in settings.BACKENDS:
backend_class = importing.get_class(backend_class_name)
backend = backend_class.start(audio=audio).proxy()
backends.append(backend)
return backends
def setup_frontends():
def stop_backends():
for backend_class_name in settings.BACKENDS:
process.stop_actors_by_class(importing.get_class(backend_class_name))
def setup_core(audio, backends):
return Core.start(audio=audio, backends=backends).proxy()
def stop_core():
process.stop_actors_by_class(Core)
def setup_frontends(core):
for frontend_class_name in settings.FRONTENDS:
try:
get_class(frontend_class_name).start()
except OptionalDependencyError as e:
logger.info(u'Disabled: %s (%s)', frontend_class_name, e)
importing.get_class(frontend_class_name).start(core=core)
except exceptions.OptionalDependencyError as ex:
logger.info(u'Disabled: %s (%s)', frontend_class_name, ex)
def stop_frontends():
for frontend_class_name in settings.FRONTENDS:
try:
stop_actors_by_class(get_class(frontend_class_name))
except OptionalDependencyError:
frontend_class = importing.get_class(frontend_class_name)
process.stop_actors_by_class(frontend_class)
except exceptions.OptionalDependencyError:
pass

View File

@ -1,412 +1,3 @@
import pygst
pygst.require('0.10')
import gst
import gobject
import logging
from pykka.actor import ThreadingActor
from pykka.registry import ActorRegistry
from mopidy import settings, utils
from mopidy.backends.base import Backend
from mopidy.utils import process
# Trigger install of gst mixer plugins
from mopidy.audio import mixers
logger = logging.getLogger('mopidy.audio')
class Audio(ThreadingActor):
"""
Audio output through `GStreamer <http://gstreamer.freedesktop.org/>`_.
**Settings:**
- :attr:`mopidy.settings.OUTPUT`
- :attr:`mopidy.settings.MIXER`
- :attr:`mopidy.settings.MIXER_TRACK`
"""
def __init__(self):
super(Audio, self).__init__()
self._default_caps = gst.Caps("""
audio/x-raw-int,
endianness=(int)1234,
channels=(int)2,
width=(int)16,
depth=(int)16,
signed=(boolean)true,
rate=(int)44100""")
self._pipeline = None
self._source = None
self._uridecodebin = None
self._output = None
self._mixer = None
self._message_processor_set_up = False
def on_start(self):
try:
self._setup_pipeline()
self._setup_output()
self._setup_mixer()
self._setup_message_processor()
except gobject.GError as ex:
logger.exception(ex)
process.exit_process()
def on_stop(self):
self._teardown_message_processor()
self._teardown_mixer()
self._teardown_pipeline()
def _setup_pipeline(self):
# TODO: replace with and input bin so we simply have an input bin we
# connect to an output bin with a mixer on the side. set_uri on bin?
description = ' ! '.join([
'uridecodebin name=uri',
'audioconvert name=convert',
'audioresample name=resample',
'queue name=queue'])
logger.debug(u'Setting up base GStreamer pipeline: %s', description)
self._pipeline = gst.parse_launch(description)
self._uridecodebin = self._pipeline.get_by_name('uri')
self._uridecodebin.connect('notify::source', self._on_new_source)
self._uridecodebin.connect('pad-added', self._on_new_pad,
self._pipeline.get_by_name('queue').get_pad('sink'))
def _teardown_pipeline(self):
self._pipeline.set_state(gst.STATE_NULL)
def _setup_output(self):
try:
self._output = gst.parse_bin_from_description(
settings.OUTPUT, ghost_unconnected_pads=True)
except gobject.GError as ex:
logger.error('Failed to create output "%s": %s',
settings.OUTPUT, ex)
process.exit_process()
return
self._pipeline.add(self._output)
gst.element_link_many(self._pipeline.get_by_name('queue'),
self._output)
logger.info('Output set to %s', settings.OUTPUT)
def _setup_mixer(self):
if not settings.MIXER:
logger.info('Not setting up mixer.')
return
try:
mixerbin = gst.parse_bin_from_description(settings.MIXER,
ghost_unconnected_pads=False)
except gobject.GError as ex:
logger.warning('Failed to create mixer "%s": %s',
settings.MIXER, ex)
return
# We assume that the bin will contain a single mixer.
mixer = mixerbin.get_by_interface('GstMixer')
if not mixer:
logger.warning('Did not find any mixers in %r', settings.MIXER)
return
if mixerbin.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS:
logger.warning('Setting mixer %r to READY failed.', settings.MIXER)
return
track = self._select_mixer_track(mixer, settings.MIXER_TRACK)
if not track:
logger.warning('Could not find usable mixer track.')
return
self._mixer = (mixer, track)
logger.info('Mixer set to %s using track called %s',
mixer.get_factory().get_name(), track.label)
def _select_mixer_track(self, mixer, track_label):
# Look for track with label == MIXER_TRACK, otherwise fallback to
# master track which is also an output.
for track in mixer.list_tracks():
if track_label:
if track.label == track_label:
return track
elif track.flags & (gst.interfaces.MIXER_TRACK_MASTER |
gst.interfaces.MIXER_TRACK_OUTPUT):
return track
def _teardown_mixer(self):
if self._mixer is not None:
(mixer, track) = self._mixer
mixer.set_state(gst.STATE_NULL)
def _setup_message_processor(self):
bus = self._pipeline.get_bus()
bus.add_signal_watch()
bus.connect('message', self._on_message)
self._message_processor_set_up = True
def _teardown_message_processor(self):
if self._message_processor_set_up:
bus = self._pipeline.get_bus()
bus.remove_signal_watch()
def _on_new_source(self, element, pad):
self._source = element.get_property('source')
try:
self._source.set_property('caps', self._default_caps)
except TypeError:
pass
def _on_new_pad(self, source, pad, target_pad):
if not pad.is_linked():
if target_pad.is_linked():
target_pad.get_peer().unlink(target_pad)
pad.link(target_pad)
def _on_message(self, bus, message):
if message.type == gst.MESSAGE_EOS:
self._notify_backend_of_eos()
elif message.type == gst.MESSAGE_ERROR:
error, debug = message.parse_error()
logger.error(u'%s %s', error, debug)
self.stop_playback()
elif message.type == gst.MESSAGE_WARNING:
error, debug = message.parse_warning()
logger.warning(u'%s %s', error, debug)
def _notify_backend_of_eos(self):
backend_refs = ActorRegistry.get_by_class(Backend)
assert len(backend_refs) <= 1, 'Expected at most one running backend.'
if backend_refs:
logger.debug(u'Notifying backend of end-of-stream.')
backend_refs[0].proxy().playback.on_end_of_track()
else:
logger.debug(u'No backend to notify of end-of-stream found.')
def set_uri(self, uri):
"""
Set URI of audio to be played.
You *MUST* call :meth:`prepare_change` before calling this method.
:param uri: the URI to play
:type uri: string
"""
self._uridecodebin.set_property('uri', uri)
def emit_data(self, capabilities, data):
"""
Call this to deliver raw audio data to be played.
Note that the uri must be set to ``appsrc://`` for this to work.
:param capabilities: a GStreamer capabilities string
:type capabilities: string
:param data: raw audio data to be played
"""
caps = gst.caps_from_string(capabilities)
buffer_ = gst.Buffer(buffer(data))
buffer_.set_caps(caps)
self._source.set_property('caps', caps)
self._source.emit('push-buffer', buffer_)
def emit_end_of_stream(self):
"""
Put an end-of-stream token on the pipeline. This is typically used in
combination with :meth:`emit_data`.
We will get a GStreamer message when the stream playback reaches the
token, and can then do any end-of-stream related tasks.
"""
self._source.emit('end-of-stream')
def get_position(self):
"""
Get position in milliseconds.
:rtype: int
"""
if self._pipeline.get_state()[1] == gst.STATE_NULL:
return 0
try:
position = self._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):
"""
Set position in milliseconds.
:param position: the position in milliseconds
:type volume: int
:rtype: :class:`True` if successful, else :class:`False`
"""
self._pipeline.get_state() # block until state changes are done
handeled = self._pipeline.seek_simple(gst.Format(gst.FORMAT_TIME),
gst.SEEK_FLAG_FLUSH, position * gst.MSECOND)
self._pipeline.get_state() # block until seek is done
return handeled
def start_playback(self):
"""
Notify GStreamer that it should start playback.
:rtype: :class:`True` if successfull, else :class:`False`
"""
return self._set_state(gst.STATE_PLAYING)
def pause_playback(self):
"""
Notify GStreamer that it should pause playback.
:rtype: :class:`True` if successfull, else :class:`False`
"""
return self._set_state(gst.STATE_PAUSED)
def prepare_change(self):
"""
Notify GStreamer that we are about to change state of playback.
This function *MUST* be called before changing URIs or doing
changes like updating data that is being pushed. The reason for this
is that GStreamer will reset all its state when it changes to
:attr:`gst.STATE_READY`.
"""
return self._set_state(gst.STATE_READY)
def stop_playback(self):
"""
Notify GStreamer that is should stop playback.
:rtype: :class:`True` if successfull, else :class:`False`
"""
return self._set_state(gst.STATE_NULL)
def _set_state(self, state):
"""
Internal method for setting the raw GStreamer state.
.. digraph:: gst_state_transitions
graph [rankdir="LR"];
node [fontsize=10];
"NULL" -> "READY"
"PAUSED" -> "PLAYING"
"PAUSED" -> "READY"
"PLAYING" -> "PAUSED"
"READY" -> "NULL"
"READY" -> "PAUSED"
:param state: State to set pipeline to. One of: `gst.STATE_NULL`,
`gst.STATE_READY`, `gst.STATE_PAUSED` and `gst.STATE_PLAYING`.
:type state: :class:`gst.State`
:rtype: :class:`True` if successfull, else :class:`False`
"""
result = self._pipeline.set_state(state)
if result == gst.STATE_CHANGE_FAILURE:
logger.warning('Setting GStreamer state to %s: failed',
state.value_name)
return False
elif result == gst.STATE_CHANGE_ASYNC:
logger.debug('Setting GStreamer state to %s: async',
state.value_name)
return True
else:
logger.debug('Setting GStreamer state to %s: OK',
state.value_name)
return True
def get_volume(self):
"""
Get volume level of the installed mixer.
Example values:
0:
Muted.
100:
Max volume for given system.
:class:`None`:
No mixer present, so the volume is unknown.
:rtype: int in range [0..100] or :class:`None`
"""
if self._mixer is None:
return None
mixer, track = self._mixer
volumes = mixer.get_volume(track)
avg_volume = float(sum(volumes)) / len(volumes)
new_scale = (0, 100)
old_scale = (track.min_volume, track.max_volume)
return utils.rescale(avg_volume, old=old_scale, new=new_scale)
def set_volume(self, volume):
"""
Set volume level of the installed mixer.
:param volume: the volume in the range [0..100]
:type volume: int
:rtype: :class:`True` if successful, else :class:`False`
"""
if self._mixer is None:
return False
mixer, track = self._mixer
old_scale = (0, 100)
new_scale = (track.min_volume, track.max_volume)
volume = utils.rescale(volume, old=old_scale, new=new_scale)
volumes = (volume,) * track.num_channels
mixer.set_volume(track, volumes)
return mixer.get_volume(track) == volumes
def set_metadata(self, track):
"""
Set track metadata for currently playing song.
Only needs to be called by sources such as `appsrc` which do not
already inject tags in pipeline, e.g. when using :meth:`emit_data` to
deliver raw audio data to GStreamer.
:param track: the current track
:type track: :class:`mopidy.models.Track`
"""
taglist = gst.TagList()
artists = [a for a in (track.artists or []) if a.name]
# Default to blank data to trick shoutcast into clearing any previous
# values it might have.
taglist[gst.TAG_ARTIST] = u' '
taglist[gst.TAG_TITLE] = u' '
taglist[gst.TAG_ALBUM] = u' '
if artists:
taglist[gst.TAG_ARTIST] = u', '.join([a.name for a in artists])
if track.name:
taglist[gst.TAG_TITLE] = track.name
if track.album and track.album.name:
taglist[gst.TAG_ALBUM] = track.album.name
event = gst.event_new_tag(taglist)
self._pipeline.send_event(event)
# flake8: noqa
from .actor import Audio
from .listener import AudioListener

406
mopidy/audio/actor.py Normal file
View File

@ -0,0 +1,406 @@
import pygst
pygst.require('0.10')
import gst
import gobject
import logging
import pykka
from mopidy import settings
from mopidy.utils import process
from . import mixers
from .listener import AudioListener
logger = logging.getLogger('mopidy.audio')
mixers.register_mixers()
class Audio(pykka.ThreadingActor):
"""
Audio output through `GStreamer <http://gstreamer.freedesktop.org/>`_.
**Settings:**
- :attr:`mopidy.settings.OUTPUT`
- :attr:`mopidy.settings.MIXER`
- :attr:`mopidy.settings.MIXER_TRACK`
"""
def __init__(self):
super(Audio, self).__init__()
self._playbin = None
self._mixer = None
self._mixer_track = None
self._software_mixing = False
self._message_processor_set_up = False
def on_start(self):
try:
self._setup_playbin()
self._setup_output()
self._setup_mixer()
self._setup_message_processor()
except gobject.GError as ex:
logger.exception(ex)
process.exit_process()
def on_stop(self):
self._teardown_message_processor()
self._teardown_mixer()
self._teardown_playbin()
def _setup_playbin(self):
self._playbin = gst.element_factory_make('playbin2')
fakesink = gst.element_factory_make('fakesink')
self._playbin.set_property('video-sink', fakesink)
self._playbin.connect('notify::source', self._on_new_source)
def _on_new_source(self, element, pad):
uri = element.get_property('uri')
if not uri or not uri.startswith('appsrc://'):
return
# These caps matches the audio data provided by libspotify
default_caps = gst.Caps(
'audio/x-raw-int, endianness=(int)1234, channels=(int)2, '
'width=(int)16, depth=(int)16, signed=(boolean)true, '
'rate=(int)44100')
source = element.get_property('source')
source.set_property('caps', default_caps)
def _teardown_playbin(self):
self._playbin.set_state(gst.STATE_NULL)
def _setup_output(self):
try:
output = gst.parse_bin_from_description(
settings.OUTPUT, ghost_unconnected_pads=True)
self._playbin.set_property('audio-sink', output)
logger.info('Audio output set to "%s"', settings.OUTPUT)
except gobject.GError as ex:
logger.error(
'Failed to create audio output "%s": %s', settings.OUTPUT, ex)
process.exit_process()
def _setup_mixer(self):
if not settings.MIXER:
logger.info('Not setting up audio mixer')
return
if settings.MIXER == 'software':
self._software_mixing = True
logger.info('Audio mixer is using software mixing')
return
try:
mixerbin = gst.parse_bin_from_description(
settings.MIXER, ghost_unconnected_pads=False)
except gobject.GError as ex:
logger.warning(
'Failed to create audio mixer "%s": %s', settings.MIXER, ex)
return
# We assume that the bin will contain a single mixer.
mixer = mixerbin.get_by_interface('GstMixer')
if not mixer:
logger.warning(
'Did not find any audio mixers in "%s"', settings.MIXER)
return
if mixerbin.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS:
logger.warning(
'Setting audio mixer "%s" to READY failed', settings.MIXER)
return
track = self._select_mixer_track(mixer, settings.MIXER_TRACK)
if not track:
logger.warning('Could not find usable audio mixer track')
return
self._mixer = mixer
self._mixer_track = track
logger.info(
'Audio mixer set to "%s" using track "%s"',
mixer.get_factory().get_name(), track.label)
def _select_mixer_track(self, mixer, track_label):
# Look for track with label == MIXER_TRACK, otherwise fallback to
# master track which is also an output.
for track in mixer.list_tracks():
if track_label:
if track.label == track_label:
return track
elif track.flags & (gst.interfaces.MIXER_TRACK_MASTER |
gst.interfaces.MIXER_TRACK_OUTPUT):
return track
def _teardown_mixer(self):
if self._mixer is not None:
self._mixer.set_state(gst.STATE_NULL)
def _setup_message_processor(self):
bus = self._playbin.get_bus()
bus.add_signal_watch()
bus.connect('message', self._on_message)
self._message_processor_set_up = True
def _teardown_message_processor(self):
if self._message_processor_set_up:
bus = self._playbin.get_bus()
bus.remove_signal_watch()
def _on_message(self, bus, message):
if message.type == gst.MESSAGE_EOS:
self._trigger_reached_end_of_stream_event()
elif message.type == gst.MESSAGE_ERROR:
error, debug = message.parse_error()
logger.error(u'%s %s', error, debug)
self.stop_playback()
elif message.type == gst.MESSAGE_WARNING:
error, debug = message.parse_warning()
logger.warning(u'%s %s', error, debug)
def _trigger_reached_end_of_stream_event(self):
logger.debug(u'Triggering reached end of stream event')
AudioListener.send('reached_end_of_stream')
def set_uri(self, uri):
"""
Set URI of audio to be played.
You *MUST* call :meth:`prepare_change` before calling this method.
:param uri: the URI to play
:type uri: string
"""
self._playbin.set_property('uri', uri)
def emit_data(self, capabilities, data):
"""
Call this to deliver raw audio data to be played.
Note that the uri must be set to ``appsrc://`` for this to work.
:param capabilities: a GStreamer capabilities string
:type capabilities: string
:param data: raw audio data to be played
"""
caps = gst.caps_from_string(capabilities)
buffer_ = gst.Buffer(buffer(data))
buffer_.set_caps(caps)
source = self._playbin.get_property('source')
source.set_property('caps', caps)
source.emit('push-buffer', buffer_)
def emit_end_of_stream(self):
"""
Put an end-of-stream token on the playbin. This is typically used in
combination with :meth:`emit_data`.
We will get a GStreamer message when the stream playback reaches the
token, and can then do any end-of-stream related tasks.
"""
self._playbin.get_property('source').emit('end-of-stream')
def get_position(self):
"""
Get position in milliseconds.
:rtype: int
"""
if self._playbin.get_state()[1] == gst.STATE_NULL:
return 0
try:
position = self._playbin.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):
"""
Set position in milliseconds.
:param position: the position in milliseconds
:type position: int
:rtype: :class:`True` if successful, else :class:`False`
"""
self._playbin.get_state() # block until state changes are done
handeled = self._playbin.seek_simple(
gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH,
position * gst.MSECOND)
self._playbin.get_state() # block until seek is done
return handeled
def start_playback(self):
"""
Notify GStreamer that it should start playback.
:rtype: :class:`True` if successfull, else :class:`False`
"""
return self._set_state(gst.STATE_PLAYING)
def pause_playback(self):
"""
Notify GStreamer that it should pause playback.
:rtype: :class:`True` if successfull, else :class:`False`
"""
return self._set_state(gst.STATE_PAUSED)
def prepare_change(self):
"""
Notify GStreamer that we are about to change state of playback.
This function *MUST* be called before changing URIs or doing
changes like updating data that is being pushed. The reason for this
is that GStreamer will reset all its state when it changes to
:attr:`gst.STATE_READY`.
"""
return self._set_state(gst.STATE_READY)
def stop_playback(self):
"""
Notify GStreamer that is should stop playback.
:rtype: :class:`True` if successfull, else :class:`False`
"""
return self._set_state(gst.STATE_NULL)
def _set_state(self, state):
"""
Internal method for setting the raw GStreamer state.
.. digraph:: gst_state_transitions
graph [rankdir="LR"];
node [fontsize=10];
"NULL" -> "READY"
"PAUSED" -> "PLAYING"
"PAUSED" -> "READY"
"PLAYING" -> "PAUSED"
"READY" -> "NULL"
"READY" -> "PAUSED"
:param state: State to set playbin to. One of: `gst.STATE_NULL`,
`gst.STATE_READY`, `gst.STATE_PAUSED` and `gst.STATE_PLAYING`.
:type state: :class:`gst.State`
:rtype: :class:`True` if successfull, else :class:`False`
"""
result = self._playbin.set_state(state)
if result == gst.STATE_CHANGE_FAILURE:
logger.warning(
'Setting GStreamer state to %s failed', state.value_name)
return False
elif result == gst.STATE_CHANGE_ASYNC:
logger.debug(
'Setting GStreamer state to %s is async', state.value_name)
return True
else:
logger.debug(
'Setting GStreamer state to %s is OK', state.value_name)
return True
def get_volume(self):
"""
Get volume level of the installed mixer.
Example values:
0:
Muted.
100:
Max volume for given system.
:class:`None`:
No mixer present, so the volume is unknown.
:rtype: int in range [0..100] or :class:`None`
"""
if self._software_mixing:
return round(self._playbin.get_property('volume') * 100)
if self._mixer is None:
return None
volumes = self._mixer.get_volume(self._mixer_track)
avg_volume = float(sum(volumes)) / len(volumes)
new_scale = (0, 100)
old_scale = (
self._mixer_track.min_volume, self._mixer_track.max_volume)
return self._rescale(avg_volume, old=old_scale, new=new_scale)
def set_volume(self, volume):
"""
Set volume level of the installed mixer.
:param volume: the volume in the range [0..100]
:type volume: int
:rtype: :class:`True` if successful, else :class:`False`
"""
if self._software_mixing:
self._playbin.set_property('volume', volume / 100.0)
return True
if self._mixer is None:
return False
old_scale = (0, 100)
new_scale = (
self._mixer_track.min_volume, self._mixer_track.max_volume)
volume = self._rescale(volume, old=old_scale, new=new_scale)
volumes = (volume,) * self._mixer_track.num_channels
self._mixer.set_volume(self._mixer_track, volumes)
return self._mixer.get_volume(self._mixer_track) == volumes
def _rescale(self, value, old=None, new=None):
"""Convert value between scales."""
new_min, new_max = new
old_min, old_max = old
scaling = float(new_max - new_min) / (old_max - old_min)
return int(round(scaling * (value - old_min) + new_min))
def set_metadata(self, track):
"""
Set track metadata for currently playing song.
Only needs to be called by sources such as `appsrc` which do not
already inject tags in playbin, e.g. when using :meth:`emit_data` to
deliver raw audio data to GStreamer.
:param track: the current track
:type track: :class:`mopidy.models.Track`
"""
taglist = gst.TagList()
artists = [a for a in (track.artists or []) if a.name]
# Default to blank data to trick shoutcast into clearing any previous
# values it might have.
taglist[gst.TAG_ARTIST] = u' '
taglist[gst.TAG_TITLE] = u' '
taglist[gst.TAG_ALBUM] = u' '
if artists:
taglist[gst.TAG_ARTIST] = u', '.join([a.name for a in artists])
if track.name:
taglist[gst.TAG_TITLE] = track.name
if track.album and track.album.name:
taglist[gst.TAG_ALBUM] = track.album.name
event = gst.event_new_tag(taglist)
self._playbin.send_event(event)

28
mopidy/audio/listener.py Normal file
View File

@ -0,0 +1,28 @@
import pykka
class AudioListener(object):
"""
Marker interface for recipients of events sent by the audio actor.
Any Pykka actor that mixes in this class will receive calls to the methods
defined here when the corresponding events happen in the core actor. This
interface is used both for looking up what actors to notify of the events,
and for providing default implementations for those listeners that are not
interested in all events.
"""
@staticmethod
def send(event, **kwargs):
"""Helper to allow calling of audio listener events"""
listeners = pykka.ActorRegistry.get_by_class(AudioListener)
for listener in listeners:
getattr(listener.proxy(), event)(**kwargs)
def reached_end_of_stream(self):
"""
Called whenever the end of the audio stream is reached.
*MAY* be implemented by actor.
"""
pass

View File

@ -3,41 +3,18 @@ pygst.require('0.10')
import gst
import gobject
def create_track(label, initial_volume, min_volume, max_volume,
num_channels, flags):
class Track(gst.interfaces.MixerTrack):
def __init__(self):
super(Track, self).__init__()
self.volumes = (initial_volume,) * self.num_channels
@gobject.property
def label(self):
return label
@gobject.property
def min_volume(self):
return min_volume
@gobject.property
def max_volume(self):
return max_volume
@gobject.property
def num_channels(self):
return num_channels
@gobject.property
def flags(self):
return flags
return Track()
# Import all mixers so that they are registered with GStreamer.
#
# Keep these imports at the bottom of the file to avoid cyclic import problems
# when mixers use the above code.
from .auto import AutoAudioMixer
from .fake import FakeMixer
from .nad import NadMixer
def register_mixer(mixer_class):
gobject.type_register(mixer_class)
gst.element_register(
mixer_class, mixer_class.__name__.lower(), gst.RANK_MARGINAL)
def register_mixers():
register_mixer(AutoAudioMixer)
register_mixer(FakeMixer)
register_mixer(NadMixer)

View File

@ -1,6 +1,19 @@
"""Mixer element that automatically selects the real mixer to use.
This is Mopidy's default mixer.
**Dependencies:**
- None
**Settings:**
- If this wasn't the default, you would set :attr:`mopidy.settings.MIXER`
to ``autoaudiomixer`` to use this mixer.
"""
import pygst
pygst.require('0.10')
import gobject
import gst
import logging
@ -10,16 +23,19 @@ logger = logging.getLogger('mopidy.audio.mixers.auto')
# TODO: we might want to add some ranking to the mixers we know about?
class AutoAudioMixer(gst.Bin):
__gstdetails__ = ('AutoAudioMixer',
'Mixer',
'Element automatically selects a mixer.',
'Thomas Adamcik')
__gstdetails__ = (
'AutoAudioMixer',
'Mixer',
'Element automatically selects a mixer.',
'Mopidy')
def __init__(self):
gst.Bin.__init__(self)
mixer = self._find_mixer()
if mixer:
# pylint: disable=E1101
self.add(mixer)
# pylint: enable=E1101
logger.debug('AutoAudioMixer chose: %s', mixer.get_name())
else:
logger.debug('AutoAudioMixer did not find any usable mixers')
@ -66,7 +82,3 @@ class AutoAudioMixer(gst.Bin):
if track.flags & flags:
return True
return False
gobject.type_register(AutoAudioMixer)
gst.element_register(AutoAudioMixer, 'autoaudiomixer', gst.RANK_MARGINAL)

View File

@ -1,31 +1,39 @@
"""Fake mixer for use in tests.
**Dependencies:**
- None
**Settings:**
- Set :attr:`mopidy.settings.MIXER` to ``fakemixer`` to use this mixer.
"""
import pygst
pygst.require('0.10')
import gobject
import gst
from mopidy.audio.mixers import create_track
from . import utils
class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer):
__gstdetails__ = ('FakeMixer',
'Mixer',
'Fake mixer for use in tests.',
'Thomas Adamcik')
__gstdetails__ = (
'FakeMixer',
'Mixer',
'Fake mixer for use in tests.',
'Mopidy')
track_label = gobject.property(type=str, default='Master')
track_initial_volume = gobject.property(type=int, default=0)
track_min_volume = gobject.property(type=int, default=0)
track_max_volume = gobject.property(type=int, default=100)
track_num_channels = gobject.property(type=int, default=2)
track_flags = gobject.property(type=int,
default=(gst.interfaces.MIXER_TRACK_MASTER |
gst.interfaces.MIXER_TRACK_OUTPUT))
def __init__(self):
gst.Element.__init__(self)
track_flags = gobject.property(type=int, default=(
gst.interfaces.MIXER_TRACK_MASTER | gst.interfaces.MIXER_TRACK_OUTPUT))
def list_tracks(self):
track = create_track(
track = utils.create_track(
self.track_label,
self.track_initial_volume,
self.track_min_volume,
@ -42,7 +50,3 @@ class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer):
def set_record(self, track, record):
pass
gobject.type_register(FakeMixer)
gst.element_register(FakeMixer, 'fakemixer', gst.RANK_MARGINAL)

View File

@ -1,3 +1,50 @@
"""Mixer that controls volume using a NAD amplifier.
**Dependencies:**
- pyserial (python-serial in Debian/Ubuntu)
- The NAD amplifier must be connected to the machine running Mopidy using a
serial cable.
**Settings:**
- Set :attr:`mopidy.settings.MIXER` to ``nadmixer`` to use it. You probably
also needs to add some properties to the ``MIXER`` setting.
Supported properties includes:
``port``:
The serial device to use, defaults to ``/dev/ttyUSB0``. This must be
set correctly for the mixer to work.
``source``:
The source that should be selected on the amplifier, like ``aux``,
``disc``, ``tape``, ``tuner``, etc. Leave unset if you don't want the
mixer to change it for you.
``speakers-a``:
Set to ``on`` or ``off`` if you want the mixer to make sure that
speaker set A is turned on or off. Leave unset if you don't want the
mixer to change it for you.
``speakers-b``:
See ``speakers-a``.
Configuration examples::
# Minimum configuration, if the amplifier is available at /dev/ttyUSB0
MIXER = u'nadmixer'
# Minimum configuration, if the amplifier is available elsewhere
MIXER = u'nadmixer port=/dev/ttyUSB3'
# Full configuration
MIXER = (
u'nadmixer port=/dev/ttyUSB0 '
u'source=aux speakers-a=on speakers-b=off')
"""
import logging
import pygst
@ -8,41 +55,41 @@ import gst
try:
import serial
except ImportError:
serial = None
serial = None # noqa
from pykka.actor import ThreadingActor
import pykka
from mopidy.audio.mixers import create_track
from . import utils
logger = logging.getLogger('mopidy.audio.mixers.nad')
class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer):
__gstdetails__ = ('NadMixer',
'Mixer',
'Mixer to control NAD amplifiers using a serial link',
'Stein Magnus Jodal')
__gstdetails__ = (
'NadMixer',
'Mixer',
'Mixer to control NAD amplifiers using a serial link',
'Mopidy')
port = gobject.property(type=str, default='/dev/ttyUSB0')
source = gobject.property(type=str)
speakers_a = gobject.property(type=str)
speakers_b = gobject.property(type=str)
def __init__(self):
gst.Element.__init__(self)
self._volume_cache = 0
self._nad_talker = None
_volume_cache = 0
_nad_talker = None
def list_tracks(self):
track = create_track(
track = utils.create_track(
label='Master',
initial_volume=0,
min_volume=0,
max_volume=100,
num_channels=1,
flags=(gst.interfaces.MIXER_TRACK_MASTER |
gst.interfaces.MIXER_TRACK_OUTPUT))
flags=(
gst.interfaces.MIXER_TRACK_MASTER |
gst.interfaces.MIXER_TRACK_OUTPUT))
return [track]
def get_volume(self, track):
@ -74,13 +121,9 @@ class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer):
).proxy()
gobject.type_register(NadMixer)
gst.element_register(NadMixer, 'nadmixer', gst.RANK_MARGINAL)
class NadTalker(ThreadingActor):
class NadTalker(pykka.ThreadingActor):
"""
Independent thread which does the communication with the NAD amplifier
Independent thread which does the communication with the NAD amplifier.
Since the communication is done in an independent thread, Mopidy won't
block other requests while doing rather time consuming work like
@ -121,8 +164,7 @@ class NadTalker(ThreadingActor):
self._set_device_to_known_state()
def _open_connection(self):
logger.info(u'NAD amplifier: Connecting through "%s"',
self.port)
logger.info(u'NAD amplifier: Connecting through "%s"', self.port)
self._device = serial.Serial(
port=self.port,
baudrate=self.BAUDRATE,
@ -137,7 +179,7 @@ class NadTalker(ThreadingActor):
self._select_speakers()
self._select_input_source()
self.mute(False)
self._calibrate_volume()
self.calibrate_volume()
def _get_device_model(self):
model = self._ask_device('Main.Model')
@ -163,14 +205,21 @@ class NadTalker(ThreadingActor):
else:
self._check_and_set('Main.Mute', 'Off')
def _calibrate_volume(self):
def calibrate_volume(self, current_nad_volume=None):
# The NAD C 355BEE amplifier has 40 different volume levels. We have no
# way of asking on which level we are. Thus, we must calibrate the
# mixer by decreasing the volume 39 times.
logger.info(u'NAD amplifier: Calibrating by setting volume to 0')
self._nad_volume = self.VOLUME_LEVELS
self.set_volume(0)
logger.info(u'NAD amplifier: Done calibrating')
if current_nad_volume is None:
current_nad_volume = self.VOLUME_LEVELS
if current_nad_volume == self.VOLUME_LEVELS:
logger.info(u'NAD amplifier: Calibrating by setting volume to 0')
self._nad_volume = current_nad_volume
if self._decrease_volume():
current_nad_volume -= 1
if current_nad_volume == 0:
logger.info(u'NAD amplifier: Done calibrating')
else:
self.actor_ref.proxy().calibrate_volume(current_nad_volume)
def set_volume(self, volume):
# Increase or decrease the amplifier volume until it matches the given
@ -200,11 +249,13 @@ class NadTalker(ThreadingActor):
for attempt in range(1, 4):
if self._ask_device(key) == value:
return
logger.info(u'NAD amplifier: Setting "%s" to "%s" (attempt %d/3)',
logger.info(
u'NAD amplifier: Setting "%s" to "%s" (attempt %d/3)',
key, value, attempt)
self._command_device(key, value)
if self._ask_device(key) != value:
logger.info(u'NAD amplifier: Gave up on setting "%s" to "%s"',
logger.info(
u'NAD amplifier: Gave up on setting "%s" to "%s"',
key, value)
def _ask_device(self, key):

View File

@ -0,0 +1,35 @@
import pygst
pygst.require('0.10')
import gst
import gobject
def create_track(label, initial_volume, min_volume, max_volume,
num_channels, flags):
class Track(gst.interfaces.MixerTrack):
def __init__(self):
super(Track, self).__init__()
self.volumes = (initial_volume,) * self.num_channels
@gobject.property
def label(self):
return label
@gobject.property
def min_volume(self):
return min_volume
@gobject.property
def max_volume(self):
return max_volume
@gobject.property
def num_channels(self):
return num_channels
@gobject.property
def flags(self):
return flags
return Track()

215
mopidy/backends/base.py Normal file
View File

@ -0,0 +1,215 @@
import copy
class Backend(object):
#: Actor proxy to an instance of :class:`mopidy.audio.Audio`.
#:
#: Should be passed to the backend constructor as the kwarg ``audio``,
#: which will then set this field.
audio = None
#: The library provider. An instance of
# :class:`mopidy.backends.base.BaseLibraryProvider`.
library = None
#: The playback provider. An instance of
#: :class:`mopidy.backends.base.BasePlaybackProvider`.
playback = None
#: The stored playlists provider. An instance of
#: :class:`mopidy.backends.base.BaseStoredPlaylistsProvider`.
stored_playlists = None
#: List of URI schemes this backend can handle.
uri_schemes = []
class BaseLibraryProvider(object):
"""
:param backend: backend the controller is a part of
:type backend: :class:`mopidy.backends.base.Backend`
"""
pykka_traversable = True
def __init__(self, backend):
self.backend = backend
def find_exact(self, **query):
"""
See :meth:`mopidy.core.LibraryController.find_exact`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def lookup(self, uri):
"""
See :meth:`mopidy.core.LibraryController.lookup`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def refresh(self, uri=None):
"""
See :meth:`mopidy.core.LibraryController.refresh`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def search(self, **query):
"""
See :meth:`mopidy.core.LibraryController.search`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
class BasePlaybackProvider(object):
"""
:param audio: the audio actor
:type audio: actor proxy to an instance of :class:`mopidy.audio.Audio`
:param backend: the backend
:type backend: :class:`mopidy.backends.base.Backend`
"""
pykka_traversable = True
def __init__(self, audio, backend):
self.audio = audio
self.backend = backend
def pause(self):
"""
Pause playback.
*MAY be reimplemented by subclass.*
:rtype: :class:`True` if successful, else :class:`False`
"""
return self.audio.pause_playback().get()
def play(self, track):
"""
Play given track.
*MAY be reimplemented by subclass.*
:param track: the track to play
:type track: :class:`mopidy.models.Track`
:rtype: :class:`True` if successful, else :class:`False`
"""
self.audio.prepare_change()
self.audio.set_uri(track.uri).get()
return self.audio.start_playback().get()
def resume(self):
"""
Resume playback at the same time position playback was paused.
*MAY be reimplemented by subclass.*
:rtype: :class:`True` if successful, else :class:`False`
"""
return self.audio.start_playback().get()
def seek(self, time_position):
"""
Seek to a given time position.
*MAY be reimplemented by subclass.*
:param time_position: time position in milliseconds
:type time_position: int
:rtype: :class:`True` if successful, else :class:`False`
"""
return self.audio.set_position(time_position).get()
def stop(self):
"""
Stop playback.
*MAY be reimplemented by subclass.*
:rtype: :class:`True` if successful, else :class:`False`
"""
return self.audio.stop_playback().get()
def get_time_position(self):
"""
Get the current time position in milliseconds.
*MAY be reimplemented by subclass.*
:rtype: int
"""
return self.audio.get_position().get()
class BaseStoredPlaylistsProvider(object):
"""
:param backend: backend the controller is a part of
:type backend: :class:`mopidy.backends.base.Backend`
"""
pykka_traversable = True
def __init__(self, backend):
self.backend = backend
self._playlists = []
@property
def playlists(self):
"""
Currently stored playlists.
Read/write. List of :class:`mopidy.models.Playlist`.
"""
return copy.copy(self._playlists)
@playlists.setter # noqa
def playlists(self, playlists):
self._playlists = playlists
def create(self, name):
"""
See :meth:`mopidy.core.StoredPlaylistsController.create`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def delete(self, uri):
"""
See :meth:`mopidy.core.StoredPlaylistsController.delete`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def lookup(self, uri):
"""
See :meth:`mopidy.core.StoredPlaylistsController.lookup`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def refresh(self):
"""
See :meth:`mopidy.core.StoredPlaylistsController.refresh`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def save(self, playlist):
"""
See :meth:`mopidy.core.StoredPlaylistsController.save`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError

View File

@ -1,24 +0,0 @@
from .library import BaseLibraryProvider
from .playback import BasePlaybackProvider
from .stored_playlists import BaseStoredPlaylistsProvider
class Backend(object):
#: The current playlist controller. An instance of
#: :class:`mopidy.backends.base.CurrentPlaylistController`.
current_playlist = None
#: The library controller. An instance of
# :class:`mopidy.backends.base.LibraryController`.
library = None
#: The playback controller. An instance of
#: :class:`mopidy.backends.base.PlaybackController`.
playback = None
#: The stored playlists controller. An instance of
#: :class:`mopidy.backends.base.StoredPlaylistsController`.
stored_playlists = None
#: List of URI schemes this backend can handle.
uri_schemes = []

View File

@ -1,42 +0,0 @@
class BaseLibraryProvider(object):
"""
:param backend: backend the controller is a part of
:type backend: :class:`mopidy.backends.base.Backend`
"""
pykka_traversable = True
def __init__(self, backend):
self.backend = backend
def find_exact(self, **query):
"""
See :meth:`mopidy.backends.base.LibraryController.find_exact`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def lookup(self, uri):
"""
See :meth:`mopidy.backends.base.LibraryController.lookup`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def refresh(self, uri=None):
"""
See :meth:`mopidy.backends.base.LibraryController.refresh`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def search(self, **query):
"""
See :meth:`mopidy.backends.base.LibraryController.search`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError

View File

@ -1,87 +0,0 @@
class BasePlaybackProvider(object):
"""
:param backend: the backend
:type backend: :class:`mopidy.backends.base.Backend`
"""
pykka_traversable = True
def __init__(self, backend):
self.backend = backend
def pause(self):
"""
Pause playback.
*MAY be reimplemented by subclass.*
:rtype: :class:`True` if successful, else :class:`False`
"""
return self.backend.audio.pause_playback().get()
def play(self, track):
"""
Play given track.
*MAY be reimplemented by subclass.*
:param track: the track to play
:type track: :class:`mopidy.models.Track`
:rtype: :class:`True` if successful, else :class:`False`
"""
self.backend.audio.prepare_change()
self.backend.audio.set_uri(track.uri).get()
return self.backend.audio.start_playback().get()
def resume(self):
"""
Resume playback at the same time position playback was paused.
*MAY be reimplemented by subclass.*
:rtype: :class:`True` if successful, else :class:`False`
"""
return self.backend.audio.start_playback().get()
def seek(self, time_position):
"""
Seek to a given time position.
*MAY be reimplemented by subclass.*
:param time_position: time position in milliseconds
:type time_position: int
:rtype: :class:`True` if successful, else :class:`False`
"""
return self.backend.audio.set_position(time_position).get()
def stop(self):
"""
Stop playback.
*MAY be reimplemented by subclass.*
:rtype: :class:`True` if successful, else :class:`False`
"""
return self.backend.audio.stop_playback().get()
def get_volume(self):
"""
Get current volume
*MAY be reimplemented by subclass.*
:rtype: int [0..100] or :class:`None`
"""
return self.backend.audio.get_volume().get()
def set_volume(self, volume):
"""
Get current volume
*MAY be reimplemented by subclass.*
:param: volume
:type volume: int [0..100]
"""
self.backend.audio.set_volume(volume)

View File

@ -1,75 +0,0 @@
from copy import copy
class BaseStoredPlaylistsProvider(object):
"""
:param backend: backend the controller is a part of
:type backend: :class:`mopidy.backends.base.Backend`
"""
pykka_traversable = True
def __init__(self, backend):
self.backend = backend
self._playlists = []
@property
def playlists(self):
"""
Currently stored playlists.
Read/write. List of :class:`mopidy.models.Playlist`.
"""
return copy(self._playlists)
@playlists.setter
def playlists(self, playlists):
self._playlists = playlists
def create(self, name):
"""
See :meth:`mopidy.backends.base.StoredPlaylistsController.create`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def delete(self, playlist):
"""
See :meth:`mopidy.backends.base.StoredPlaylistsController.delete`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def lookup(self, uri):
"""
See :meth:`mopidy.backends.base.StoredPlaylistsController.lookup`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def refresh(self):
"""
See :meth:`mopidy.backends.base.StoredPlaylistsController.refresh`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def rename(self, playlist, new_name):
"""
See :meth:`mopidy.backends.base.StoredPlaylistsController.rename`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def save(self, playlist):
"""
See :meth:`mopidy.backends.base.StoredPlaylistsController.save`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError

View File

@ -1,34 +1,32 @@
from pykka.actor import ThreadingActor
"""A dummy backend for use in tests.
This backend implements the backend API in the simplest way possible. It is
used in tests of the frontends.
The backend handles URIs starting with ``dummy:``.
**Dependencies:**
- None
**Settings:**
- None
"""
import pykka
from mopidy import core
from mopidy.backends import base
from mopidy.models import Playlist
class DummyBackend(ThreadingActor, base.Backend):
"""
A backend which implements the backend API in the simplest way possible.
Used in tests of the frontends.
class DummyBackend(pykka.ThreadingActor, base.Backend):
def __init__(self, audio):
super(DummyBackend, self).__init__()
Handles URIs starting with ``dummy:``.
"""
def __init__(self, *args, **kwargs):
super(DummyBackend, self).__init__(*args, **kwargs)
self.current_playlist = core.CurrentPlaylistController(backend=self)
library_provider = DummyLibraryProvider(backend=self)
self.library = core.LibraryController(backend=self,
provider=library_provider)
playback_provider = DummyPlaybackProvider(backend=self)
self.playback = core.PlaybackController(backend=self,
provider=playback_provider)
stored_playlists_provider = DummyStoredPlaylistsProvider(backend=self)
self.stored_playlists = core.StoredPlaylistsController(backend=self,
provider=stored_playlists_provider)
self.library = DummyLibraryProvider(backend=self)
self.playback = DummyPlaybackProvider(audio=audio, backend=self)
self.stored_playlists = DummyStoredPlaylistsProvider(backend=self)
self.uri_schemes = [u'dummy']
@ -56,29 +54,28 @@ class DummyLibraryProvider(base.BaseLibraryProvider):
class DummyPlaybackProvider(base.BasePlaybackProvider):
def __init__(self, *args, **kwargs):
super(DummyPlaybackProvider, self).__init__(*args, **kwargs)
self._volume = None
self._time_position = 0
def pause(self):
return True
def play(self, track):
"""Pass None as track to force failure"""
return track is not None
"""Pass a track with URI 'dummy:error' to force failure"""
self._time_position = 0
return track.uri != 'dummy:error'
def resume(self):
return True
def seek(self, time_position):
self._time_position = time_position
return True
def stop(self):
return True
def get_volume(self):
return self._volume
def set_volume(self, volume):
self._volume = volume
def get_time_position(self):
return self._time_position
class DummyStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider):

View File

@ -1,251 +1,24 @@
import glob
import glib
import logging
import os
import shutil
"""A backend for playing music from a local music archive.
from pykka.actor import ThreadingActor
from pykka.registry import ActorRegistry
This backend handles URIs starting with ``file:``.
from mopidy import audio, core, settings, DATA_PATH
from mopidy.backends import base
from mopidy.models import Playlist, Track, Album
See :ref:`music-from-local-storage` for further instructions on using this
backend.
from .translator import parse_m3u, parse_mpd_tag_cache
**Issues:**
logger = logging.getLogger(u'mopidy.backends.local')
https://github.com/mopidy/mopidy/issues?labels=Local+backend
DEFAULT_PLAYLIST_PATH = os.path.join(DATA_PATH, 'playlists')
DEFAULT_TAG_CACHE_FILE = os.path.join(DATA_PATH, 'tag_cache')
DEFAULT_MUSIC_PATH = str(glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC))
**Dependencies:**
if not DEFAULT_MUSIC_PATH or DEFAULT_MUSIC_PATH == os.path.expanduser(u'~'):
DEFAULT_MUSIC_PATH = os.path.expanduser(u'~/music')
- None
**Settings:**
class LocalBackend(ThreadingActor, base.Backend):
"""
A backend for playing music from a local music archive.
- :attr:`mopidy.settings.LOCAL_MUSIC_PATH`
- :attr:`mopidy.settings.LOCAL_PLAYLIST_PATH`
- :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE`
"""
**Dependencies:**
- None
**Settings:**
- :attr:`mopidy.settings.LOCAL_MUSIC_PATH`
- :attr:`mopidy.settings.LOCAL_PLAYLIST_PATH`
- :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE`
"""
def __init__(self, *args, **kwargs):
super(LocalBackend, self).__init__(*args, **kwargs)
self.current_playlist = core.CurrentPlaylistController(backend=self)
library_provider = LocalLibraryProvider(backend=self)
self.library = core.LibraryController(backend=self,
provider=library_provider)
playback_provider = base.BasePlaybackProvider(backend=self)
self.playback = LocalPlaybackController(backend=self,
provider=playback_provider)
stored_playlists_provider = LocalStoredPlaylistsProvider(backend=self)
self.stored_playlists = core.StoredPlaylistsController(backend=self,
provider=stored_playlists_provider)
self.uri_schemes = [u'file']
self.audio = None
def on_start(self):
audio_refs = ActorRegistry.get_by_class(audio.Audio)
assert len(audio_refs) == 1, \
'Expected exactly one running Audio instance.'
self.audio = audio_refs[0].proxy()
class LocalPlaybackController(core.PlaybackController):
def __init__(self, *args, **kwargs):
super(LocalPlaybackController, self).__init__(*args, **kwargs)
# XXX Why do we call stop()? Is it to set GStreamer state to 'READY'?
self.stop()
@property
def time_position(self):
return self.backend.audio.get_position().get()
class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider):
def __init__(self, *args, **kwargs):
super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs)
self._folder = settings.LOCAL_PLAYLIST_PATH or DEFAULT_PLAYLIST_PATH
self.refresh()
def lookup(self, uri):
pass # TODO
def refresh(self):
playlists = []
logger.info('Loading playlists from %s', self._folder)
for m3u in glob.glob(os.path.join(self._folder, '*.m3u')):
name = os.path.basename(m3u)[:-len('.m3u')]
tracks = []
for uri in parse_m3u(m3u):
try:
tracks.append(self.backend.library.lookup(uri))
except LookupError, e:
logger.error('Playlist item could not be added: %s', e)
playlist = Playlist(tracks=tracks, name=name)
# FIXME playlist name needs better handling
# FIXME tracks should come from lib. lookup
playlists.append(playlist)
self.playlists = playlists
def create(self, name):
playlist = Playlist(name=name)
self.save(playlist)
return playlist
def delete(self, playlist):
if playlist not in self._playlists:
return
self._playlists.remove(playlist)
filename = os.path.join(self._folder, playlist.name + '.m3u')
if os.path.exists(filename):
os.remove(filename)
def rename(self, playlist, name):
if playlist not in self._playlists:
return
src = os.path.join(self._folder, playlist.name + '.m3u')
dst = os.path.join(self._folder, name + '.m3u')
renamed = playlist.copy(name=name)
index = self._playlists.index(playlist)
self._playlists[index] = renamed
shutil.move(src, dst)
def save(self, playlist):
file_path = os.path.join(self._folder, playlist.name + '.m3u')
# FIXME this should be a save_m3u function, not inside save
with open(file_path, 'w') as file_handle:
for track in playlist.tracks:
if track.uri.startswith('file://'):
file_handle.write(track.uri[len('file://'):] + '\n')
else:
file_handle.write(track.uri + '\n')
self._playlists.append(playlist)
class LocalLibraryProvider(base.BaseLibraryProvider):
def __init__(self, *args, **kwargs):
super(LocalLibraryProvider, self).__init__(*args, **kwargs)
self._uri_mapping = {}
self.refresh()
def refresh(self, uri=None):
tag_cache = settings.LOCAL_TAG_CACHE_FILE or DEFAULT_TAG_CACHE_FILE
music_folder = settings.LOCAL_MUSIC_PATH or DEFAULT_MUSIC_PATH
tracks = parse_mpd_tag_cache(tag_cache, music_folder)
logger.info('Loading tracks in %s from %s', music_folder, tag_cache)
for track in tracks:
self._uri_mapping[track.uri] = track
def lookup(self, uri):
try:
return self._uri_mapping[uri]
except KeyError:
logger.debug(u'Failed to lookup "%s"', uri)
return None
def find_exact(self, **query):
self._validate_query(query)
result_tracks = self._uri_mapping.values()
for (field, values) in query.iteritems():
if not hasattr(values, '__iter__'):
values = [values]
# FIXME this is bound to be slow for large libraries
for value in values:
q = value.strip()
track_filter = lambda t: q == t.name
album_filter = lambda t: q == getattr(t, 'album', Album()).name
artist_filter = lambda t: filter(
lambda a: q == a.name, t.artists)
uri_filter = lambda t: q == t.uri
any_filter = lambda t: (track_filter(t) or album_filter(t) or
artist_filter(t) or uri_filter(t))
if field == 'track':
result_tracks = filter(track_filter, result_tracks)
elif field == 'album':
result_tracks = filter(album_filter, result_tracks)
elif field == 'artist':
result_tracks = filter(artist_filter, result_tracks)
elif field == 'uri':
result_tracks = filter(uri_filter, result_tracks)
elif field == 'any':
result_tracks = filter(any_filter, result_tracks)
else:
raise LookupError('Invalid lookup field: %s' % field)
return Playlist(tracks=result_tracks)
def search(self, **query):
self._validate_query(query)
result_tracks = self._uri_mapping.values()
for (field, values) in query.iteritems():
if not hasattr(values, '__iter__'):
values = [values]
# FIXME this is bound to be slow for large libraries
for value in values:
q = value.strip().lower()
track_filter = lambda t: q in t.name.lower()
album_filter = lambda t: q in getattr(
t, 'album', Album()).name.lower()
artist_filter = lambda t: filter(
lambda a: q in a.name.lower(), t.artists)
uri_filter = lambda t: q in t.uri.lower()
any_filter = lambda t: track_filter(t) or album_filter(t) or \
artist_filter(t) or uri_filter(t)
if field == 'track':
result_tracks = filter(track_filter, result_tracks)
elif field == 'album':
result_tracks = filter(album_filter, result_tracks)
elif field == 'artist':
result_tracks = filter(artist_filter, result_tracks)
elif field == 'uri':
result_tracks = filter(uri_filter, result_tracks)
elif field == 'any':
result_tracks = filter(any_filter, result_tracks)
else:
raise LookupError('Invalid lookup field: %s' % field)
return Playlist(tracks=result_tracks)
def _validate_query(self, query):
for (_, values) in query.iteritems():
if not values:
raise LookupError('Missing query')
for value in values:
if not value:
raise LookupError('Missing query')
# flake8: noqa
from .actor import LocalBackend

View File

@ -0,0 +1,21 @@
import logging
import pykka
from mopidy.backends import base
from .library import LocalLibraryProvider
from .stored_playlists import LocalStoredPlaylistsProvider
logger = logging.getLogger(u'mopidy.backends.local')
class LocalBackend(pykka.ThreadingActor, base.Backend):
def __init__(self, audio):
super(LocalBackend, self).__init__()
self.library = LocalLibraryProvider(backend=self)
self.playback = base.BasePlaybackProvider(audio=audio, backend=self)
self.stored_playlists = LocalStoredPlaylistsProvider(backend=self)
self.uri_schemes = [u'file']

View File

@ -0,0 +1,110 @@
import logging
from mopidy import settings
from mopidy.backends import base
from mopidy.models import Playlist, Album
from .translator import parse_mpd_tag_cache
logger = logging.getLogger(u'mopidy.backends.local')
class LocalLibraryProvider(base.BaseLibraryProvider):
def __init__(self, *args, **kwargs):
super(LocalLibraryProvider, self).__init__(*args, **kwargs)
self._uri_mapping = {}
self.refresh()
def refresh(self, uri=None):
tracks = parse_mpd_tag_cache(
settings.LOCAL_TAG_CACHE_FILE, settings.LOCAL_MUSIC_PATH)
logger.info(
'Loading tracks from %s using %s',
settings.LOCAL_MUSIC_PATH, settings.LOCAL_TAG_CACHE_FILE)
for track in tracks:
self._uri_mapping[track.uri] = track
def lookup(self, uri):
try:
return self._uri_mapping[uri]
except KeyError:
logger.debug(u'Failed to lookup %r', uri)
return None
def find_exact(self, **query):
self._validate_query(query)
result_tracks = self._uri_mapping.values()
for (field, values) in query.iteritems():
if not hasattr(values, '__iter__'):
values = [values]
# FIXME this is bound to be slow for large libraries
for value in values:
q = value.strip()
track_filter = lambda t: q == t.name
album_filter = lambda t: q == getattr(t, 'album', Album()).name
artist_filter = lambda t: filter(
lambda a: q == a.name, t.artists)
uri_filter = lambda t: q == t.uri
any_filter = lambda t: (
track_filter(t) or album_filter(t) or
artist_filter(t) or uri_filter(t))
if field == 'track':
result_tracks = filter(track_filter, result_tracks)
elif field == 'album':
result_tracks = filter(album_filter, result_tracks)
elif field == 'artist':
result_tracks = filter(artist_filter, result_tracks)
elif field in ('uri', 'filename'):
result_tracks = filter(uri_filter, result_tracks)
elif field == 'any':
result_tracks = filter(any_filter, result_tracks)
else:
raise LookupError('Invalid lookup field: %s' % field)
return Playlist(tracks=result_tracks)
def search(self, **query):
self._validate_query(query)
result_tracks = self._uri_mapping.values()
for (field, values) in query.iteritems():
if not hasattr(values, '__iter__'):
values = [values]
# FIXME this is bound to be slow for large libraries
for value in values:
q = value.strip().lower()
track_filter = lambda t: q in t.name.lower()
album_filter = lambda t: q in getattr(
t, 'album', Album()).name.lower()
artist_filter = lambda t: filter(
lambda a: q in a.name.lower(), t.artists)
uri_filter = lambda t: q in t.uri.lower()
any_filter = lambda t: track_filter(t) or album_filter(t) or \
artist_filter(t) or uri_filter(t)
if field == 'track':
result_tracks = filter(track_filter, result_tracks)
elif field == 'album':
result_tracks = filter(album_filter, result_tracks)
elif field == 'artist':
result_tracks = filter(artist_filter, result_tracks)
elif field in ('uri', 'filename'):
result_tracks = filter(uri_filter, result_tracks)
elif field == 'any':
result_tracks = filter(any_filter, result_tracks)
else:
raise LookupError('Invalid lookup field: %s' % field)
return Playlist(tracks=result_tracks)
def _validate_query(self, query):
for (_, values) in query.iteritems():
if not values:
raise LookupError('Missing query')
for value in values:
if not value:
raise LookupError('Missing query')

View File

@ -0,0 +1,116 @@
import glob
import logging
import os
import shutil
from mopidy import settings
from mopidy.backends import base
from mopidy.models import Playlist
from mopidy.utils import formatting, path
from .translator import parse_m3u
logger = logging.getLogger(u'mopidy.backends.local')
class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider):
def __init__(self, *args, **kwargs):
super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs)
self._path = settings.LOCAL_PLAYLIST_PATH
self.refresh()
def create(self, name):
name = formatting.slugify(name)
uri = path.path_to_uri(self._get_m3u_path(name))
playlist = Playlist(uri=uri, name=name)
return self.save(playlist)
def delete(self, uri):
playlist = self.lookup(uri)
if not playlist:
return
self._playlists.remove(playlist)
self._delete_m3u(playlist.uri)
def lookup(self, uri):
for playlist in self._playlists:
if playlist.uri == uri:
return playlist
def refresh(self):
logger.info('Loading playlists from %s', self._path)
playlists = []
for m3u in glob.glob(os.path.join(self._path, '*.m3u')):
uri = path.path_to_uri(m3u)
name = os.path.splitext(os.path.basename(m3u))[0]
tracks = []
for track_uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH):
try:
# TODO We must use core.library.lookup() to support tracks
# from other backends
tracks.append(self.backend.library.lookup(track_uri))
except LookupError as ex:
logger.error('Playlist item could not be added: %s', ex)
playlist = Playlist(uri=uri, name=name, tracks=tracks)
playlists.append(playlist)
self.playlists = playlists
def save(self, playlist):
assert playlist.uri, 'Cannot save playlist without URI'
old_playlist = self.lookup(playlist.uri)
if old_playlist and playlist.name != old_playlist.name:
playlist = playlist.copy(name=formatting.slugify(playlist.name))
playlist = self._rename_m3u(playlist)
self._save_m3u(playlist)
if old_playlist is not None:
index = self._playlists.index(old_playlist)
self._playlists[index] = playlist
else:
self._playlists.append(playlist)
return playlist
def _get_m3u_path(self, name):
name = formatting.slugify(name)
file_path = os.path.join(self._path, name + '.m3u')
path.check_file_path_is_inside_base_dir(file_path, self._path)
return file_path
def _save_m3u(self, playlist):
file_path = path.uri_to_path(playlist.uri)
path.check_file_path_is_inside_base_dir(file_path, self._path)
with open(file_path, 'w') as file_handle:
for track in playlist.tracks:
if track.uri.startswith('file://'):
uri = path.uri_to_path(track.uri)
else:
uri = track.uri
file_handle.write(uri + '\n')
def _delete_m3u(self, uri):
file_path = path.uri_to_path(uri)
path.check_file_path_is_inside_base_dir(file_path, self._path)
if os.path.exists(file_path):
os.remove(file_path)
def _rename_m3u(self, playlist):
src_file_path = path.uri_to_path(playlist.uri)
path.check_file_path_is_inside_base_dir(src_file_path, self._path)
dst_file_path = self._get_m3u_path(playlist.name)
path.check_file_path_is_inside_base_dir(dst_file_path, self._path)
shutil.move(src_file_path, dst_file_path)
return playlist.copy(uri=path.path_to_uri(dst_file_path))

View File

@ -1,14 +1,14 @@
import logging
import os
logger = logging.getLogger('mopidy.backends.local.translator')
from mopidy.models import Track, Artist, Album
from mopidy.utils import locale_decode
from mopidy.utils.encoding import locale_decode
from mopidy.utils.path import path_to_uri
def parse_m3u(file_path):
"""
logger = logging.getLogger('mopidy.backends.local')
def parse_m3u(file_path, music_folder):
r"""
Convert M3U file list of uris
Example M3U data::
@ -29,8 +29,6 @@ def parse_m3u(file_path):
"""
uris = []
folder = os.path.dirname(file_path)
try:
with open(file_path) as m3u:
contents = m3u.readlines()
@ -48,11 +46,12 @@ def parse_m3u(file_path):
if line.startswith('file://'):
uris.append(line)
else:
path = path_to_uri(folder, line)
path = path_to_uri(music_folder, line)
uris.append(path)
return uris
def parse_mpd_tag_cache(tag_cache, music_dir=''):
"""
Converts a MPD tag_cache into a lists of tracks, artists and albums.
@ -91,6 +90,7 @@ def parse_mpd_tag_cache(tag_cache, music_dir=''):
return tracks
def _convert_mpd_data(data, tracks, music_dir):
if not data:
return
@ -130,7 +130,8 @@ def _convert_mpd_data(data, tracks, music_dir):
artist_kwargs['musicbrainz_id'] = data['musicbrainz_artistid']
if 'musicbrainz_albumartistid' in data:
albumartist_kwargs['musicbrainz_id'] = data['musicbrainz_albumartistid']
albumartist_kwargs['musicbrainz_id'] = (
data['musicbrainz_albumartistid'])
if data['file'][0] == '/':
path = data['file'][1:]
@ -144,7 +145,7 @@ def _convert_mpd_data(data, tracks, music_dir):
if albumartist_kwargs:
albumartist = Artist(**albumartist_kwargs)
album_kwargs['artists'] = [albumartist]
if album_kwargs:
album = Album(**album_kwargs)
track_kwargs['album'] = album

View File

@ -1,94 +1,34 @@
import logging
"""A backend for playing music from Spotify
from pykka.actor import ThreadingActor
from pykka.registry import ActorRegistry
`Spotify <http://www.spotify.com/>`_ is a music streaming service. The backend
uses the official `libspotify
<http://developer.spotify.com/en/libspotify/overview/>`_ library and the
`pyspotify <http://github.com/mopidy/pyspotify/>`_ Python bindings for
libspotify. This backend handles URIs starting with ``spotify:``.
from mopidy import audio, core, settings
from mopidy.backends import base
See :ref:`music-from-spotify` for further instructions on using this backend.
logger = logging.getLogger('mopidy.backends.spotify')
.. note::
BITRATES = {96: 2, 160: 0, 320: 1}
This product uses SPOTIFY(R) CORE but is not endorsed, certified or
otherwise approved in any way by Spotify. Spotify is the registered
trade mark of the Spotify Group.
class SpotifyBackend(ThreadingActor, base.Backend):
"""
A backend for playing music from the `Spotify <http://www.spotify.com/>`_
music streaming service. The backend uses the official `libspotify
<http://developer.spotify.com/en/libspotify/overview/>`_ library and the
`pyspotify <http://github.com/winjer/pyspotify/>`_ Python bindings for
libspotify.
**Issues:**
.. note::
https://github.com/mopidy/mopidy/issues?labels=Spotify+backend
This product uses SPOTIFY(R) CORE but is not endorsed, certified or
otherwise approved in any way by Spotify. Spotify is the registered
trade mark of the Spotify Group.
**Dependencies:**
**Issues:**
https://github.com/mopidy/mopidy/issues?labels=backend-spotify
- libspotify >= 12, < 13 (libspotify12 package from apt.mopidy.com)
- pyspotify >= 1.8, < 1.9 (python-spotify package from apt.mopidy.com)
**Dependencies:**
**Settings:**
- libspotify >= 10, < 11 (libspotify10 package from apt.mopidy.com)
- pyspotify >= 1.5 (python-spotify package from apt.mopidy.com)
- :attr:`mopidy.settings.SPOTIFY_CACHE_PATH`
- :attr:`mopidy.settings.SPOTIFY_USERNAME`
- :attr:`mopidy.settings.SPOTIFY_PASSWORD`
"""
**Settings:**
- :attr:`mopidy.settings.SPOTIFY_CACHE_PATH`
- :attr:`mopidy.settings.SPOTIFY_USERNAME`
- :attr:`mopidy.settings.SPOTIFY_PASSWORD`
"""
# Imports inside methods are to prevent loading of __init__.py to fail on
# missing spotify dependencies.
def __init__(self, *args, **kwargs):
from .library import SpotifyLibraryProvider
from .playback import SpotifyPlaybackProvider
from .stored_playlists import SpotifyStoredPlaylistsProvider
super(SpotifyBackend, self).__init__(*args, **kwargs)
self.current_playlist = core.CurrentPlaylistController(backend=self)
library_provider = SpotifyLibraryProvider(backend=self)
self.library = core.LibraryController(backend=self,
provider=library_provider)
playback_provider = SpotifyPlaybackProvider(backend=self)
self.playback = core.PlaybackController(backend=self,
provider=playback_provider)
stored_playlists_provider = SpotifyStoredPlaylistsProvider(
backend=self)
self.stored_playlists = core.StoredPlaylistsController(backend=self,
provider=stored_playlists_provider)
self.uri_schemes = [u'spotify']
self.audio = None
self.spotify = None
# Fail early if settings are not present
self.username = settings.SPOTIFY_USERNAME
self.password = settings.SPOTIFY_PASSWORD
def on_start(self):
audio_refs = ActorRegistry.get_by_class(audio.Audio)
assert len(audio_refs) == 1, \
'Expected exactly one running Audio instance.'
self.audio = audio_refs[0].proxy()
logger.info(u'Mopidy uses SPOTIFY(R) CORE')
self.spotify = self._connect()
def on_stop(self):
self.spotify.logout()
def _connect(self):
from .session_manager import SpotifySessionManager
logger.debug(u'Connecting to Spotify')
spotify = SpotifySessionManager(self.username, self.password)
spotify.start()
return spotify
# flake8: noqa
from .actor import SpotifyBackend

View File

@ -0,0 +1,42 @@
import logging
import pykka
from mopidy import settings
from mopidy.backends import base
logger = logging.getLogger('mopidy.backends.spotify')
class SpotifyBackend(pykka.ThreadingActor, base.Backend):
# Imports inside methods are to prevent loading of __init__.py to fail on
# missing spotify dependencies.
def __init__(self, audio):
super(SpotifyBackend, self).__init__()
from .library import SpotifyLibraryProvider
from .playback import SpotifyPlaybackProvider
from .session_manager import SpotifySessionManager
from .stored_playlists import SpotifyStoredPlaylistsProvider
self.library = SpotifyLibraryProvider(backend=self)
self.playback = SpotifyPlaybackProvider(audio=audio, backend=self)
self.stored_playlists = SpotifyStoredPlaylistsProvider(backend=self)
self.uri_schemes = [u'spotify']
# Fail early if settings are not present
username = settings.SPOTIFY_USERNAME
password = settings.SPOTIFY_PASSWORD
self.spotify = SpotifySessionManager(
username, password, audio=audio, backend_ref=self.actor_ref)
def on_start(self):
logger.info(u'Mopidy uses SPOTIFY(R) CORE')
logger.debug(u'Connecting to Spotify')
self.spotify.start()
def on_stop(self):
self.spotify.logout()

View File

@ -3,7 +3,8 @@ import logging
from spotify.manager import SpotifyContainerManager as \
PyspotifyContainerManager
logger = logging.getLogger('mopidy.backends.spotify.container_manager')
logger = logging.getLogger('mopidy.backends.spotify')
class SpotifyContainerManager(PyspotifyContainerManager):
def __init__(self, session_manager):
@ -25,13 +26,13 @@ class SpotifyContainerManager(PyspotifyContainerManager):
def playlist_added(self, container, playlist, position, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: playlist added at position %d',
position)
logger.debug(
u'Callback called: playlist added at position %d', position)
# container_loaded() is called after this callback, so we do not need
# to handle this callback.
def playlist_moved(self, container, playlist, old_position, new_position,
userdata):
userdata):
"""Callback used by pyspotify"""
logger.debug(
u'Callback called: playlist "%s" moved from position %d to %d',

View File

@ -3,16 +3,18 @@ import Queue
from spotify import Link, SpotifyError
from mopidy.backends.base import BaseLibraryProvider
from mopidy.backends.spotify.translator import SpotifyTranslator
from mopidy.backends import base
from mopidy.models import Track, Playlist
logger = logging.getLogger('mopidy.backends.spotify.library')
from . import translator
logger = logging.getLogger('mopidy.backends.spotify')
class SpotifyTrack(Track):
"""Proxy object for unloaded Spotify tracks."""
def __init__(self, uri):
super(SpotifyTrack, self).__init__()
self._spotify_track = Link.from_string(uri).as_track()
self._unloaded_track = Track(uri=uri, name=u'[loading...]')
self._track = None
@ -22,7 +24,7 @@ class SpotifyTrack(Track):
if self._track:
return self._track
elif self._spotify_track.is_loaded():
self._track = SpotifyTranslator.to_mopidy_track(self._spotify_track)
self._track = translator.to_mopidy_track(self._spotify_track)
return self._track
else:
return self._unloaded_track
@ -47,7 +49,7 @@ class SpotifyTrack(Track):
return self._proxy.copy(**values)
class SpotifyLibraryProvider(BaseLibraryProvider):
class SpotifyLibraryProvider(base.BaseLibraryProvider):
def find_exact(self, **query):
return self.search(**query)
@ -59,7 +61,7 @@ class SpotifyLibraryProvider(BaseLibraryProvider):
return None
def refresh(self, uri=None):
pass # TODO
pass # TODO
def search(self, **query):
if not query:
@ -81,7 +83,7 @@ class SpotifyLibraryProvider(BaseLibraryProvider):
if field == u'any':
spotify_query.append(value)
elif field == u'year':
value = int(value.split('-')[0]) # Extract year
value = int(value.split('-')[0]) # Extract year
spotify_query.append(u'%s:%d' % (field, value))
else:
spotify_query.append(u'%s:"%s"' % (field, value))
@ -90,6 +92,6 @@ class SpotifyLibraryProvider(BaseLibraryProvider):
queue = Queue.Queue()
self.backend.spotify.search(spotify_query, queue)
try:
return queue.get(timeout=3) # XXX What is an reasonable timeout?
return queue.get(timeout=3) # XXX What is an reasonable timeout?
except Queue.Empty:
return Playlist(tracks=[])

View File

@ -1,40 +1,114 @@
import logging
import time
from spotify import Link, SpotifyError
from mopidy.backends.base import BasePlaybackProvider
from mopidy.backends import base
from mopidy.core import PlaybackState
logger = logging.getLogger('mopidy.backends.spotify.playback')
class SpotifyPlaybackProvider(BasePlaybackProvider):
logger = logging.getLogger('mopidy.backends.spotify')
class SpotifyPlaybackProvider(base.BasePlaybackProvider):
def __init__(self, *args, **kwargs):
super(SpotifyPlaybackProvider, self).__init__(*args, **kwargs)
self._timer = TrackPositionTimer()
def pause(self):
self._timer.pause()
return super(SpotifyPlaybackProvider, self).pause()
def play(self, track):
if self.backend.playback.state == PlaybackState.PLAYING:
self.backend.spotify.session.play(0)
if track.uri is None:
return False
try:
self.backend.spotify.session.load(
Link.from_string(track.uri).as_track())
self.backend.spotify.session.play(1)
self.backend.audio.prepare_change()
self.backend.audio.set_uri('appsrc://')
self.backend.audio.start_playback()
self.backend.audio.set_metadata(track)
self.audio.prepare_change()
self.audio.set_uri('appsrc://')
self.audio.start_playback()
self.audio.set_metadata(track)
self._timer.play()
return True
except SpotifyError as e:
logger.info('Playback of %s failed: %s', track.uri, e)
return False
def resume(self):
return self.seek(self.backend.playback.time_position)
time_position = self.get_time_position()
self._timer.resume()
return self.seek(time_position)
def seek(self, time_position):
self.backend.audio.prepare_change()
self.audio.prepare_change()
self.backend.spotify.session.seek(time_position)
self.backend.audio.start_playback()
self.audio.start_playback()
self._timer.seek(time_position)
return True
def stop(self):
self.backend.spotify.session.play(0)
return super(SpotifyPlaybackProvider, self).stop()
def get_time_position(self):
# XXX: The default implementation of get_time_position hangs/times out
# when used with the Spotify backend and GStreamer appsrc. If this can
# be resolved, we no longer need to use a wall clock based time
# position for Spotify playback.
return self._timer.get_time_position()
class TrackPositionTimer(object):
"""
Keeps track of time position in a track using the wall clock and playback
events.
To not introduce a reverse dependency on the playback controller, this
class keeps track of playback state itself.
"""
def __init__(self):
self._state = PlaybackState.STOPPED
self._accumulated = 0
self._started = 0
def play(self):
self._state = PlaybackState.PLAYING
self._accumulated = 0
self._started = self._wall_time()
def pause(self):
self._state = PlaybackState.PAUSED
self._accumulated += self._wall_time() - self._started
def resume(self):
self._state = PlaybackState.PLAYING
def seek(self, time_position):
self._started = self._wall_time()
self._accumulated = time_position
def get_time_position(self):
if self._state == PlaybackState.PLAYING:
time_since_started = self._wall_time() - self._started
return self._accumulated + time_since_started
elif self._state == PlaybackState.PAUSED:
return self._accumulated
elif self._state == PlaybackState.STOPPED:
return 0
def _wall_time(self):
return int(time.time() * 1000)

View File

@ -3,7 +3,8 @@ import logging
from spotify.manager import SpotifyPlaylistManager as PyspotifyPlaylistManager
logger = logging.getLogger('mopidy.backends.spotify.playlist_manager')
logger = logging.getLogger('mopidy.backends.spotify')
class SpotifyPlaylistManager(PyspotifyPlaylistManager):
def __init__(self, session_manager):
@ -12,48 +13,55 @@ class SpotifyPlaylistManager(PyspotifyPlaylistManager):
def tracks_added(self, playlist, tracks, position, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: '
logger.debug(
u'Callback called: '
u'%d track(s) added to position %d in playlist "%s"',
len(tracks), position, playlist.name())
self.session_manager.refresh_stored_playlists()
def tracks_moved(self, playlist, tracks, new_position, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: '
logger.debug(
u'Callback called: '
u'%d track(s) moved to position %d in playlist "%s"',
len(tracks), new_position, playlist.name())
self.session_manager.refresh_stored_playlists()
def tracks_removed(self, playlist, tracks, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: '
logger.debug(
u'Callback called: '
u'%d track(s) removed from playlist "%s"',
len(tracks), playlist.name())
self.session_manager.refresh_stored_playlists()
def playlist_renamed(self, playlist, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: Playlist renamed to "%s"',
playlist.name())
logger.debug(
u'Callback called: Playlist renamed to "%s"', playlist.name())
self.session_manager.refresh_stored_playlists()
def playlist_state_changed(self, playlist, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: The state of playlist "%s" changed',
logger.debug(
u'Callback called: The state of playlist "%s" changed',
playlist.name())
def playlist_update_in_progress(self, playlist, done, userdata):
"""Callback used by pyspotify"""
if done:
logger.debug(u'Callback called: '
u'Update of playlist "%s" done', playlist.name())
logger.debug(
u'Callback called: Update of playlist "%s" done',
playlist.name())
else:
logger.debug(u'Callback called: '
u'Update of playlist "%s" in progress', playlist.name())
logger.debug(
u'Callback called: Update of playlist "%s" in progress',
playlist.name())
def playlist_metadata_updated(self, playlist, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: Metadata updated for playlist "%s"',
logger.debug(
u'Callback called: Metadata updated for playlist "%s"',
playlist.name())
def track_created_changed(self, playlist, position, user, when, userdata):
@ -90,5 +98,6 @@ class SpotifyPlaylistManager(PyspotifyPlaylistManager):
def image_changed(self, playlist, image, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: Image changed for playlist "%s"',
logger.debug(
u'Callback called: Image changed for playlist "%s"',
playlist.name())

View File

@ -4,37 +4,36 @@ import threading
from spotify.manager import SpotifySessionManager as PyspotifySessionManager
from pykka.registry import ActorRegistry
from mopidy import audio, get_version, settings, CACHE_PATH
from mopidy.backends.base import Backend
from mopidy.backends.spotify import BITRATES
from mopidy.backends.spotify.container_manager import SpotifyContainerManager
from mopidy.backends.spotify.playlist_manager import SpotifyPlaylistManager
from mopidy.backends.spotify.translator import SpotifyTranslator
from mopidy import settings
from mopidy.models import Playlist
from mopidy.utils.process import BaseThread
from mopidy.utils import process, versioning
logger = logging.getLogger('mopidy.backends.spotify.session_manager')
from . import translator
from .container_manager import SpotifyContainerManager
from .playlist_manager import SpotifyPlaylistManager
logger = logging.getLogger('mopidy.backends.spotify')
BITRATES = {96: 2, 160: 0, 320: 1}
# pylint: disable = R0901
# SpotifySessionManager: Too many ancestors (9/7)
class SpotifySessionManager(BaseThread, PyspotifySessionManager):
cache_location = (settings.SPOTIFY_CACHE_PATH
or os.path.join(CACHE_PATH, 'spotify'))
class SpotifySessionManager(process.BaseThread, PyspotifySessionManager):
cache_location = settings.SPOTIFY_CACHE_PATH
settings_location = cache_location
appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key')
user_agent = 'Mopidy %s' % get_version()
user_agent = 'Mopidy %s' % versioning.get_version()
def __init__(self, username, password):
def __init__(self, username, password, audio, backend_ref):
PyspotifySessionManager.__init__(self, username, password)
BaseThread.__init__(self)
process.BaseThread.__init__(self)
self.name = 'SpotifyThread'
self.audio = None
self.audio = audio
self.backend = None
self.backend_ref = backend_ref
self.connected = threading.Event()
self.session = None
@ -45,19 +44,9 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
self._initial_data_receive_completed = False
def run_inside_try(self):
self.setup()
self.backend = self.backend_ref.proxy()
self.connect()
def setup(self):
audio_refs = ActorRegistry.get_by_class(audio.Audio)
assert len(audio_refs) == 1, \
'Expected exactly one running Audio instance.'
self.audio = audio_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:
@ -67,7 +56,8 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
logger.info(u'Connected to Spotify')
self.session = session
logger.debug(u'Preferred Spotify bitrate is %s kbps',
logger.debug(
u'Preferred Spotify bitrate is %s kbps',
settings.SPOTIFY_BITRATE)
self.session.set_preferred_bitrate(BITRATES[settings.SPOTIFY_BITRATE])
@ -99,7 +89,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
logger.debug(u'User message: %s', message.strip())
def music_delivery(self, session, frames, frame_size, num_frames,
sample_type, sample_rate, channels):
sample_type, sample_rate, channels):
"""Callback used by pyspotify"""
# pylint: disable = R0913
# Too many arguments (8/5)
@ -150,11 +140,11 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
if not self._initial_data_receive_completed:
logger.debug(u'Still getting data; skipped refresh of playlists')
return
playlists = map(SpotifyTranslator.to_mopidy_playlist,
self.session.playlist_container())
playlists = map(
translator.to_mopidy_playlist, self.session.playlist_container())
playlists = filter(None, playlists)
self.backend.stored_playlists.playlists = playlists
logger.debug(u'Refreshed %d stored playlist(s)', len(playlists))
logger.info(u'Loaded %d Spotify playlist(s)', len(playlists))
def search(self, query, queue):
"""Search method used by Mopidy backend"""
@ -163,12 +153,11 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
# TODO Consider launching a second search if results.total_tracks()
# is larger than len(results.tracks())
playlist = Playlist(tracks=[
SpotifyTranslator.to_mopidy_track(t)
for t in results.tracks()])
translator.to_mopidy_track(t) for t in results.tracks()])
queue.put(playlist)
self.connected.wait()
self.session.search(query, callback, track_count=100,
album_count=0, artist_count=0)
self.session.search(
query, callback, track_count=100, album_count=0, artist_count=0)
def logout(self):
"""Log out from spotify"""

View File

@ -1,20 +1,21 @@
from mopidy.backends.base import BaseStoredPlaylistsProvider
from mopidy.backends import base
class SpotifyStoredPlaylistsProvider(BaseStoredPlaylistsProvider):
class SpotifyStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider):
def create(self, name):
pass # TODO
pass # TODO
def delete(self, playlist):
pass # TODO
pass # TODO
def lookup(self, uri):
pass # TODO
pass # TODO
def refresh(self):
pass # TODO
pass # TODO
def rename(self, playlist, new_name):
pass # TODO
pass # TODO
def save(self, playlist):
pass # TODO
pass # TODO

View File

@ -1,63 +1,63 @@
import logging
from spotify import Link, SpotifyError
from spotify import Link
from mopidy import settings
from mopidy.models import Artist, Album, Track, Playlist
logger = logging.getLogger('mopidy.backends.spotify.translator')
class SpotifyTranslator(object):
@classmethod
def to_mopidy_artist(cls, spotify_artist):
if not spotify_artist.is_loaded():
return Artist(name=u'[loading...]')
return Artist(
uri=str(Link.from_artist(spotify_artist)),
name=spotify_artist.name()
)
def to_mopidy_artist(spotify_artist):
if spotify_artist is None:
return
uri = str(Link.from_artist(spotify_artist))
if not spotify_artist.is_loaded():
return Artist(uri=uri, name=u'[loading...]')
return Artist(uri=uri, name=spotify_artist.name())
@classmethod
def to_mopidy_album(cls, spotify_album):
if spotify_album is None or not spotify_album.is_loaded():
return Album(name=u'[loading...]')
# TODO pyspotify got much more data on albums than this
return Album(name=spotify_album.name())
@classmethod
def to_mopidy_track(cls, spotify_track):
uri = str(Link.from_track(spotify_track, 0))
if not spotify_track.is_loaded():
return Track(uri=uri, name=u'[loading...]')
spotify_album = spotify_track.album()
if spotify_album is not None and spotify_album.is_loaded():
date = spotify_album.year()
else:
date = None
return Track(
uri=uri,
name=spotify_track.name(),
artists=[cls.to_mopidy_artist(a) for a in spotify_track.artists()],
album=cls.to_mopidy_album(spotify_track.album()),
track_no=spotify_track.index(),
date=date,
length=spotify_track.duration(),
bitrate=settings.SPOTIFY_BITRATE,
)
def to_mopidy_album(spotify_album):
if spotify_album is None:
return
uri = str(Link.from_album(spotify_album))
if not spotify_album.is_loaded():
return Album(uri=uri, name=u'[loading...]')
return Album(
uri=uri,
name=spotify_album.name(),
artists=[to_mopidy_artist(spotify_album.artist())],
date=spotify_album.year())
@classmethod
def to_mopidy_playlist(cls, spotify_playlist):
if not spotify_playlist.is_loaded():
return Playlist(name=u'[loading...]')
if spotify_playlist.type() != 'playlist':
return
try:
return Playlist(
uri=str(Link.from_playlist(spotify_playlist)),
name=spotify_playlist.name(),
# FIXME if check on link is a hackish workaround for is_local
tracks=[cls.to_mopidy_track(t) for t in spotify_playlist
if str(Link.from_track(t, 0))],
)
except SpotifyError, e:
logger.warning(u'Failed translating Spotify playlist: %s', e)
def to_mopidy_track(spotify_track):
if spotify_track is None:
return
uri = str(Link.from_track(spotify_track, 0))
if not spotify_track.is_loaded():
return Track(uri=uri, name=u'[loading...]')
spotify_album = spotify_track.album()
if spotify_album is not None and spotify_album.is_loaded():
date = spotify_album.year()
else:
date = None
return Track(
uri=uri,
name=spotify_track.name(),
artists=[to_mopidy_artist(a) for a in spotify_track.artists()],
album=to_mopidy_album(spotify_track.album()),
track_no=spotify_track.index(),
date=date,
length=spotify_track.duration(),
bitrate=settings.SPOTIFY_BITRATE)
def to_mopidy_playlist(spotify_playlist):
if spotify_playlist is None or spotify_playlist.type() != 'playlist':
return
uri = str(Link.from_playlist(spotify_playlist))
if not spotify_playlist.is_loaded():
return Playlist(uri=uri, name=u'[loading...]')
return Playlist(
uri=uri,
name=spotify_playlist.name(),
tracks=[
to_mopidy_track(spotify_track)
for spotify_track in spotify_playlist
if not spotify_track.is_local()])

View File

@ -1,4 +1,7 @@
# flake8: noqa
from .actor import Core
from .current_playlist import CurrentPlaylistController
from .library import LibraryController
from .listener import CoreListener
from .playback import PlaybackController, PlaybackState
from .stored_playlists import StoredPlaylistsController

71
mopidy/core/actor.py Normal file
View File

@ -0,0 +1,71 @@
import itertools
import pykka
from mopidy.audio import AudioListener
from .current_playlist import CurrentPlaylistController
from .library import LibraryController
from .playback import PlaybackController
from .stored_playlists import StoredPlaylistsController
class Core(pykka.ThreadingActor, AudioListener):
#: The current playlist controller. An instance of
#: :class:`mopidy.core.CurrentPlaylistController`.
current_playlist = None
#: The library controller. An instance of
# :class:`mopidy.core.LibraryController`.
library = None
#: The playback controller. An instance of
#: :class:`mopidy.core.PlaybackController`.
playback = None
#: The stored playlists controller. An instance of
#: :class:`mopidy.core.StoredPlaylistsController`.
stored_playlists = None
def __init__(self, audio=None, backends=None):
super(Core, self).__init__()
self.backends = Backends(backends)
self.current_playlist = CurrentPlaylistController(core=self)
self.library = LibraryController(backends=self.backends, core=self)
self.playback = PlaybackController(
audio=audio, backends=self.backends, core=self)
self.stored_playlists = StoredPlaylistsController(
backends=self.backends, core=self)
@property
def uri_schemes(self):
"""List of URI schemes we can handle"""
futures = [b.uri_schemes for b in self.backends]
results = pykka.get_all(futures)
uri_schemes = itertools.chain(*results)
return sorted(uri_schemes)
def reached_end_of_stream(self):
self.playback.on_end_of_track()
class Backends(list):
def __init__(self, backends):
super(Backends, self).__init__(backends)
self.by_uri_scheme = {}
for backend in backends:
uri_schemes = backend.uri_schemes.get()
for uri_scheme in uri_schemes:
assert uri_scheme not in self.by_uri_scheme, (
'Cannot add URI scheme %s for %s, '
'it is already handled by %s'
) % (
uri_scheme, backend.__class__.__name__,
self.by_uri_scheme[uri_scheme].__class__.__name__)
self.by_uri_scheme[uri_scheme] = backend

View File

@ -2,23 +2,19 @@ from copy import copy
import logging
import random
from mopidy.listeners import BackendListener
from mopidy.models import CpTrack
from . import listener
logger = logging.getLogger('mopidy.core')
class CurrentPlaylistController(object):
"""
:param backend: backend the controller is a part of
:type backend: :class:`mopidy.backends.base.Backend`
"""
pykka_traversable = True
def __init__(self, backend):
self.backend = backend
def __init__(self, core):
self.core = core
self.cp_id = 0
self._cp_tracks = []
self._version = 0
@ -56,10 +52,10 @@ class CurrentPlaylistController(object):
"""
return self._version
@version.setter
@version.setter # noqa
def version(self, version):
self._version = version
self.backend.playback.on_current_playlist_change()
self.core.playback.on_current_playlist_change()
self._trigger_playlist_changed()
def add(self, track, at_position=None, increase_version=True):
@ -71,6 +67,8 @@ class CurrentPlaylistController(object):
:type track: :class:`mopidy.models.Track`
:param at_position: position in current playlist to add track
:type at_position: int or :class:`None`
:param increase_version: if the playlist version should be increased
:type increase_version: :class:`True` or :class:`False`
:rtype: two-tuple of (CPID integer, :class:`mopidy.models.Track`) that
was added to the current playlist playlist
"""
@ -127,8 +125,8 @@ class CurrentPlaylistController(object):
if key == 'cpid':
matches = filter(lambda ct: ct.cpid == value, matches)
else:
matches = filter(lambda ct: getattr(ct.track, key) == value,
matches)
matches = filter(
lambda ct: getattr(ct.track, key) == value, matches)
if len(matches) == 1:
return matches[0]
criteria_string = ', '.join(
@ -240,4 +238,4 @@ class CurrentPlaylistController(object):
def _trigger_playlist_changed(self):
logger.debug(u'Triggering playlist changed event')
BackendListener.send('playlist_changed')
listener.CoreListener.send('playlist_changed')

View File

@ -1,16 +1,21 @@
class LibraryController(object):
"""
:param backend: backend the controller is a part of
:type backend: :class:`mopidy.backends.base.Backend`
:param provider: provider the controller should use
:type provider: instance of :class:`BaseLibraryProvider`
"""
import itertools
import urlparse
import pykka
from mopidy.models import Playlist
class LibraryController(object):
pykka_traversable = True
def __init__(self, backend, provider):
self.backend = backend
self.provider = provider
def __init__(self, backends, core):
self.backends = backends
self.core = core
def _get_backend(self, uri):
uri_scheme = urlparse.urlparse(uri).scheme
return self.backends.by_uri_scheme.get(uri_scheme, None)
def find_exact(self, **query):
"""
@ -29,7 +34,10 @@ class LibraryController(object):
:type query: dict
:rtype: :class:`mopidy.models.Playlist`
"""
return self.provider.find_exact(**query)
futures = [b.library.find_exact(**query) for b in self.backends]
results = pykka.get_all(futures)
return Playlist(tracks=[
track for playlist in results for track in playlist.tracks])
def lookup(self, uri):
"""
@ -39,7 +47,11 @@ class LibraryController(object):
:type uri: string
:rtype: :class:`mopidy.models.Track` or :class:`None`
"""
return self.provider.lookup(uri)
backend = self._get_backend(uri)
if backend:
return backend.library.lookup(uri).get()
else:
return None
def refresh(self, uri=None):
"""
@ -48,7 +60,13 @@ class LibraryController(object):
:param uri: directory or track URI
:type uri: string
"""
return self.provider.refresh(uri)
if uri is not None:
backend = self._get_backend(uri)
if backend:
backend.library.refresh(uri).get()
else:
futures = [b.library.refresh(uri) for b in self.backends]
pykka.get_all(futures)
def search(self, **query):
"""
@ -67,4 +85,8 @@ class LibraryController(object):
:type query: dict
:rtype: :class:`mopidy.models.Playlist`
"""
return self.provider.search(**query)
futures = [b.library.search(**query) for b in self.backends]
results = pykka.get_all(futures)
track_lists = [playlist.tracks for playlist in results]
tracks = list(itertools.chain(*track_lists))
return Playlist(tracks=tracks)

View File

@ -1,11 +1,12 @@
from pykka import registry
import pykka
class BackendListener(object):
class CoreListener(object):
"""
Marker interface for recipients of events sent by the backend.
Marker interface for recipients of events sent by the core actor.
Any Pykka actor that mixes in this class will receive calls to the methods
defined here when the corresponding events happen in the backend. This
defined here when the corresponding events happen in the core actor. This
interface is used both for looking up what actors to notify of the events,
and for providing default implementations for those listeners that are not
interested in all events.
@ -13,15 +14,10 @@ class BackendListener(object):
@staticmethod
def send(event, **kwargs):
"""Helper to allow calling of backend listener events"""
# FIXME this should be updated once Pykka supports non-blocking calls
# on proxies or some similar solution.
registry.ActorRegistry.broadcast({
'command': 'pykka_call',
'attr_path': (event,),
'args': [],
'kwargs': kwargs,
}, target_class=BackendListener)
"""Helper to allow calling of core listener events"""
listeners = pykka.ActorRegistry.get_by_class(CoreListener)
for listener in listeners:
getattr(listener.proxy(), event)(**kwargs)
def track_playback_paused(self, track, time_position):
"""
@ -49,7 +45,6 @@ class BackendListener(object):
"""
pass
def track_playback_started(self, track):
"""
Called whenever a new track starts playing.
@ -74,11 +69,16 @@ class BackendListener(object):
"""
pass
def playback_state_changed(self):
def playback_state_changed(self, old_state, new_state):
"""
Called whenever playback state is changed.
*MAY* be implemented by actor.
:param old_state: the state before the change
:type old_state: string from :class:`mopidy.core.PlaybackState` field
:param new_state: the state after the change
:type new_state: string from :class:`mopidy.core.PlaybackState` field
"""
pass
@ -106,11 +106,14 @@ class BackendListener(object):
"""
pass
def seeked(self):
def seeked(self, time_position):
"""
Called whenever the time position changes by an unexpected amount, e.g.
at seek to a new time position.
*MAY* be implemented by actor.
:param time_position: the position that was seeked to in milliseconds
:type time_position: int
"""
pass

View File

@ -1,11 +1,11 @@
import logging
import random
import time
import urlparse
from mopidy.listeners import BackendListener
from . import listener
logger = logging.getLogger('mopidy.backends.base')
logger = logging.getLogger('mopidy.core')
def option_wrapper(name, default):
@ -14,13 +14,14 @@ def option_wrapper(name, default):
def set_option(self, value):
if getattr(self, name, default) != value:
# pylint: disable = W0212
self._trigger_options_changed()
# pylint: enable = W0212
return setattr(self, name, value)
return property(get_option, set_option)
class PlaybackState(object):
"""
Enum of playback states.
@ -37,13 +38,6 @@ class PlaybackState(object):
class PlaybackController(object):
"""
:param backend: the backend
:type backend: :class:`mopidy.backends.base.Backend`
:param provider: provider the controller should use
:type provider: instance of :class:`BasePlaybackProvider`
"""
# pylint: disable = R0902
# Too many instance attributes
@ -81,14 +75,22 @@ class PlaybackController(object):
#: Playback continues after current song.
single = option_wrapper('_single', False)
def __init__(self, backend, provider):
self.backend = backend
self.provider = provider
def __init__(self, audio, backends, core):
self.audio = audio
self.backends = backends
self.core = core
self._state = PlaybackState.STOPPED
self._shuffled = []
self._first_shuffle = True
self.play_time_accumulated = 0
self.play_time_started = None
self._volume = None
def _get_backend(self):
if self.current_cp_track is None:
return None
uri = self.current_cp_track.track.uri
uri_scheme = urlparse.urlparse(uri).scheme
return self.backends.by_uri_scheme[uri_scheme]
def _get_cpid(self, cp_track):
if cp_track is None:
@ -129,7 +131,7 @@ class PlaybackController(object):
if self.current_cp_track is None:
return None
try:
return self.backend.current_playlist.cp_tracks.index(
return self.core.current_playlist.cp_tracks.index(
self.current_cp_track)
except ValueError:
return None
@ -156,7 +158,7 @@ class PlaybackController(object):
# pylint: disable = R0911
# Too many return statements
cp_tracks = self.backend.current_playlist.cp_tracks
cp_tracks = self.core.current_playlist.cp_tracks
if not cp_tracks:
return None
@ -208,7 +210,7 @@ class PlaybackController(object):
enabled this should be a random track, all tracks should be played once
before the list repeats.
"""
cp_tracks = self.backend.current_playlist.cp_tracks
cp_tracks = self.core.current_playlist.cp_tracks
if not cp_tracks:
return None
@ -262,7 +264,7 @@ class PlaybackController(object):
if self.current_playlist_position in (None, 0):
return None
return self.backend.current_playlist.cp_tracks[
return self.core.current_playlist.cp_tracks[
self.current_playlist_position - 1]
@property
@ -285,59 +287,37 @@ class PlaybackController(object):
"""
return self._state
@state.setter
@state.setter # noqa
def state(self, new_state):
(old_state, self._state) = (self.state, new_state)
logger.debug(u'Changing state: %s -> %s', old_state, new_state)
self._trigger_playback_state_changed()
# FIXME play_time stuff assumes backend does not have a better way of
# handeling this stuff :/
if (old_state in (PlaybackState.PLAYING, PlaybackState.STOPPED)
and new_state == PlaybackState.PLAYING):
self._play_time_start()
elif (old_state == PlaybackState.PLAYING
and new_state == PlaybackState.PAUSED):
self._play_time_pause()
elif (old_state == PlaybackState.PAUSED
and new_state == PlaybackState.PLAYING):
self._play_time_resume()
self._trigger_playback_state_changed(old_state, new_state)
@property
def time_position(self):
"""Time position in milliseconds."""
if self.state == PlaybackState.PLAYING:
time_since_started = (self._current_wall_time -
self.play_time_started)
return self.play_time_accumulated + time_since_started
elif self.state == PlaybackState.PAUSED:
return self.play_time_accumulated
elif self.state == PlaybackState.STOPPED:
backend = self._get_backend()
if backend is None:
return 0
def _play_time_start(self):
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
def _play_time_resume(self):
self.play_time_started = self._current_wall_time
@property
def _current_wall_time(self):
return int(time.time() * 1000)
return backend.playback.get_time_position().get()
@property
def volume(self):
return self.provider.get_volume()
"""Volume as int in range [0..100] or :class:`None`"""
if self.audio:
return self.audio.get_volume().get()
else:
# For testing
return self._volume
@volume.setter
@volume.setter # noqa
def volume(self, volume):
self.provider.set_volume(volume)
if self.audio:
self.audio.set_volume(volume)
else:
# For testing
self._volume = volume
def change_track(self, cp_track, on_error_step=1):
"""
@ -375,20 +355,20 @@ class PlaybackController(object):
self.stop(clear_current_track=True)
if self.consume:
self.backend.current_playlist.remove(cpid=original_cp_track.cpid)
self.core.current_playlist.remove(cpid=original_cp_track.cpid)
def on_current_playlist_change(self):
"""
Tell the playback controller that the current playlist has changed.
Used by :class:`mopidy.backends.base.CurrentPlaylistController`.
Used by :class:`mopidy.core.CurrentPlaylistController`.
"""
self._first_shuffle = True
self._shuffled = []
if (not self.backend.current_playlist.cp_tracks or
if (not self.core.current_playlist.cp_tracks or
self.current_cp_track not in
self.backend.current_playlist.cp_tracks):
self.core.current_playlist.cp_tracks):
self.stop(clear_current_track=True)
def next(self):
@ -406,7 +386,8 @@ class PlaybackController(object):
def pause(self):
"""Pause playback."""
if self.provider.pause():
backend = self._get_backend()
if backend is None or backend.playback.pause().get():
self.state = PlaybackState.PAUSED
self._trigger_track_playback_paused()
@ -424,7 +405,7 @@ class PlaybackController(object):
"""
if cp_track is not None:
assert cp_track in self.backend.current_playlist.cp_tracks
assert cp_track in self.core.current_playlist.cp_tracks
elif cp_track is None:
if self.state == PlaybackState.PAUSED:
return self.resume()
@ -438,7 +419,7 @@ class PlaybackController(object):
if cp_track is not None:
self.current_cp_track = cp_track
self.state = PlaybackState.PLAYING
if not self.provider.play(cp_track.track):
if not self._get_backend().playback.play(cp_track.track).get():
# Track is not playable
if self.random and self._shuffled:
self._shuffled.remove(cp_track)
@ -464,7 +445,8 @@ class PlaybackController(object):
def resume(self):
"""If paused, resume playing the current track."""
if self.state == PlaybackState.PAUSED and self.provider.resume():
if (self.state == PlaybackState.PAUSED and
self._get_backend().playback.resume().get()):
self.state = PlaybackState.PLAYING
self._trigger_track_playback_resumed()
@ -476,7 +458,7 @@ class PlaybackController(object):
:type time_position: int
:rtype: :class:`True` if successful, else :class:`False`
"""
if not self.backend.current_playlist.tracks:
if not self.core.current_playlist.tracks:
return False
if self.state == PlaybackState.STOPPED:
@ -490,12 +472,9 @@ class PlaybackController(object):
self.next()
return True
self.play_time_started = self._current_wall_time
self.play_time_accumulated = time_position
success = self.provider.seek(time_position)
success = self._get_backend().playback.seek(time_position).get()
if success:
self._trigger_seeked()
self._trigger_seeked(time_position)
return success
def stop(self, clear_current_track=False):
@ -507,7 +486,7 @@ class PlaybackController(object):
:type clear_current_track: boolean
"""
if self.state != PlaybackState.STOPPED:
if self.provider.stop():
if self._get_backend().playback.stop().get():
self._trigger_track_playback_ended()
self.state = PlaybackState.STOPPED
if clear_current_track:
@ -517,41 +496,43 @@ class PlaybackController(object):
logger.debug(u'Triggering track playback paused event')
if self.current_track is None:
return
BackendListener.send('track_playback_paused',
track=self.current_track,
time_position=self.time_position)
listener.CoreListener.send(
'track_playback_paused',
track=self.current_track, time_position=self.time_position)
def _trigger_track_playback_resumed(self):
logger.debug(u'Triggering track playback resumed event')
if self.current_track is None:
return
BackendListener.send('track_playback_resumed',
track=self.current_track,
time_position=self.time_position)
listener.CoreListener.send(
'track_playback_resumed',
track=self.current_track, time_position=self.time_position)
def _trigger_track_playback_started(self):
logger.debug(u'Triggering track playback started event')
if self.current_track is None:
return
BackendListener.send('track_playback_started',
track=self.current_track)
listener.CoreListener.send(
'track_playback_started', track=self.current_track)
def _trigger_track_playback_ended(self):
logger.debug(u'Triggering track playback ended event')
if self.current_track is None:
return
BackendListener.send('track_playback_ended',
track=self.current_track,
time_position=self.time_position)
listener.CoreListener.send(
'track_playback_ended',
track=self.current_track, time_position=self.time_position)
def _trigger_playback_state_changed(self):
def _trigger_playback_state_changed(self, old_state, new_state):
logger.debug(u'Triggering playback state change event')
BackendListener.send('playback_state_changed')
listener.CoreListener.send(
'playback_state_changed',
old_state=old_state, new_state=new_state)
def _trigger_options_changed(self):
logger.debug(u'Triggering options changed event')
BackendListener.send('options_changed')
listener.CoreListener.send('options_changed')
def _trigger_seeked(self):
def _trigger_seeked(self, time_position):
logger.debug(u'Triggering seeked event')
BackendListener.send('seeked')
listener.CoreListener.send('seeked', time_position=time_position)

View File

@ -1,48 +1,65 @@
class StoredPlaylistsController(object):
"""
:param backend: backend the controller is a part of
:type backend: :class:`mopidy.backends.base.Backend`
:param provider: provider the controller should use
:type provider: instance of :class:`BaseStoredPlaylistsProvider`
"""
import itertools
import urlparse
import pykka
class StoredPlaylistsController(object):
pykka_traversable = True
def __init__(self, backend, provider):
self.backend = backend
self.provider = provider
def __init__(self, backends, core):
self.backends = backends
self.core = core
@property
def playlists(self):
"""
Currently stored playlists.
Read/write. List of :class:`mopidy.models.Playlist`.
Read-only. List of :class:`mopidy.models.Playlist`.
"""
return self.provider.playlists
futures = [b.stored_playlists.playlists for b in self.backends]
results = pykka.get_all(futures)
return list(itertools.chain(*results))
@playlists.setter
def playlists(self, playlists):
self.provider.playlists = playlists
def create(self, name):
def create(self, name, uri_scheme=None):
"""
Create a new playlist.
If ``uri_scheme`` matches an URI scheme handled by a current backend,
that backend is asked to create the playlist. If ``uri_scheme`` is
:class:`None` or doesn't match a current backend, the first backend is
asked to create the playlist.
All new playlists should be created by calling this method, and **not**
by creating new instances of :class:`mopidy.models.Playlist`.
:param name: name of the new playlist
:type name: string
:param uri_scheme: use the backend matching the URI scheme
:type uri_scheme: string
:rtype: :class:`mopidy.models.Playlist`
"""
return self.provider.create(name)
if uri_scheme in self.backends.by_uri_scheme:
backend = self.backends.by_uri_scheme[uri_scheme]
else:
backend = self.backends[0]
return backend.stored_playlists.create(name).get()
def delete(self, playlist):
def delete(self, uri):
"""
Delete playlist.
Delete playlist identified by the URI.
:param playlist: the playlist to delete
:type playlist: :class:`mopidy.models.Playlist`
If the URI doesn't match the URI schemes handled by the current
backends, nothing happens.
:param uri: URI of the playlist to delete
:type uri: string
"""
return self.provider.delete(playlist)
uri_scheme = urlparse.urlparse(uri).scheme
if uri_scheme in self.backends.by_uri_scheme:
backend = self.backends.by_uri_scheme[uri_scheme]
backend.stored_playlists.delete(uri).get()
def get(self, **criteria):
"""
@ -71,43 +88,71 @@ class StoredPlaylistsController(object):
if len(matches) == 0:
raise LookupError('"%s" match no playlists' % criteria_string)
else:
raise LookupError('"%s" match multiple playlists'
% criteria_string)
raise LookupError(
'"%s" match multiple playlists' % criteria_string)
def lookup(self, uri):
"""
Lookup playlist with given URI in both the set of stored playlists and
in any other playlist sources.
in any other playlist sources. Returns :class:`None` if not found.
:param uri: playlist URI
:type uri: string
:rtype: :class:`mopidy.models.Playlist`
:rtype: :class:`mopidy.models.Playlist` or :class:`None`
"""
return self.provider.lookup(uri)
uri_scheme = urlparse.urlparse(uri).scheme
backend = self.backends.by_uri_scheme.get(uri_scheme, None)
if backend:
return backend.stored_playlists.lookup(uri).get()
else:
return None
def refresh(self):
def refresh(self, uri_scheme=None):
"""
Refresh the stored playlists in
:attr:`mopidy.backends.base.StoredPlaylistsController.playlists`.
"""
return self.provider.refresh()
Refresh the stored playlists in :attr:`playlists`.
def rename(self, playlist, new_name):
"""
Rename playlist.
If ``uri_scheme`` is :class:`None`, all backends are asked to refresh.
If ``uri_scheme`` is an URI scheme handled by a backend, only that
backend is asked to refresh. If ``uri_scheme`` doesn't match any
current backend, nothing happens.
:param playlist: the playlist
:type playlist: :class:`mopidy.models.Playlist`
:param new_name: the new name
:type new_name: string
:param uri_scheme: limit to the backend matching the URI scheme
:type uri_scheme: string
"""
return self.provider.rename(playlist, new_name)
if uri_scheme is None:
futures = [b.stored_playlists.refresh() for b in self.backends]
pykka.get_all(futures)
else:
if uri_scheme in self.backends.by_uri_scheme:
backend = self.backends.by_uri_scheme[uri_scheme]
backend.stored_playlists.refresh().get()
def save(self, playlist):
"""
Save the playlist to the set of stored playlists.
For a playlist to be saveable, it must have the ``uri`` attribute set.
You should not set the ``uri`` atribute yourself, but use playlist
objects returned by :meth:`create` or retrieved from :attr:`playlists`,
which will always give you saveable playlists.
The method returns the saved playlist. The return playlist may differ
from the saved playlist. E.g. if the playlist name was changed, the
returned playlist may have a different URI. The caller of this method
should throw away the playlist sent to this method, and use the
returned playlist instead.
If the playlist's URI isn't set or doesn't match the URI scheme of a
current backend, nothing is done and :class:`None` is returned.
:param playlist: the playlist
:type playlist: :class:`mopidy.models.Playlist`
:rtype: :class:`mopidy.models.Playlist` or :class:`None`
"""
return self.provider.save(playlist)
if playlist.uri is None:
return
uri_scheme = urlparse.urlparse(playlist.uri).scheme
if uri_scheme not in self.backends.by_uri_scheme:
return
backend = self.backends.by_uri_scheme[uri_scheme]
return backend.stored_playlists.save(playlist).get()

21
mopidy/exceptions.py Normal file
View File

@ -0,0 +1,21 @@
class MopidyException(Exception):
def __init__(self, message, *args, **kwargs):
super(MopidyException, self).__init__(message, *args, **kwargs)
self._message = message
@property
def message(self):
"""Reimplement message field that was deprecated in Python 2.6"""
return self._message
@message.setter # noqa
def message(self, message):
self._message = message
class SettingsError(MopidyException):
pass
class OptionalDependencyError(MopidyException):
pass

View File

@ -1,42 +1,48 @@
"""
Frontend which scrobbles the music you play to your `Last.fm
<http://www.last.fm>`_ profile.
.. note::
This frontend requires a free user account at Last.fm.
**Dependencies:**
- `pylast <http://code.google.com/p/pylast/>`_ >= 0.5.7
**Settings:**
- :attr:`mopidy.settings.LASTFM_USERNAME`
- :attr:`mopidy.settings.LASTFM_PASSWORD`
**Usage:**
Make sure :attr:`mopidy.settings.FRONTENDS` includes
``mopidy.frontends.lastfm.LastfmFrontend``. By default, the setting includes
the Last.fm frontend.
"""
import logging
import time
import pykka
from mopidy import exceptions, settings
from mopidy.core import CoreListener
try:
import pylast
except ImportError as import_error:
from mopidy import OptionalDependencyError
raise OptionalDependencyError(import_error)
from pykka.actor import ThreadingActor
from mopidy import settings, SettingsError
from mopidy.listeners import BackendListener
raise exceptions.OptionalDependencyError(import_error)
logger = logging.getLogger('mopidy.frontends.lastfm')
API_KEY = '2236babefa8ebb3d93ea467560d00d04'
API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd'
class LastfmFrontend(ThreadingActor, BackendListener):
"""
Frontend which scrobbles the music you play to your `Last.fm
<http://www.last.fm>`_ profile.
.. note::
This frontend requires a free user account at Last.fm.
**Dependencies:**
- `pylast <http://code.google.com/p/pylast/>`_ >= 0.5.7
**Settings:**
- :attr:`mopidy.settings.LASTFM_USERNAME`
- :attr:`mopidy.settings.LASTFM_PASSWORD`
"""
def __init__(self):
class LastfmFrontend(pykka.ThreadingActor, CoreListener):
def __init__(self, core):
super(LastfmFrontend, self).__init__()
self.lastfm = None
self.last_start_time = None
@ -49,7 +55,7 @@ class LastfmFrontend(ThreadingActor, BackendListener):
api_key=API_KEY, api_secret=API_SECRET,
username=username, password_hash=password_hash)
logger.info(u'Connected to Last.fm')
except SettingsError as e:
except exceptions.SettingsError as e:
logger.info(u'Last.fm scrobbler not started')
logger.debug(u'Last.fm settings error: %s', e)
self.stop()

View File

@ -1,110 +1,25 @@
import logging
import sys
"""The MPD server frontend.
from pykka import registry, actor
MPD stands for Music Player Daemon. MPD is an independent project and server.
Mopidy implements the MPD protocol, and is thus compatible with clients for the
original MPD server.
from mopidy import listeners, settings
from mopidy.frontends.mpd import dispatcher, protocol
from mopidy.utils import locale_decode, log, network, process
**Dependencies:**
logger = logging.getLogger('mopidy.frontends.mpd')
- None
class MpdFrontend(actor.ThreadingActor, listeners.BackendListener):
"""
The MPD frontend.
**Settings:**
**Dependencies:**
- :attr:`mopidy.settings.MPD_SERVER_HOSTNAME`
- :attr:`mopidy.settings.MPD_SERVER_PORT`
- :attr:`mopidy.settings.MPD_SERVER_PASSWORD`
- None
**Usage:**
**Settings:**
Make sure :attr:`mopidy.settings.FRONTENDS` includes
``mopidy.frontends.mpd.MpdFrontend``. By default, the setting includes the MPD
frontend.
"""
- :attr:`mopidy.settings.MPD_SERVER_HOSTNAME`
- :attr:`mopidy.settings.MPD_SERVER_PORT`
- :attr:`mopidy.settings.MPD_SERVER_PASSWORD`
"""
def __init__(self):
super(MpdFrontend, self).__init__()
hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME)
port = settings.MPD_SERVER_PORT
try:
network.Server(hostname, port, protocol=MpdSession,
max_connections=settings.MPD_SERVER_MAX_CONNECTIONS)
except IOError as error:
logger.error(u'MPD server startup failed: %s', locale_decode(error))
sys.exit(1)
logger.info(u'MPD server running at [%s]:%s', hostname, port)
def on_stop(self):
process.stop_actors_by_class(MpdSession)
def send_idle(self, subsystem):
# FIXME this should be updated once pykka supports non-blocking calls
# on proxies or some similar solution
registry.ActorRegistry.broadcast({
'command': 'pykka_call',
'attr_path': ('on_idle',),
'args': [subsystem],
'kwargs': {},
}, target_class=MpdSession)
def playback_state_changed(self):
self.send_idle('player')
def playlist_changed(self):
self.send_idle('playlist')
def options_changed(self):
self.send_idle('options')
def volume_changed(self):
self.send_idle('mixer')
class MpdSession(network.LineProtocol):
"""
The MPD client session. Keeps track of a single client session. Any
requests from the client is passed on to the MPD request dispatcher.
"""
terminator = protocol.LINE_TERMINATOR
encoding = protocol.ENCODING
delimeter = r'\r?\n'
def __init__(self, connection):
super(MpdSession, self).__init__(connection)
self.dispatcher = dispatcher.MpdDispatcher(self)
def on_start(self):
logger.info(u'New MPD connection from [%s]:%s', self.host, self.port)
self.send_lines([u'OK MPD %s' % protocol.VERSION])
def on_line_received(self, line):
logger.debug(u'Request from [%s]:%s to %s: %s', self.host, self.port,
self.actor_urn, line)
response = self.dispatcher.handle_request(line)
if not response:
return
logger.debug(u'Response to [%s]:%s from %s: %s', self.host, self.port,
self.actor_urn, log.indent(self.terminator.join(response)))
self.send_lines(response)
def on_idle(self, subsystem):
self.dispatcher.handle_idle(subsystem)
def decode(self, line):
try:
return super(MpdSession, self).decode(line.decode('string_escape'))
except ValueError:
logger.warning(u'Stopping actor due to unescaping error, data '
'supplied by client was not valid.')
self.stop()
def close(self):
self.stop()
# flake8: noqa
from .actor import MpdFrontend

View File

@ -0,0 +1,51 @@
import logging
import sys
import pykka
from mopidy import settings
from mopidy.core import CoreListener
from mopidy.frontends.mpd import session
from mopidy.utils import encoding, network, process
logger = logging.getLogger('mopidy.frontends.mpd')
class MpdFrontend(pykka.ThreadingActor, CoreListener):
def __init__(self, core):
super(MpdFrontend, self).__init__()
hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME)
port = settings.MPD_SERVER_PORT
try:
network.Server(
hostname, port,
protocol=session.MpdSession, protocol_kwargs={'core': core},
max_connections=settings.MPD_SERVER_MAX_CONNECTIONS)
except IOError as error:
logger.error(
u'MPD server startup failed: %s',
encoding.locale_decode(error))
sys.exit(1)
logger.info(u'MPD server running at [%s]:%s', hostname, port)
def on_stop(self):
process.stop_actors_by_class(session.MpdSession)
def send_idle(self, subsystem):
listeners = pykka.ActorRegistry.get_by_class(session.MpdSession)
for listener in listeners:
getattr(listener.proxy(), 'on_idle')(subsystem)
def playback_state_changed(self, old_state, new_state):
self.send_idle('player')
def playlist_changed(self):
self.send_idle('playlist')
def options_changed(self):
self.send_idle('options')
def volume_changed(self):
self.send_idle('mixer')

View File

@ -1,24 +1,16 @@
import logging
import re
from pykka import ActorDeadError
from pykka.registry import ActorRegistry
import pykka
from mopidy import settings
from mopidy.backends.base import Backend
from mopidy.frontends.mpd import exceptions
from mopidy.frontends.mpd.protocol import mpd_commands, request_handlers
# Do not remove the following import. The protocol modules must be imported to
# get them registered as request handlers.
# pylint: disable = W0611
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.utils import flatten
from mopidy.frontends.mpd import exceptions, protocol
logger = logging.getLogger('mopidy.frontends.mpd.dispatcher')
protocol.load_protocol_modules()
class MpdDispatcher(object):
"""
The MPD session feeds the MPD dispatcher with requests. The dispatcher
@ -28,12 +20,13 @@ class MpdDispatcher(object):
_noidle = re.compile(r'^noidle$')
def __init__(self, session=None):
def __init__(self, session=None, core=None):
self.authenticated = False
self.command_list = False
self.command_list_receiving = False
self.command_list_ok = False
self.command_list = []
self.command_list_index = None
self.context = MpdContext(self, session=session)
self.context = MpdContext(self, session=session, core=core)
def handle_request(self, request, current_command_list_index=None):
"""Dispatch incoming requests to the correct handler."""
@ -72,7 +65,6 @@ class MpdDispatcher(object):
else:
return response
### Filter: catch MPD ACK errors
def _catch_mpd_ack_errors_filter(self, request, response, filter_chain):
@ -83,7 +75,6 @@ class MpdDispatcher(object):
mpd_ack_error.index = self.command_list_index
return [mpd_ack_error.get_mpd_ack()]
### Filter: authenticate
def _authenticate_filter(self, request, response, filter_chain):
@ -95,14 +86,13 @@ class MpdDispatcher(object):
else:
command_name = request.split(' ')[0]
command_names_not_requiring_auth = [
command.name for command in mpd_commands
command.name for command in protocol.mpd_commands
if not command.auth_required]
if command_name in command_names_not_requiring_auth:
return self._call_next_filter(request, response, filter_chain)
else:
raise exceptions.MpdPermissionError(command=command_name)
### Filter: command list
def _command_list_filter(self, request, response, filter_chain):
@ -118,25 +108,26 @@ class MpdDispatcher(object):
return response
def _is_receiving_command_list(self, request):
return (self.command_list is not False
and request != u'command_list_end')
return (
self.command_list_receiving and request != u'command_list_end')
def _is_processing_command_list(self, request):
return (self.command_list_index is not None
and request != u'command_list_end')
return (
self.command_list_index is not None and
request != u'command_list_end')
### Filter: idle
def _idle_filter(self, request, response, filter_chain):
if self._is_currently_idle() and not self._noidle.match(request):
logger.debug(u'Client sent us %s, only %s is allowed while in '
'the idle state', repr(request), repr(u'noidle'))
logger.debug(
u'Client sent us %s, only %s is allowed while in '
u'the idle state', repr(request), repr(u'noidle'))
self.context.session.close()
return []
if not self._is_currently_idle() and self._noidle.match(request):
return [] # noidle was called before idle
return [] # noidle was called before idle
response = self._call_next_filter(request, response, filter_chain)
@ -148,7 +139,6 @@ class MpdDispatcher(object):
def _is_currently_idle(self):
return bool(self.context.subscriptions)
### Filter: add OK
def _add_ok_filter(self, request, response, filter_chain):
@ -160,14 +150,13 @@ class MpdDispatcher(object):
def _has_error(self, response):
return response and response[-1].startswith(u'ACK')
### Filter: call handler
def _call_handler_filter(self, request, response, filter_chain):
try:
response = self._format_response(self._call_handler(request))
return self._call_next_filter(request, response, filter_chain)
except ActorDeadError as e:
except pykka.ActorDeadError as e:
logger.warning(u'Tried to communicate with dead actor.')
raise exceptions.MpdSystemError(e)
@ -176,14 +165,15 @@ class MpdDispatcher(object):
return handler(self.context, **kwargs)
def _find_handler(self, request):
for pattern in request_handlers:
for pattern in protocol.request_handlers:
matches = re.match(pattern, request)
if matches is not None:
return (request_handlers[pattern], matches.groupdict())
return (
protocol.request_handlers[pattern], matches.groupdict())
command_name = request.split(' ')[0]
if command_name in [command.name for command in mpd_commands]:
raise exceptions.MpdArgError(u'incorrect arguments',
command=command_name)
if command_name in [command.name for command in protocol.mpd_commands]:
raise exceptions.MpdArgError(
u'incorrect arguments', command=command_name)
raise exceptions.MpdUnknownCommand(command=command_name)
def _format_response(self, response):
@ -196,10 +186,19 @@ class MpdDispatcher(object):
if result is None:
return []
if isinstance(result, set):
return flatten(list(result))
return self._flatten(list(result))
if not isinstance(result, list):
return [result]
return flatten(result)
return self._flatten(result)
def _flatten(self, the_list):
result = []
for element in the_list:
if isinstance(element, list):
result.extend(self._flatten(element))
else:
result.append(element)
return result
def _format_lines(self, line):
if isinstance(line, dict):
@ -222,27 +221,18 @@ class MpdContext(object):
#: The current :class:`mopidy.frontends.mpd.MpdSession`.
session = None
#: The Mopidy core API. An instance of :class:`mopidy.core.Core`.
core = None
#: The active subsystems that have pending events.
events = None
#: The subsytems that we want to be notified about in idle mode.
subscriptions = None
def __init__(self, dispatcher, session=None):
def __init__(self, dispatcher, session=None, core=None):
self.dispatcher = dispatcher
self.session = session
self.core = core
self.events = set()
self.subscriptions = set()
self._backend = None
@property
def backend(self):
"""
The backend. An instance of :class:`mopidy.backends.base.Backend`.
"""
if self._backend is None:
backend_refs = ActorRegistry.get_by_class(Backend)
assert len(backend_refs) == 1, \
'Expected exactly one running backend.'
self._backend = backend_refs[0].proxy()
return self._backend

View File

@ -1,4 +1,5 @@
from mopidy import MopidyException
from mopidy.exceptions import MopidyException
class MpdAckError(MopidyException):
"""See fields on this class for available MPD error codes"""
@ -33,12 +34,15 @@ class MpdAckError(MopidyException):
return u'ACK [%i@%i] {%s} %s' % (
self.__class__.error_code, self.index, self.command, self.message)
class MpdArgError(MpdAckError):
error_code = MpdAckError.ACK_ERROR_ARG
class MpdPasswordError(MpdAckError):
error_code = MpdAckError.ACK_ERROR_PASSWORD
class MpdPermissionError(MpdAckError):
error_code = MpdAckError.ACK_ERROR_PERMISSION
@ -46,6 +50,7 @@ class MpdPermissionError(MpdAckError):
super(MpdPermissionError, self).__init__(*args, **kwargs)
self.message = u'you don\'t have permission for "%s"' % self.command
class MpdUnknownCommand(MpdAckError):
error_code = MpdAckError.ACK_ERROR_UNKNOWN
@ -54,12 +59,15 @@ class MpdUnknownCommand(MpdAckError):
self.message = u'unknown command "%s"' % self.command
self.command = u''
class MpdNoExistError(MpdAckError):
error_code = MpdAckError.ACK_ERROR_NO_EXIST
class MpdSystemError(MpdAckError):
error_code = MpdAckError.ACK_ERROR_SYSTEM
class MpdNotImplemented(MpdAckError):
error_code = 0

View File

@ -24,11 +24,13 @@ VERSION = u'0.16.0'
MpdCommand = namedtuple('MpdCommand', ['name', 'auth_required'])
#: List of all available commands, represented as :class:`MpdCommand` objects.
#: Set of all available commands, represented as :class:`MpdCommand` objects.
mpd_commands = set()
#: Map between request matchers and request handler functions.
request_handlers = {}
def handle_request(pattern, auth_required=True):
"""
Decorator for connecting command handlers to command requests.
@ -60,3 +62,15 @@ def handle_request(pattern, auth_required=True):
pattern, func.__doc__ or '')
return func
return decorator
def load_protocol_modules():
"""
The protocol modules must be imported to get them registered in
:attr:`request_handlers` and :attr:`mpd_commands`.
"""
# pylint: disable = W0612
from . import ( # noqa
audio_output, command_list, connection, current_playlist, empty,
music_db, playback, reflection, status, stickers, stored_playlists)
# pylint: enable = W0612

View File

@ -1,6 +1,7 @@
from mopidy.frontends.mpd.protocol import handle_request
from mopidy.frontends.mpd.exceptions import MpdNotImplemented
@handle_request(r'^disableoutput "(?P<outputid>\d+)"$')
def disableoutput(context, outputid):
"""
@ -10,7 +11,8 @@ def disableoutput(context, outputid):
Turns an output off.
"""
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^enableoutput "(?P<outputid>\d+)"$')
def enableoutput(context, outputid):
@ -21,7 +23,8 @@ def enableoutput(context, outputid):
Turns an output on.
"""
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^outputs$')
def outputs(context):

View File

@ -1,6 +1,7 @@
from mopidy.frontends.mpd.protocol import handle_request
from mopidy.frontends.mpd.exceptions import MpdUnknownCommand
@handle_request(r'^command_list_begin$')
def command_list_begin(context):
"""
@ -18,17 +19,19 @@ def command_list_begin(context):
returned. If ``command_list_ok_begin`` is used, ``list_OK`` is
returned for each successful command executed in the command list.
"""
context.dispatcher.command_list = []
context.dispatcher.command_list_receiving = True
context.dispatcher.command_list_ok = False
context.dispatcher.command_list = []
@handle_request(r'^command_list_end$')
def command_list_end(context):
"""See :meth:`command_list_begin()`."""
if context.dispatcher.command_list is False:
# Test for False exactly, and not e.g. empty list
if not context.dispatcher.command_list_receiving:
raise MpdUnknownCommand(command='command_list_end')
context.dispatcher.command_list_receiving = False
(command_list, context.dispatcher.command_list) = (
context.dispatcher.command_list, False)
context.dispatcher.command_list, [])
(command_list_ok, context.dispatcher.command_list_ok) = (
context.dispatcher.command_list_ok, False)
command_list_response = []
@ -43,8 +46,10 @@ def command_list_end(context):
command_list_response.append(u'list_OK')
return command_list_response
@handle_request(r'^command_list_ok_begin$')
def command_list_ok_begin(context):
"""See :meth:`command_list_begin()`."""
context.dispatcher.command_list = []
context.dispatcher.command_list_receiving = True
context.dispatcher.command_list_ok = True
context.dispatcher.command_list = []

View File

@ -1,7 +1,8 @@
from mopidy import settings
from mopidy.frontends.mpd.protocol import handle_request
from mopidy.frontends.mpd.exceptions import (MpdPasswordError,
MpdPermissionError)
from mopidy.frontends.mpd.exceptions import (
MpdPasswordError, MpdPermissionError)
@handle_request(r'^close$', auth_required=False)
def close(context):
@ -14,6 +15,7 @@ def close(context):
"""
context.session.close()
@handle_request(r'^kill$')
def kill(context):
"""
@ -25,6 +27,7 @@ def kill(context):
"""
raise MpdPermissionError(command=u'kill')
@handle_request(r'^password "(?P<password>[^"]+)"$', auth_required=False)
def password_(context, password):
"""
@ -40,6 +43,7 @@ def password_(context, password):
else:
raise MpdPasswordError(u'incorrect password', command=u'password')
@handle_request(r'^ping$', auth_required=False)
def ping(context):
"""

View File

@ -1,8 +1,8 @@
from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError,
MpdNotImplemented)
from mopidy.frontends.mpd import translator
from mopidy.frontends.mpd.exceptions import (
MpdArgError, MpdNoExistError, MpdNotImplemented)
from mopidy.frontends.mpd.protocol import handle_request
from mopidy.frontends.mpd.translator import (track_to_mpd_format,
tracks_to_mpd_format)
@handle_request(r'^add "(?P<uri>[^"]*)"$')
def add(context, uri):
@ -20,15 +20,16 @@ def add(context, uri):
"""
if not uri:
return
for uri_scheme in context.backend.uri_schemes.get():
for uri_scheme in context.core.uri_schemes.get():
if uri.startswith(uri_scheme):
track = context.backend.library.lookup(uri).get()
track = context.core.library.lookup(uri).get()
if track is not None:
context.backend.current_playlist.add(track)
context.core.current_playlist.add(track)
return
raise MpdNoExistError(
u'directory or file not found', command=u'add')
@handle_request(r'^addid "(?P<uri>[^"]*)"( "(?P<songpos>\d+)")*$')
def addid(context, uri, songpos=None):
"""
@ -52,15 +53,16 @@ def addid(context, uri, songpos=None):
raise MpdNoExistError(u'No such song', command=u'addid')
if songpos is not None:
songpos = int(songpos)
track = context.backend.library.lookup(uri).get()
track = context.core.library.lookup(uri).get()
if track is None:
raise MpdNoExistError(u'No such song', command=u'addid')
if songpos and songpos > context.backend.current_playlist.length.get():
if songpos and songpos > context.core.current_playlist.length.get():
raise MpdArgError(u'Bad song index', command=u'addid')
cp_track = context.backend.current_playlist.add(track,
at_position=songpos).get()
cp_track = context.core.current_playlist.add(
track, at_position=songpos).get()
return ('Id', cp_track.cpid)
@handle_request(r'^delete "(?P<start>\d+):(?P<end>\d+)*"$')
def delete_range(context, start, end=None):
"""
@ -74,24 +76,26 @@ def delete_range(context, start, end=None):
if end is not None:
end = int(end)
else:
end = context.backend.current_playlist.length.get()
cp_tracks = context.backend.current_playlist.slice(start, end).get()
end = context.core.current_playlist.length.get()
cp_tracks = context.core.current_playlist.slice(start, end).get()
if not cp_tracks:
raise MpdArgError(u'Bad song index', command=u'delete')
for (cpid, _) in cp_tracks:
context.backend.current_playlist.remove(cpid=cpid)
context.core.current_playlist.remove(cpid=cpid)
@handle_request(r'^delete "(?P<songpos>\d+)"$')
def delete_songpos(context, songpos):
"""See :meth:`delete_range`"""
try:
songpos = int(songpos)
(cpid, _) = context.backend.current_playlist.slice(
(cpid, _) = context.core.current_playlist.slice(
songpos, songpos + 1).get()[0]
context.backend.current_playlist.remove(cpid=cpid)
context.core.current_playlist.remove(cpid=cpid)
except IndexError:
raise MpdArgError(u'Bad song index', command=u'delete')
@handle_request(r'^deleteid "(?P<cpid>\d+)"$')
def deleteid(context, cpid):
"""
@ -103,12 +107,13 @@ def deleteid(context, cpid):
"""
try:
cpid = int(cpid)
if context.backend.playback.current_cpid.get() == cpid:
context.backend.playback.next()
return context.backend.current_playlist.remove(cpid=cpid).get()
if context.core.playback.current_cpid.get() == cpid:
context.core.playback.next()
return context.core.current_playlist.remove(cpid=cpid).get()
except LookupError:
raise MpdNoExistError(u'No such song', command=u'deleteid')
@handle_request(r'^clear$')
def clear(context):
"""
@ -118,7 +123,8 @@ def clear(context):
Clears the current playlist.
"""
context.backend.current_playlist.clear()
context.core.current_playlist.clear()
@handle_request(r'^move "(?P<start>\d+):(?P<end>\d+)*" "(?P<to>\d+)"$')
def move_range(context, start, to, end=None):
@ -131,18 +137,20 @@ def move_range(context, start, to, end=None):
``TO`` in the playlist.
"""
if end is None:
end = context.backend.current_playlist.length.get()
end = context.core.current_playlist.length.get()
start = int(start)
end = int(end)
to = int(to)
context.backend.current_playlist.move(start, end, to)
context.core.current_playlist.move(start, end, to)
@handle_request(r'^move "(?P<songpos>\d+)" "(?P<to>\d+)"$')
def move_songpos(context, songpos, to):
"""See :meth:`move_range`."""
songpos = int(songpos)
to = int(to)
context.backend.current_playlist.move(songpos, songpos + 1, to)
context.core.current_playlist.move(songpos, songpos + 1, to)
@handle_request(r'^moveid "(?P<cpid>\d+)" "(?P<to>\d+)"$')
def moveid(context, cpid, to):
@ -157,9 +165,10 @@ def moveid(context, cpid, to):
"""
cpid = int(cpid)
to = int(to)
cp_track = context.backend.current_playlist.get(cpid=cpid).get()
position = context.backend.current_playlist.index(cp_track).get()
context.backend.current_playlist.move(position, position + 1, to)
cp_track = context.core.current_playlist.get(cpid=cpid).get()
position = context.core.current_playlist.index(cp_track).get()
context.core.current_playlist.move(position, position + 1, to)
@handle_request(r'^playlist$')
def playlist(context):
@ -176,6 +185,7 @@ def playlist(context):
"""
return playlistinfo(context)
@handle_request(r'^playlistfind (?P<tag>[^"]+) "(?P<needle>[^"]+)"$')
@handle_request(r'^playlistfind "(?P<tag>[^"]+)" "(?P<needle>[^"]+)"$')
def playlistfind(context, tag, needle):
@ -192,12 +202,13 @@ def playlistfind(context, tag, needle):
"""
if tag == 'filename':
try:
cp_track = context.backend.current_playlist.get(uri=needle).get()
position = context.backend.current_playlist.index(cp_track).get()
return track_to_mpd_format(cp_track, position=position)
cp_track = context.core.current_playlist.get(uri=needle).get()
position = context.core.current_playlist.index(cp_track).get()
return translator.track_to_mpd_format(cp_track, position=position)
except LookupError:
return None
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^playlistid( "(?P<cpid>\d+)")*$')
def playlistid(context, cpid=None):
@ -212,21 +223,21 @@ def playlistid(context, cpid=None):
if cpid is not None:
try:
cpid = int(cpid)
cp_track = context.backend.current_playlist.get(cpid=cpid).get()
position = context.backend.current_playlist.index(cp_track).get()
return track_to_mpd_format(cp_track, position=position)
cp_track = context.core.current_playlist.get(cpid=cpid).get()
position = context.core.current_playlist.index(cp_track).get()
return translator.track_to_mpd_format(cp_track, position=position)
except LookupError:
raise MpdNoExistError(u'No such song', command=u'playlistid')
else:
return tracks_to_mpd_format(
context.backend.current_playlist.cp_tracks.get())
return translator.tracks_to_mpd_format(
context.core.current_playlist.cp_tracks.get())
@handle_request(r'^playlistinfo$')
@handle_request(r'^playlistinfo "-1"$')
@handle_request(r'^playlistinfo "(?P<songpos>-?\d+)"$')
@handle_request(r'^playlistinfo "(?P<start>\d+):(?P<end>\d+)*"$')
def playlistinfo(context, songpos=None,
start=None, end=None):
def playlistinfo(context, songpos=None, start=None, end=None):
"""
*musicpd.org, current playlist section:*
@ -243,20 +254,21 @@ def playlistinfo(context, songpos=None,
"""
if songpos is not None:
songpos = int(songpos)
cp_track = context.backend.current_playlist.cp_tracks.get()[songpos]
return track_to_mpd_format(cp_track, position=songpos)
cp_track = context.core.current_playlist.cp_tracks.get()[songpos]
return translator.track_to_mpd_format(cp_track, position=songpos)
else:
if start is None:
start = 0
start = int(start)
if not (0 <= start <= context.backend.current_playlist.length.get()):
if not (0 <= start <= context.core.current_playlist.length.get()):
raise MpdArgError(u'Bad song index', command=u'playlistinfo')
if end is not None:
end = int(end)
if end > context.backend.current_playlist.length.get():
if end > context.core.current_playlist.length.get():
end = None
cp_tracks = context.backend.current_playlist.cp_tracks.get()
return tracks_to_mpd_format(cp_tracks, start, end)
cp_tracks = context.core.current_playlist.cp_tracks.get()
return translator.tracks_to_mpd_format(cp_tracks, start, end)
@handle_request(r'^playlistsearch "(?P<tag>[^"]+)" "(?P<needle>[^"]+)"$')
@handle_request(r'^playlistsearch (?P<tag>\S+) "(?P<needle>[^"]+)"$')
@ -274,7 +286,8 @@ def playlistsearch(context, tag, needle):
- does not add quotes around the tag
- uses ``filename`` and ``any`` as tags
"""
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^plchanges (?P<version>-?\d+)$')
@handle_request(r'^plchanges "(?P<version>-?\d+)"$')
@ -294,9 +307,10 @@ def plchanges(context, version):
- Calls ``plchanges "-1"`` two times per second to get the entire playlist.
"""
# XXX Naive implementation that returns all tracks as changed
if int(version) < context.backend.current_playlist.version:
return tracks_to_mpd_format(
context.backend.current_playlist.cp_tracks.get())
if int(version) < context.core.current_playlist.version.get():
return translator.tracks_to_mpd_format(
context.core.current_playlist.cp_tracks.get())
@handle_request(r'^plchangesposid "(?P<version>\d+)"$')
def plchangesposid(context, version):
@ -313,14 +327,15 @@ def plchangesposid(context, version):
``playlistlength`` returned by status command.
"""
# XXX Naive implementation that returns all tracks as changed
if int(version) != context.backend.current_playlist.version.get():
if int(version) != context.core.current_playlist.version.get():
result = []
for (position, (cpid, _)) in enumerate(
context.backend.current_playlist.cp_tracks.get()):
context.core.current_playlist.cp_tracks.get()):
result.append((u'cpos', position))
result.append((u'Id', cpid))
return result
@handle_request(r'^shuffle$')
@handle_request(r'^shuffle "(?P<start>\d+):(?P<end>\d+)*"$')
def shuffle(context, start=None, end=None):
@ -336,7 +351,8 @@ def shuffle(context, start=None, end=None):
start = int(start)
if end is not None:
end = int(end)
context.backend.current_playlist.shuffle(start, end)
context.core.current_playlist.shuffle(start, end)
@handle_request(r'^swap "(?P<songpos1>\d+)" "(?P<songpos2>\d+)"$')
def swap(context, songpos1, songpos2):
@ -349,15 +365,16 @@ def swap(context, songpos1, songpos2):
"""
songpos1 = int(songpos1)
songpos2 = int(songpos2)
tracks = context.backend.current_playlist.tracks.get()
tracks = context.core.current_playlist.tracks.get()
song1 = tracks[songpos1]
song2 = tracks[songpos2]
del tracks[songpos1]
tracks.insert(songpos1, song2)
del tracks[songpos2]
tracks.insert(songpos2, song1)
context.backend.current_playlist.clear()
context.backend.current_playlist.append(tracks)
context.core.current_playlist.clear()
context.core.current_playlist.append(tracks)
@handle_request(r'^swapid "(?P<cpid1>\d+)" "(?P<cpid2>\d+)"$')
def swapid(context, cpid1, cpid2):
@ -370,8 +387,8 @@ def swapid(context, cpid1, cpid2):
"""
cpid1 = int(cpid1)
cpid2 = int(cpid2)
cp_track1 = context.backend.current_playlist.get(cpid=cpid1).get()
cp_track2 = context.backend.current_playlist.get(cpid=cpid2).get()
position1 = context.backend.current_playlist.index(cp_track1).get()
position2 = context.backend.current_playlist.index(cp_track2).get()
cp_track1 = context.core.current_playlist.get(cpid=cpid1).get()
cp_track2 = context.core.current_playlist.get(cpid=cpid2).get()
position1 = context.core.current_playlist.index(cp_track1).get()
position2 = context.core.current_playlist.index(cp_track2).get()
swap(context, position1, position2)

View File

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

View File

@ -5,6 +5,7 @@ from mopidy.frontends.mpd.exceptions import MpdArgError, MpdNotImplemented
from mopidy.frontends.mpd.protocol import handle_request, stored_playlists
from mopidy.frontends.mpd.translator import playlist_to_mpd_format
def _build_query(mpd_query):
"""
Parses a MPD query string and converts it to the Mopidy query format.
@ -21,7 +22,7 @@ def _build_query(mpd_query):
field = m.groupdict()['field'].lower()
if field == u'title':
field = u'track'
field = str(field) # Needed for kwargs keys on OS X and Windows
field = str(field) # Needed for kwargs keys on OS X and Windows
what = m.groupdict()['what'].lower()
if field in query:
query[field].append(what)
@ -29,6 +30,7 @@ def _build_query(mpd_query):
query[field] = [what]
return query
@handle_request(r'^count "(?P<tag>[^"]+)" "(?P<needle>[^"]*)"$')
def count(context, tag, needle):
"""
@ -39,11 +41,12 @@ def count(context, tag, needle):
Counts the number of songs and their total playtime in the db
matching ``TAG`` exactly.
"""
return [('songs', 0), ('playtime', 0)] # TODO
return [('songs', 0), ('playtime', 0)] # TODO
@handle_request(r'^find '
r'(?P<mpd_query>("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|'
r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$')
@handle_request(
r'^find (?P<mpd_query>("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|'
r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$')
def find(context, mpd_query):
"""
*musicpd.org, music database section:*
@ -70,11 +73,13 @@ def find(context, mpd_query):
"""
query = _build_query(mpd_query)
return playlist_to_mpd_format(
context.backend.library.find_exact(**query).get())
context.core.library.find_exact(**query).get())
@handle_request(r'^findadd '
r'(?P<query>("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? '
'"[^"]+"\s?)+)$')
@handle_request(
r'^findadd '
r'(?P<query>("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? '
r'"[^"]+"\s?)+)$')
def findadd(context, query):
"""
*musicpd.org, music database section:*
@ -88,8 +93,10 @@ def findadd(context, query):
# TODO Add result to current playlist
#result = context.find(query)
@handle_request(r'^list "?(?P<field>([Aa]rtist|[Aa]lbum|[Dd]ate|[Gg]enre))"?'
'( (?P<mpd_query>.*))?$')
@handle_request(
r'^list "?(?P<field>([Aa]rtist|[Aa]lbum|[Dd]ate|[Gg]enre))"?'
r'( (?P<mpd_query>.*))?$')
def list_(context, field, mpd_query=None):
"""
*musicpd.org, music database section:*
@ -183,7 +190,8 @@ def list_(context, field, mpd_query=None):
elif field == u'date':
return _list_date(context, query)
elif field == u'genre':
pass # TODO We don't have genre in our internal data structures yet
pass # TODO We don't have genre in our internal data structures yet
def _list_build_query(field, mpd_query):
"""Converts a ``list`` query to a Mopidy query."""
@ -208,7 +216,7 @@ def _list_build_query(field, mpd_query):
query = {}
while tokens:
key = tokens[0].lower()
key = str(key) # Needed for kwargs keys on OS X and Windows
key = str(key) # Needed for kwargs keys on OS X and Windows
value = tokens[1]
tokens = tokens[2:]
if key not in (u'artist', u'album', u'date', u'genre'):
@ -221,30 +229,34 @@ def _list_build_query(field, mpd_query):
else:
raise MpdArgError(u'not able to parse args', command=u'list')
def _list_artist(context, query):
artists = set()
playlist = context.backend.library.find_exact(**query).get()
playlist = context.core.library.find_exact(**query).get()
for track in playlist.tracks:
for artist in track.artists:
artists.add((u'Artist', artist.name))
return artists
def _list_album(context, query):
albums = set()
playlist = context.backend.library.find_exact(**query).get()
playlist = context.core.library.find_exact(**query).get()
for track in playlist.tracks:
if track.album is not None:
albums.add((u'Album', track.album.name))
return albums
def _list_date(context, query):
dates = set()
playlist = context.backend.library.find_exact(**query).get()
playlist = context.core.library.find_exact(**query).get()
for track in playlist.tracks:
if track.date is not None:
dates.add((u'Date', track.date))
return dates
@handle_request(r'^listall "(?P<uri>[^"]+)"')
def listall(context, uri):
"""
@ -254,7 +266,8 @@ def listall(context, uri):
Lists all songs and directories in ``URI``.
"""
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^listallinfo "(?P<uri>[^"]+)"')
def listallinfo(context, uri):
@ -266,7 +279,8 @@ def listallinfo(context, uri):
Same as ``listall``, except it also returns metadata info in the
same format as ``lsinfo``.
"""
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^lsinfo$')
@handle_request(r'^lsinfo "(?P<uri>[^"]*)"$')
@ -288,7 +302,8 @@ def lsinfo(context, uri=None):
"""
if uri is None or uri == u'/' or uri == u'':
return stored_playlists.listplaylists(context)
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^rescan( "(?P<uri>[^"]+)")*$')
def rescan(context, uri=None):
@ -301,9 +316,10 @@ def rescan(context, uri=None):
"""
return update(context, uri, rescan_unmodified_files=True)
@handle_request(r'^search '
r'(?P<mpd_query>("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|'
r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$')
@handle_request(
r'^search (?P<mpd_query>("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|'
r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$')
def search(context, mpd_query):
"""
*musicpd.org, music database section:*
@ -333,7 +349,8 @@ def search(context, mpd_query):
"""
query = _build_query(mpd_query)
return playlist_to_mpd_format(
context.backend.library.search(**query).get())
context.core.library.search(**query).get())
@handle_request(r'^update( "(?P<uri>[^"]+)")*$')
def update(context, uri=None, rescan_unmodified_files=False):
@ -352,4 +369,4 @@ def update(context, uri=None, rescan_unmodified_files=False):
identifying the update job. You can read the current job id in the
``status`` response.
"""
return {'updating_db': 0} # TODO
return {'updating_db': 0} # TODO

View File

@ -1,7 +1,8 @@
from mopidy.core import PlaybackState
from mopidy.frontends.mpd.protocol import handle_request
from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError,
MpdNotImplemented)
from mopidy.frontends.mpd.exceptions import (
MpdArgError, MpdNoExistError, MpdNotImplemented)
@handle_request(r'^consume (?P<state>[01])$')
@handle_request(r'^consume "(?P<state>[01])"$')
@ -16,9 +17,10 @@ def consume(context, state):
playlist.
"""
if int(state):
context.backend.playback.consume = True
context.core.playback.consume = True
else:
context.backend.playback.consume = False
context.core.playback.consume = False
@handle_request(r'^crossfade "(?P<seconds>\d+)"$')
def crossfade(context, seconds):
@ -30,7 +32,8 @@ def crossfade(context, seconds):
Sets crossfading between songs.
"""
seconds = int(seconds)
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^next$')
def next_(context):
@ -87,7 +90,8 @@ def next_(context):
order as the first time.
"""
return context.backend.playback.next().get()
return context.core.playback.next().get()
@handle_request(r'^pause$')
@handle_request(r'^pause "(?P<state>[01])"$')
@ -104,14 +108,15 @@ def pause(context, state=None):
- Calls ``pause`` without any arguments to toogle pause.
"""
if state is None:
if (context.backend.playback.state.get() == PlaybackState.PLAYING):
context.backend.playback.pause()
elif (context.backend.playback.state.get() == PlaybackState.PAUSED):
context.backend.playback.resume()
if (context.core.playback.state.get() == PlaybackState.PLAYING):
context.core.playback.pause()
elif (context.core.playback.state.get() == PlaybackState.PAUSED):
context.core.playback.resume()
elif int(state):
context.backend.playback.pause()
context.core.playback.pause()
else:
context.backend.playback.resume()
context.core.playback.resume()
@handle_request(r'^play$')
def play(context):
@ -119,10 +124,11 @@ def play(context):
The original MPD server resumes from the paused state on ``play``
without arguments.
"""
return context.backend.playback.play().get()
return context.core.playback.play().get()
@handle_request(r'^playid "(?P<cpid>\d+)"$')
@handle_request(r'^playid "(?P<cpid>-1)"$')
@handle_request(r'^playid (?P<cpid>-?\d+)$')
@handle_request(r'^playid "(?P<cpid>-?\d+)"$')
def playid(context, cpid):
"""
*musicpd.org, playback section:*
@ -144,11 +150,12 @@ def playid(context, cpid):
if cpid == -1:
return _play_minus_one(context)
try:
cp_track = context.backend.current_playlist.get(cpid=cpid).get()
return context.backend.playback.play(cp_track).get()
cp_track = context.core.current_playlist.get(cpid=cpid).get()
return context.core.playback.play(cp_track).get()
except LookupError:
raise MpdNoExistError(u'No such song', command=u'playid')
@handle_request(r'^play (?P<songpos>-?\d+)$')
@handle_request(r'^play "(?P<songpos>-?\d+)"$')
def playpos(context, songpos):
@ -161,11 +168,11 @@ def playpos(context, songpos):
*Clarifications:*
- ``playid "-1"`` when playing is ignored.
- ``playid "-1"`` when paused resumes playback.
- ``playid "-1"`` when stopped with a current track starts playback at the
- ``play "-1"`` when playing is ignored.
- ``play "-1"`` when paused resumes playback.
- ``play "-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
- ``play "-1"`` when stopped without a current track, e.g. after playlist
replacement, starts playback at the first track.
*BitMPC:*
@ -176,25 +183,27 @@ def playpos(context, songpos):
if songpos == -1:
return _play_minus_one(context)
try:
cp_track = context.backend.current_playlist.slice(
cp_track = context.core.current_playlist.slice(
songpos, songpos + 1).get()[0]
return context.backend.playback.play(cp_track).get()
return context.core.playback.play(cp_track).get()
except IndexError:
raise MpdArgError(u'Bad song index', command=u'play')
def _play_minus_one(context):
if (context.backend.playback.state.get() == PlaybackState.PLAYING):
return # Nothing to do
elif (context.backend.playback.state.get() == PlaybackState.PAUSED):
return context.backend.playback.resume().get()
elif context.backend.playback.current_cp_track.get() is not None:
cp_track = context.backend.playback.current_cp_track.get()
return context.backend.playback.play(cp_track).get()
elif context.backend.current_playlist.slice(0, 1).get():
cp_track = context.backend.current_playlist.slice(0, 1).get()[0]
return context.backend.playback.play(cp_track).get()
if (context.core.playback.state.get() == PlaybackState.PLAYING):
return # Nothing to do
elif (context.core.playback.state.get() == PlaybackState.PAUSED):
return context.core.playback.resume().get()
elif context.core.playback.current_cp_track.get() is not None:
cp_track = context.core.playback.current_cp_track.get()
return context.core.playback.play(cp_track).get()
elif context.core.current_playlist.slice(0, 1).get():
cp_track = context.core.current_playlist.slice(0, 1).get()[0]
return context.core.playback.play(cp_track).get()
else:
return # Fail silently
return # Fail silently
@handle_request(r'^previous$')
def previous(context):
@ -240,7 +249,8 @@ def previous(context):
``previous`` should do a seek to time position 0.
"""
return context.backend.playback.previous().get()
return context.core.playback.previous().get()
@handle_request(r'^random (?P<state>[01])$')
@handle_request(r'^random "(?P<state>[01])"$')
@ -253,9 +263,10 @@ def random(context, state):
Sets random state to ``STATE``, ``STATE`` should be 0 or 1.
"""
if int(state):
context.backend.playback.random = True
context.core.playback.random = True
else:
context.backend.playback.random = False
context.core.playback.random = False
@handle_request(r'^repeat (?P<state>[01])$')
@handle_request(r'^repeat "(?P<state>[01])"$')
@ -268,9 +279,10 @@ def repeat(context, state):
Sets repeat state to ``STATE``, ``STATE`` should be 0 or 1.
"""
if int(state):
context.backend.playback.repeat = True
context.core.playback.repeat = True
else:
context.backend.playback.repeat = False
context.core.playback.repeat = False
@handle_request(r'^replay_gain_mode "(?P<mode>(off|track|album))"$')
def replay_gain_mode(context, mode):
@ -286,7 +298,8 @@ def replay_gain_mode(context, mode):
This command triggers the options idle event.
"""
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^replay_gain_status$')
def replay_gain_status(context):
@ -298,7 +311,8 @@ def replay_gain_status(context):
Prints replay gain options. Currently, only the variable
``replay_gain_mode`` is returned.
"""
return u'off' # TODO
return u'off' # TODO
@handle_request(r'^seek (?P<songpos>\d+) (?P<seconds>\d+)$')
@handle_request(r'^seek "(?P<songpos>\d+)" "(?P<seconds>\d+)"$')
@ -315,9 +329,10 @@ def seek(context, songpos, seconds):
- issues ``seek 1 120`` without quotes around the arguments.
"""
if context.backend.playback.current_playlist_position != songpos:
if context.core.playback.current_playlist_position != songpos:
playpos(context, songpos)
context.backend.playback.seek(int(seconds) * 1000)
context.core.playback.seek(int(seconds) * 1000)
@handle_request(r'^seekid "(?P<cpid>\d+)" "(?P<seconds>\d+)"$')
def seekid(context, cpid, seconds):
@ -328,9 +343,10 @@ def seekid(context, cpid, seconds):
Seeks to the position ``TIME`` (in seconds) of song ``SONGID``.
"""
if context.backend.playback.current_cpid != cpid:
if context.core.playback.current_cpid != cpid:
playid(context, cpid)
context.backend.playback.seek(int(seconds) * 1000)
context.core.playback.seek(int(seconds) * 1000)
@handle_request(r'^setvol (?P<volume>[-+]*\d+)$')
@handle_request(r'^setvol "(?P<volume>[-+]*\d+)"$')
@ -351,7 +367,8 @@ def setvol(context, volume):
volume = 0
if volume > 100:
volume = 100
context.backend.playback.volume = volume
context.core.playback.volume = volume
@handle_request(r'^single (?P<state>[01])$')
@handle_request(r'^single "(?P<state>[01])"$')
@ -366,9 +383,10 @@ def single(context, state):
song is repeated if the ``repeat`` mode is enabled.
"""
if int(state):
context.backend.playback.single = True
context.core.playback.single = True
else:
context.backend.playback.single = False
context.core.playback.single = False
@handle_request(r'^stop$')
def stop(context):
@ -379,4 +397,4 @@ def stop(context):
Stops playing.
"""
context.backend.playback.stop()
context.core.playback.stop()

View File

@ -1,6 +1,7 @@
from mopidy.frontends.mpd.protocol import handle_request, mpd_commands
from mopidy.frontends.mpd.exceptions import MpdNotImplemented
@handle_request(r'^commands$', auth_required=False)
def commands(context):
"""
@ -13,16 +14,20 @@ def commands(context):
if context.dispatcher.authenticated:
command_names = set([command.name for command in mpd_commands])
else:
command_names = set([command.name for command in mpd_commands
command_names = set([
command.name for command in mpd_commands
if not command.auth_required])
# No one is permited to use kill, rest of commands are not listed by MPD,
# so we shouldn't either.
command_names = command_names - set(['kill', 'command_list_begin',
'command_list_ok_begin', 'command_list_ok_begin', 'command_list_end',
'idle', 'noidle', 'sticker'])
command_names = command_names - set([
'kill', 'command_list_begin', 'command_list_ok_begin',
'command_list_ok_begin', 'command_list_end', 'idle', 'noidle',
'sticker'])
return [
('command', command_name) for command_name in sorted(command_names)]
return [('command', command_name) for command_name in sorted(command_names)]
@handle_request(r'^decoders$')
def decoders(context):
@ -41,7 +46,8 @@ def decoders(context):
plugin: mpcdec
suffix: mpc
"""
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^notcommands$', auth_required=False)
def notcommands(context):
@ -55,13 +61,15 @@ def notcommands(context):
if context.dispatcher.authenticated:
command_names = []
else:
command_names = [command.name for command in mpd_commands
if command.auth_required]
command_names = [
command.name for command in mpd_commands if command.auth_required]
# No permission to use
command_names.append('kill')
return [('command', command_name) for command_name in sorted(command_names)]
return [
('command', command_name) for command_name in sorted(command_names)]
@handle_request(r'^tagtypes$')
def tagtypes(context):
@ -72,7 +80,8 @@ def tagtypes(context):
Shows a list of available song metadata.
"""
pass # TODO
pass # TODO
@handle_request(r'^urlhandlers$')
def urlhandlers(context):
@ -83,5 +92,6 @@ def urlhandlers(context):
Gets a list of available URL handlers.
"""
return [(u'handler', uri_scheme)
for uri_scheme in context.backend.uri_schemes.get()]
return [
(u'handler', uri_scheme)
for uri_scheme in context.core.uri_schemes.get()]

View File

@ -1,4 +1,4 @@
import pykka.future
import pykka
from mopidy.core import PlaybackState
from mopidy.frontends.mpd.exceptions import MpdNotImplemented
@ -6,8 +6,10 @@ from mopidy.frontends.mpd.protocol import handle_request
from mopidy.frontends.mpd.translator import track_to_mpd_format
#: Subsystems that can be registered with idle command.
SUBSYSTEMS = ['database', 'mixer', 'options', 'output',
'player', 'playlist', 'stored_playlist', 'update', ]
SUBSYSTEMS = [
'database', 'mixer', 'options', 'output', 'player', 'playlist',
'stored_playlist', 'update']
@handle_request(r'^clearerror$')
def clearerror(context):
@ -19,7 +21,8 @@ def clearerror(context):
Clears the current error message in status (this is also
accomplished by any command that starts playback).
"""
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^currentsong$')
def currentsong(context):
@ -31,11 +34,12 @@ def currentsong(context):
Displays the song info of the current song (same song that is
identified in status).
"""
current_cp_track = context.backend.playback.current_cp_track.get()
current_cp_track = context.core.playback.current_cp_track.get()
if current_cp_track is not None:
position = context.backend.playback.current_playlist_position.get()
position = context.core.playback.current_playlist_position.get()
return track_to_mpd_format(current_cp_track, position=position)
@handle_request(r'^idle$')
@handle_request(r'^idle (?P<subsystems>.+)$')
def idle(context, subsystems=None):
@ -93,6 +97,7 @@ def idle(context, subsystems=None):
response.append(u'changed: %s' % subsystem)
return response
@handle_request(r'^noidle$')
def noidle(context):
"""See :meth:`_status_idle`."""
@ -102,6 +107,7 @@ def noidle(context):
context.events = set()
context.session.prevent_timeout = False
@handle_request(r'^stats$')
def stats(context):
"""
@ -119,15 +125,16 @@ def stats(context):
- ``playtime``: time length of music played
"""
return {
'artists': 0, # TODO
'albums': 0, # TODO
'songs': 0, # TODO
'uptime': 0, # TODO
'db_playtime': 0, # TODO
'db_update': 0, # TODO
'playtime': 0, # TODO
'artists': 0, # TODO
'albums': 0, # TODO
'songs': 0, # TODO
'uptime': 0, # TODO
'db_playtime': 0, # TODO
'db_update': 0, # TODO
'playtime': 0, # TODO
}
@handle_request(r'^status$')
def status(context):
"""
@ -153,7 +160,7 @@ def status(context):
- ``nextsongid``: playlist songid of the next song to be played
- ``time``: total time elapsed (of current playing/paused song)
- ``elapsed``: Total time elapsed within the current song, but with
higher resolution.
higher resolution.
- ``bitrate``: instantaneous bitrate in kbps
- ``xfade``: crossfade in seconds
- ``audio``: sampleRate``:bits``:channels
@ -166,20 +173,20 @@ def status(context):
decimal places for millisecond precision.
"""
futures = {
'current_playlist.length': context.backend.current_playlist.length,
'current_playlist.version': context.backend.current_playlist.version,
'playback.volume': context.backend.playback.volume,
'playback.consume': context.backend.playback.consume,
'playback.random': context.backend.playback.random,
'playback.repeat': context.backend.playback.repeat,
'playback.single': context.backend.playback.single,
'playback.state': context.backend.playback.state,
'playback.current_cp_track': context.backend.playback.current_cp_track,
'playback.current_playlist_position':
context.backend.playback.current_playlist_position,
'playback.time_position': context.backend.playback.time_position,
'current_playlist.length': context.core.current_playlist.length,
'current_playlist.version': context.core.current_playlist.version,
'playback.volume': context.core.playback.volume,
'playback.consume': context.core.playback.consume,
'playback.random': context.core.playback.random,
'playback.repeat': context.core.playback.repeat,
'playback.single': context.core.playback.single,
'playback.state': context.core.playback.state,
'playback.current_cp_track': context.core.playback.current_cp_track,
'playback.current_playlist_position': (
context.core.playback.current_playlist_position),
'playback.time_position': context.core.playback.time_position,
}
pykka.future.get_all(futures.values())
pykka.get_all(futures.values())
result = [
('volume', _status_volume(futures)),
('repeat', _status_repeat(futures)),
@ -194,39 +201,47 @@ def status(context):
if futures['playback.current_cp_track'].get() is not None:
result.append(('song', _status_songpos(futures)))
result.append(('songid', _status_songid(futures)))
if futures['playback.state'].get() in (PlaybackState.PLAYING,
PlaybackState.PAUSED):
if futures['playback.state'].get() in (
PlaybackState.PLAYING, PlaybackState.PAUSED):
result.append(('time', _status_time(futures)))
result.append(('elapsed', _status_time_elapsed(futures)))
result.append(('bitrate', _status_bitrate(futures)))
return result
def _status_bitrate(futures):
current_cp_track = futures['playback.current_cp_track'].get()
if current_cp_track is not None:
return current_cp_track.track.bitrate
def _status_consume(futures):
if futures['playback.consume'].get():
return 1
else:
return 0
def _status_playlist_length(futures):
return futures['current_playlist.length'].get()
def _status_playlist_version(futures):
return futures['current_playlist.version'].get()
def _status_random(futures):
return int(futures['playback.random'].get())
def _status_repeat(futures):
return int(futures['playback.repeat'].get())
def _status_single(futures):
return int(futures['playback.single'].get())
def _status_songid(futures):
current_cp_track = futures['playback.current_cp_track'].get()
if current_cp_track is not None:
@ -234,9 +249,11 @@ def _status_songid(futures):
else:
return _status_songpos(futures)
def _status_songpos(futures):
return futures['playback.current_playlist_position'].get()
def _status_state(futures):
state = futures['playback.state'].get()
if state == PlaybackState.PLAYING:
@ -246,13 +263,17 @@ def _status_state(futures):
elif state == PlaybackState.PAUSED:
return u'pause'
def _status_time(futures):
return u'%d:%d' % (futures['playback.time_position'].get() // 1000,
return u'%d:%d' % (
futures['playback.time_position'].get() // 1000,
_status_time_total(futures) // 1000)
def _status_time_elapsed(futures):
return u'%.3f' % (futures['playback.time_position'].get() / 1000.0)
def _status_time_total(futures):
current_cp_track = futures['playback.current_cp_track'].get()
if current_cp_track is None:
@ -262,6 +283,7 @@ def _status_time_total(futures):
else:
return current_cp_track.track.length
def _status_volume(futures):
volume = futures['playback.volume'].get()
if volume is not None:
@ -269,5 +291,6 @@ def _status_volume(futures):
else:
return -1
def _status_xfade(futures):
return 0 # Not supported
return 0 # Not supported

View File

@ -1,7 +1,9 @@
from mopidy.frontends.mpd.protocol import handle_request
from mopidy.frontends.mpd.exceptions import MpdNotImplemented
@handle_request(r'^sticker delete "(?P<field>[^"]+)" '
@handle_request(
r'^sticker delete "(?P<field>[^"]+)" '
r'"(?P<uri>[^"]+)"( "(?P<name>[^"]+)")*$')
def sticker_delete(context, field, uri, name=None):
"""
@ -12,9 +14,11 @@ def sticker_delete(context, field, uri, name=None):
Deletes a sticker value from the specified object. If you do not
specify a sticker name, all sticker values are deleted.
"""
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^sticker find "(?P<field>[^"]+)" "(?P<uri>[^"]+)" '
@handle_request(
r'^sticker find "(?P<field>[^"]+)" "(?P<uri>[^"]+)" '
r'"(?P<name>[^"]+)"$')
def sticker_find(context, field, uri, name):
"""
@ -26,9 +30,11 @@ def sticker_find(context, field, uri, name):
below the specified directory (``URI``). For each matching song, it
prints the ``URI`` and that one sticker's value.
"""
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^sticker get "(?P<field>[^"]+)" "(?P<uri>[^"]+)" '
@handle_request(
r'^sticker get "(?P<field>[^"]+)" "(?P<uri>[^"]+)" '
r'"(?P<name>[^"]+)"$')
def sticker_get(context, field, uri, name):
"""
@ -38,7 +44,8 @@ def sticker_get(context, field, uri, name):
Reads a sticker value for the specified object.
"""
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^sticker list "(?P<field>[^"]+)" "(?P<uri>[^"]+)"$')
def sticker_list(context, field, uri):
@ -49,9 +56,11 @@ def sticker_list(context, field, uri):
Lists the stickers for the specified object.
"""
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^sticker set "(?P<field>[^"]+)" "(?P<uri>[^"]+)" '
@handle_request(
r'^sticker set "(?P<field>[^"]+)" "(?P<uri>[^"]+)" '
r'"(?P<name>[^"]+)" "(?P<value>[^"]+)"$')
def sticker_set(context, field, uri, name, value):
"""
@ -62,4 +71,4 @@ def sticker_set(context, field, uri, name, value):
Adds a sticker value to the specified object. If a sticker item
with that name already exists, it is replaced.
"""
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO

View File

@ -4,6 +4,8 @@ from mopidy.frontends.mpd.exceptions import MpdNoExistError, MpdNotImplemented
from mopidy.frontends.mpd.protocol import handle_request
from mopidy.frontends.mpd.translator import playlist_to_mpd_format
@handle_request(r'^listplaylist (?P<name>\S+)$')
@handle_request(r'^listplaylist "(?P<name>[^"]+)"$')
def listplaylist(context, name):
"""
@ -20,11 +22,13 @@ def listplaylist(context, name):
file: relative/path/to/file3.mp3
"""
try:
playlist = context.backend.stored_playlists.get(name=name).get()
playlist = context.core.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')
@handle_request(r'^listplaylistinfo (?P<name>\S+)$')
@handle_request(r'^listplaylistinfo "(?P<name>[^"]+)"$')
def listplaylistinfo(context, name):
"""
@ -40,12 +44,13 @@ def listplaylistinfo(context, name):
Album, Artist, Track
"""
try:
playlist = context.backend.stored_playlists.get(name=name).get()
playlist = context.core.stored_playlists.get(name=name).get()
return playlist_to_mpd_format(playlist)
except LookupError:
raise MpdNoExistError(
u'No such playlist', command=u'listplaylistinfo')
@handle_request(r'^listplaylists$')
def listplaylists(context):
"""
@ -68,10 +73,10 @@ def listplaylists(context):
Last-Modified: 2010-02-06T02:11:08Z
"""
result = []
for playlist in context.backend.stored_playlists.playlists.get():
for playlist in context.core.stored_playlists.playlists.get():
result.append((u'playlist', playlist.name))
last_modified = (playlist.last_modified or
dt.datetime.now()).isoformat()
last_modified = (
playlist.last_modified or dt.datetime.now()).isoformat()
# Remove microseconds
last_modified = last_modified.split('.')[0]
# Add time zone information
@ -80,6 +85,7 @@ def listplaylists(context):
result.append((u'Last-Modified', last_modified))
return result
@handle_request(r'^load "(?P<name>[^"]+)"$')
def load(context, name):
"""
@ -94,11 +100,12 @@ def load(context, name):
- ``load`` appends the given playlist to the current playlist.
"""
try:
playlist = context.backend.stored_playlists.get(name=name).get()
context.backend.current_playlist.append(playlist.tracks)
playlist = context.core.stored_playlists.get(name=name).get()
context.core.current_playlist.append(playlist.tracks)
except LookupError:
raise MpdNoExistError(u'No such playlist', command=u'load')
@handle_request(r'^playlistadd "(?P<name>[^"]+)" "(?P<uri>[^"]+)"$')
def playlistadd(context, name, uri):
"""
@ -110,7 +117,8 @@ def playlistadd(context, name, uri):
``NAME.m3u`` will be created if it does not exist.
"""
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^playlistclear "(?P<name>[^"]+)"$')
def playlistclear(context, name):
@ -121,7 +129,8 @@ def playlistclear(context, name):
Clears the playlist ``NAME.m3u``.
"""
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^playlistdelete "(?P<name>[^"]+)" "(?P<songpos>\d+)"$')
def playlistdelete(context, name, songpos):
@ -132,9 +141,11 @@ def playlistdelete(context, name, songpos):
Deletes ``SONGPOS`` from the playlist ``NAME.m3u``.
"""
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^playlistmove "(?P<name>[^"]+)" '
@handle_request(
r'^playlistmove "(?P<name>[^"]+)" '
r'"(?P<from_pos>\d+)" "(?P<to_pos>\d+)"$')
def playlistmove(context, name, from_pos, to_pos):
"""
@ -151,7 +162,8 @@ def playlistmove(context, name, from_pos, to_pos):
documentation, but just the ``SONGPOS`` to move *from*, i.e.
``playlistmove {NAME} {FROM_SONGPOS} {TO_SONGPOS}``.
"""
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^rename "(?P<old_name>[^"]+)" "(?P<new_name>[^"]+)"$')
def rename(context, old_name, new_name):
@ -162,7 +174,8 @@ def rename(context, old_name, new_name):
Renames the playlist ``NAME.m3u`` to ``NEW_NAME.m3u``.
"""
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^rm "(?P<name>[^"]+)"$')
def rm(context, name):
@ -173,7 +186,8 @@ def rm(context, name):
Removes the playlist ``NAME.m3u`` from the playlist directory.
"""
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^save "(?P<name>[^"]+)"$')
def save(context, name):
@ -185,4 +199,4 @@ def save(context, name):
Saves the current playlist to ``NAME.m3u`` in the playlist
directory.
"""
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO

View File

@ -0,0 +1,53 @@
import logging
from mopidy.frontends.mpd import dispatcher, protocol
from mopidy.utils import formatting, network
logger = logging.getLogger('mopidy.frontends.mpd')
class MpdSession(network.LineProtocol):
"""
The MPD client session. Keeps track of a single client session. Any
requests from the client is passed on to the MPD request dispatcher.
"""
terminator = protocol.LINE_TERMINATOR
encoding = protocol.ENCODING
delimiter = r'\r?\n'
def __init__(self, connection, core=None):
super(MpdSession, self).__init__(connection)
self.dispatcher = dispatcher.MpdDispatcher(session=self, core=core)
def on_start(self):
logger.info(u'New MPD connection from [%s]:%s', self.host, self.port)
self.send_lines([u'OK MPD %s' % protocol.VERSION])
def on_line_received(self, line):
logger.debug(u'Request from [%s]:%s: %s', self.host, self.port, line)
response = self.dispatcher.handle_request(line)
if not response:
return
logger.debug(
u'Response to [%s]:%s: %s', self.host, self.port,
formatting.indent(self.terminator.join(response)))
self.send_lines(response)
def on_idle(self, subsystem):
self.dispatcher.handle_idle(subsystem)
def decode(self, line):
try:
return super(MpdSession, self).decode(line.decode('string_escape'))
except ValueError:
logger.warning(
u'Stopping actor due to unescaping error, data '
u'supplied by client was not valid.')
self.stop()
def close(self):
self.stop()

View File

@ -6,6 +6,7 @@ from mopidy.frontends.mpd import protocol
from mopidy.models import CpTrack
from mopidy.utils.path import mtime as get_mtime, uri_to_path, split_path
def track_to_mpd_format(track, position=None):
"""
Format track for output to MPD client.
@ -48,8 +49,8 @@ def track_to_mpd_format(track, position=None):
# FIXME don't use first and best artist?
# FIXME don't duplicate following code?
if track.album is not None and track.album.artists:
artists = filter(lambda a: a.musicbrainz_id is not None,
track.album.artists)
artists = filter(
lambda a: a.musicbrainz_id is not None, track.album.artists)
if artists:
result.append(
('MUSICBRAINZ_ALBUMARTISTID', artists[0].musicbrainz_id))
@ -61,16 +62,19 @@ def track_to_mpd_format(track, position=None):
result.append(('MUSICBRAINZ_TRACKID', track.musicbrainz_id))
return result
MPD_KEY_ORDER = '''
key file Time Artist AlbumArtist Title Album Track Date MUSICBRAINZ_ALBUMID
MUSICBRAINZ_ALBUMARTISTID MUSICBRAINZ_ARTISTID MUSICBRAINZ_TRACKID mtime
'''.split()
def order_mpd_track_info(result):
"""
Order results from :func:`mopidy.frontends.mpd.translator.track_to_mpd_format`
so that it matches MPD's ordering. Simply a cosmetic fix for easier
diffing of tag_caches.
Order results from
:func:`mopidy.frontends.mpd.translator.track_to_mpd_format` so that it
matches MPD's ordering. Simply a cosmetic fix for easier diffing of
tag_caches.
:param result: the track info
:type result: list of tuples
@ -78,6 +82,7 @@ def order_mpd_track_info(result):
"""
return sorted(result, key=lambda i: MPD_KEY_ORDER.index(i[0]))
def artists_to_mpd_format(artists):
"""
Format track artists for output to MPD client.
@ -90,6 +95,7 @@ def artists_to_mpd_format(artists):
artists.sort(key=lambda a: a.name)
return u', '.join([a.name for a in artists if a.name])
def tracks_to_mpd_format(tracks, start=0, end=None):
"""
Format list of tracks for output to MPD client.
@ -115,6 +121,7 @@ def tracks_to_mpd_format(tracks, start=0, end=None):
result.append(track_to_mpd_format(track, position))
return result
def playlist_to_mpd_format(playlist, *args, **kwargs):
"""
Format playlist for output to MPD client.
@ -123,6 +130,7 @@ def playlist_to_mpd_format(playlist, *args, **kwargs):
"""
return tracks_to_mpd_format(playlist.tracks, *args, **kwargs)
def tracks_to_tag_cache_format(tracks):
"""
Format list of tracks for output to MPD tag cache
@ -141,6 +149,7 @@ def tracks_to_tag_cache_format(tracks):
_add_to_tag_cache(result, *tracks_to_directory_tree(tracks))
return result
def _add_to_tag_cache(result, folders, files):
music_folder = settings.LOCAL_MUSIC_PATH
regexp = '^' + re.escape(music_folder).rstrip('/') + '/?'
@ -165,6 +174,7 @@ def _add_to_tag_cache(result, folders, files):
result.extend(track_result)
result.append(('songList end',))
def tracks_to_directory_tree(tracks):
directories = ({}, [])
for track in tracks:

View File

@ -1,131 +1,54 @@
import logging
"""
Frontend which lets you control Mopidy through the Media Player Remote
Interfacing Specification (`MPRIS <http://www.mpris.org/>`_) D-Bus
interface.
logger = logging.getLogger('mopidy.frontends.mpris')
An example of an MPRIS client is the `Ubuntu Sound Menu
<https://wiki.ubuntu.com/SoundMenu>`_.
try:
import indicate
except ImportError as import_error:
indicate = None
logger.debug(u'Startup notification will not be sent (%s)', import_error)
**Dependencies:**
from pykka.actor import ThreadingActor
- D-Bus Python bindings. The package is named ``python-dbus`` in
Ubuntu/Debian.
from mopidy import settings
from mopidy.frontends.mpris import objects
from mopidy.listeners import BackendListener
- ``libindicate`` Python bindings is needed to expose Mopidy in e.g. the
Ubuntu Sound Menu. The package is named ``python-indicate`` in
Ubuntu/Debian.
- An ``.desktop`` file for Mopidy installed at the path set in
:attr:`mopidy.settings.DESKTOP_FILE`. See :ref:`install-desktop-file` for
details.
class MprisFrontend(ThreadingActor, BackendListener):
"""
Frontend which lets you control Mopidy through the Media Player Remote
Interfacing Specification (`MPRIS <http://www.mpris.org/>`_) D-Bus
interface.
**Settings:**
An example of an MPRIS client is the `Ubuntu Sound Menu
<https://wiki.ubuntu.com/SoundMenu>`_.
- :attr:`mopidy.settings.DESKTOP_FILE`
**Dependencies:**
**Usage:**
- D-Bus Python bindings. The package is named ``python-dbus`` in
Ubuntu/Debian.
- ``libindicate`` Python bindings is needed to expose Mopidy in e.g. the
Ubuntu Sound Menu. The package is named ``python-indicate`` in
Ubuntu/Debian.
- An ``.desktop`` file for Mopidy installed at the path set in
:attr:`mopidy.settings.DESKTOP_FILE`. See :ref:`install_desktop_file` for
details.
Make sure :attr:`mopidy.settings.FRONTENDS` includes
``mopidy.frontends.mpris.MprisFrontend``. By default, the setting includes the
MPRIS frontend.
**Testing the frontend**
**Testing the frontend**
To test, start Mopidy, and then run the following in a Python shell::
To test, start Mopidy, and then run the following in a Python shell::
import dbus
bus = dbus.SessionBus()
player = bus.get_object('org.mpris.MediaPlayer2.mopidy',
'/org/mpris/MediaPlayer2')
import dbus
bus = dbus.SessionBus()
player = bus.get_object('org.mpris.MediaPlayer2.mopidy',
'/org/mpris/MediaPlayer2')
Now you can control Mopidy through the player object. Examples:
Now you can control Mopidy through the player object. Examples:
- To get some properties from Mopidy, run::
- To get some properties from Mopidy, run::
props = player.GetAll('org.mpris.MediaPlayer2',
dbus_interface='org.freedesktop.DBus.Properties')
props = player.GetAll('org.mpris.MediaPlayer2',
dbus_interface='org.freedesktop.DBus.Properties')
- To quit Mopidy through D-Bus, run::
- To quit Mopidy through D-Bus, run::
player.Quit(dbus_interface='org.mpris.MediaPlayer2')
"""
player.Quit(dbus_interface='org.mpris.MediaPlayer2')
"""
def __init__(self):
super(MprisFrontend, self).__init__()
self.indicate_server = None
self.mpris_object = None
def on_start(self):
try:
self.mpris_object = objects.MprisObject()
self._send_startup_notification()
except Exception as e:
logger.error(u'MPRIS frontend setup failed (%s)', e)
self.stop()
def on_stop(self):
logger.debug(u'Removing MPRIS object from D-Bus connection...')
if self.mpris_object:
self.mpris_object.remove_from_connection()
self.mpris_object = None
logger.debug(u'Removed MPRIS object from D-Bus connection')
def _send_startup_notification(self):
"""
Send startup notification using libindicate to make Mopidy appear in
e.g. `Ubuntu's sound menu <https://wiki.ubuntu.com/SoundMenu>`_.
A reference to the libindicate server is kept for as long as Mopidy is
running. When Mopidy exits, the server will be unreferenced and Mopidy
will automatically be unregistered from e.g. the sound menu.
"""
if not indicate:
return
logger.debug(u'Sending startup notification...')
self.indicate_server = indicate.Server()
self.indicate_server.set_type('music.mopidy')
self.indicate_server.set_desktop_file(settings.DESKTOP_FILE)
self.indicate_server.show()
logger.debug(u'Startup notification sent')
def _emit_properties_changed(self, *changed_properties):
if self.mpris_object is None:
return
props_with_new_values = [
(p, self.mpris_object.Get(objects.PLAYER_IFACE, p))
for p in changed_properties]
self.mpris_object.PropertiesChanged(objects.PLAYER_IFACE,
dict(props_with_new_values), [])
def track_playback_paused(self, track, time_position):
logger.debug(u'Received track playback paused event')
self._emit_properties_changed('PlaybackStatus')
def track_playback_resumed(self, track, time_position):
logger.debug(u'Received track playback resumed event')
self._emit_properties_changed('PlaybackStatus')
def track_playback_started(self, track):
logger.debug(u'Received track playback started event')
self._emit_properties_changed('PlaybackStatus', 'Metadata')
def track_playback_ended(self, track, time_position):
logger.debug(u'Received track playback ended event')
self._emit_properties_changed('PlaybackStatus', 'Metadata')
def volume_changed(self):
logger.debug(u'Received volume changed event')
self._emit_properties_changed('Volume')
def seeked(self):
logger.debug(u'Received seeked event')
if self.mpris_object is None:
return
self.mpris_object.Seeked(
self.mpris_object.Get(objects.PLAYER_IFACE, 'Position'))
# flake8: noqa
from .actor import MprisFrontend

View File

@ -0,0 +1,89 @@
import logging
import pykka
from mopidy import settings
from mopidy.core import CoreListener
from mopidy.frontends.mpris import objects
logger = logging.getLogger('mopidy.frontends.mpris')
try:
import indicate
except ImportError as import_error:
indicate = None # noqa
logger.debug(u'Startup notification will not be sent (%s)', import_error)
class MprisFrontend(pykka.ThreadingActor, CoreListener):
def __init__(self, core):
super(MprisFrontend, self).__init__()
self.core = core
self.indicate_server = None
self.mpris_object = None
def on_start(self):
try:
self.mpris_object = objects.MprisObject(self.core)
self._send_startup_notification()
except Exception as e:
logger.error(u'MPRIS frontend setup failed (%s)', e)
self.stop()
def on_stop(self):
logger.debug(u'Removing MPRIS object from D-Bus connection...')
if self.mpris_object:
self.mpris_object.remove_from_connection()
self.mpris_object = None
logger.debug(u'Removed MPRIS object from D-Bus connection')
def _send_startup_notification(self):
"""
Send startup notification using libindicate to make Mopidy appear in
e.g. `Ubuntu's sound menu <https://wiki.ubuntu.com/SoundMenu>`_.
A reference to the libindicate server is kept for as long as Mopidy is
running. When Mopidy exits, the server will be unreferenced and Mopidy
will automatically be unregistered from e.g. the sound menu.
"""
if not indicate:
return
logger.debug(u'Sending startup notification...')
self.indicate_server = indicate.Server()
self.indicate_server.set_type('music.mopidy')
self.indicate_server.set_desktop_file(settings.DESKTOP_FILE)
self.indicate_server.show()
logger.debug(u'Startup notification sent')
def _emit_properties_changed(self, *changed_properties):
if self.mpris_object is None:
return
props_with_new_values = [
(p, self.mpris_object.Get(objects.PLAYER_IFACE, p))
for p in changed_properties]
self.mpris_object.PropertiesChanged(
objects.PLAYER_IFACE, dict(props_with_new_values), [])
def track_playback_paused(self, track, time_position):
logger.debug(u'Received track playback paused event')
self._emit_properties_changed('PlaybackStatus')
def track_playback_resumed(self, track, time_position):
logger.debug(u'Received track playback resumed event')
self._emit_properties_changed('PlaybackStatus')
def track_playback_started(self, track):
logger.debug(u'Received track playback started event')
self._emit_properties_changed('PlaybackStatus', 'Metadata')
def track_playback_ended(self, track, time_position):
logger.debug(u'Received track playback ended event')
self._emit_properties_changed('PlaybackStatus', 'Metadata')
def volume_changed(self):
logger.debug(u'Received volume changed event')
self._emit_properties_changed('Volume')
def seeked(self, time_position_in_ms):
logger.debug(u'Received seeked event')
self.mpris_object.Seeked(time_position_in_ms * 1000)

View File

@ -1,24 +1,22 @@
import logging
import os
logger = logging.getLogger('mopidy.frontends.mpris')
try:
import dbus
import dbus.mainloop.glib
import dbus.service
import gobject
except ImportError as import_error:
from mopidy import OptionalDependencyError
from mopidy.exceptions import OptionalDependencyError
raise OptionalDependencyError(import_error)
from pykka.registry import ActorRegistry
from mopidy import settings
from mopidy.backends.base import Backend
from mopidy.core import PlaybackState
from mopidy.utils.process import exit_process
logger = logging.getLogger('mopidy.frontends.mpris')
# Must be done before dbus.SessionBus() is called
gobject.threads_init()
dbus.mainloop.glib.threads_init()
@ -34,14 +32,14 @@ class MprisObject(dbus.service.Object):
properties = None
def __init__(self):
self._backend = None
def __init__(self, core):
self.core = core
self.properties = {
ROOT_IFACE: self._get_root_iface_properties(),
PLAYER_IFACE: self._get_player_iface_properties(),
}
bus_name = self._connect_to_dbus()
super(MprisObject, self).__init__(bus_name, OBJECT_PATH)
dbus.service.Object.__init__(self, bus_name, OBJECT_PATH)
def _get_root_iface_properties(self):
return {
@ -79,20 +77,11 @@ class MprisObject(dbus.service.Object):
def _connect_to_dbus(self):
logger.debug(u'Connecting to D-Bus...')
mainloop = dbus.mainloop.glib.DBusGMainLoop()
bus_name = dbus.service.BusName(BUS_NAME,
dbus.SessionBus(mainloop=mainloop))
bus_name = dbus.service.BusName(
BUS_NAME, dbus.SessionBus(mainloop=mainloop))
logger.info(u'Connected to D-Bus')
return bus_name
@property
def backend(self):
if self._backend is None:
backend_refs = ActorRegistry.get_by_class(Backend)
assert len(backend_refs) == 1, \
'Expected exactly one running backend.'
self._backend = backend_refs[0].proxy()
return self._backend
def _get_track_id(self, cp_track):
return '/com/mopidy/track/%d' % cp_track.cpid
@ -103,46 +92,48 @@ class MprisObject(dbus.service.Object):
### Properties interface
@dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE,
in_signature='ss', out_signature='v')
in_signature='ss', out_signature='v')
def Get(self, interface, prop):
logger.debug(u'%s.Get(%s, %s) called',
logger.debug(
u'%s.Get(%s, %s) called',
dbus.PROPERTIES_IFACE, repr(interface), repr(prop))
(getter, setter) = self.properties[interface][prop]
(getter, _) = self.properties[interface][prop]
if callable(getter):
return getter()
else:
return getter
@dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE,
in_signature='s', out_signature='a{sv}')
in_signature='s', out_signature='a{sv}')
def GetAll(self, interface):
logger.debug(u'%s.GetAll(%s) called',
dbus.PROPERTIES_IFACE, repr(interface))
logger.debug(
u'%s.GetAll(%s) called', dbus.PROPERTIES_IFACE, repr(interface))
getters = {}
for key, (getter, setter) in self.properties[interface].iteritems():
for key, (getter, _) in self.properties[interface].iteritems():
getters[key] = getter() if callable(getter) else getter
return getters
@dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE,
in_signature='ssv', out_signature='')
in_signature='ssv', out_signature='')
def Set(self, interface, prop, value):
logger.debug(u'%s.Set(%s, %s, %s) called',
logger.debug(
u'%s.Set(%s, %s, %s) called',
dbus.PROPERTIES_IFACE, repr(interface), repr(prop), repr(value))
getter, setter = self.properties[interface][prop]
_, setter = self.properties[interface][prop]
if setter is not None:
setter(value)
self.PropertiesChanged(interface,
{prop: self.Get(interface, prop)}, [])
self.PropertiesChanged(
interface, {prop: self.Get(interface, prop)}, [])
@dbus.service.signal(dbus_interface=dbus.PROPERTIES_IFACE,
signature='sa{sv}as')
signature='sa{sv}as')
def PropertiesChanged(self, interface, changed_properties,
invalidated_properties):
logger.debug(u'%s.PropertiesChanged(%s, %s, %s) signaled',
invalidated_properties):
logger.debug(
u'%s.PropertiesChanged(%s, %s, %s) signaled',
dbus.PROPERTIES_IFACE, interface, changed_properties,
invalidated_properties)
### Root interface methods
@dbus.service.method(dbus_interface=ROOT_IFACE)
@ -155,15 +146,13 @@ class MprisObject(dbus.service.Object):
logger.debug(u'%s.Quit called', ROOT_IFACE)
exit_process()
### Root interface properties
def get_DesktopEntry(self):
return os.path.splitext(os.path.basename(settings.DESKTOP_FILE))[0]
def get_SupportedUriSchemes(self):
return dbus.Array(self.backend.uri_schemes.get(), signature='s')
return dbus.Array(self.core.uri_schemes.get(), signature='s')
### Player interface methods
@ -173,7 +162,7 @@ class MprisObject(dbus.service.Object):
if not self.get_CanGoNext():
logger.debug(u'%s.Next not allowed', PLAYER_IFACE)
return
self.backend.playback.next().get()
self.core.playback.next().get()
@dbus.service.method(dbus_interface=PLAYER_IFACE)
def Previous(self):
@ -181,7 +170,7 @@ class MprisObject(dbus.service.Object):
if not self.get_CanGoPrevious():
logger.debug(u'%s.Previous not allowed', PLAYER_IFACE)
return
self.backend.playback.previous().get()
self.core.playback.previous().get()
@dbus.service.method(dbus_interface=PLAYER_IFACE)
def Pause(self):
@ -189,7 +178,7 @@ class MprisObject(dbus.service.Object):
if not self.get_CanPause():
logger.debug(u'%s.Pause not allowed', PLAYER_IFACE)
return
self.backend.playback.pause().get()
self.core.playback.pause().get()
@dbus.service.method(dbus_interface=PLAYER_IFACE)
def PlayPause(self):
@ -197,13 +186,13 @@ class MprisObject(dbus.service.Object):
if not self.get_CanPause():
logger.debug(u'%s.PlayPause not allowed', PLAYER_IFACE)
return
state = self.backend.playback.state.get()
state = self.core.playback.state.get()
if state == PlaybackState.PLAYING:
self.backend.playback.pause().get()
self.core.playback.pause().get()
elif state == PlaybackState.PAUSED:
self.backend.playback.resume().get()
self.core.playback.resume().get()
elif state == PlaybackState.STOPPED:
self.backend.playback.play().get()
self.core.playback.play().get()
@dbus.service.method(dbus_interface=PLAYER_IFACE)
def Stop(self):
@ -211,7 +200,7 @@ class MprisObject(dbus.service.Object):
if not self.get_CanControl():
logger.debug(u'%s.Stop not allowed', PLAYER_IFACE)
return
self.backend.playback.stop().get()
self.core.playback.stop().get()
@dbus.service.method(dbus_interface=PLAYER_IFACE)
def Play(self):
@ -219,11 +208,11 @@ class MprisObject(dbus.service.Object):
if not self.get_CanPlay():
logger.debug(u'%s.Play not allowed', PLAYER_IFACE)
return
state = self.backend.playback.state.get()
state = self.core.playback.state.get()
if state == PlaybackState.PAUSED:
self.backend.playback.resume().get()
self.core.playback.resume().get()
else:
self.backend.playback.play().get()
self.core.playback.play().get()
@dbus.service.method(dbus_interface=PLAYER_IFACE)
def Seek(self, offset):
@ -232,9 +221,9 @@ class MprisObject(dbus.service.Object):
logger.debug(u'%s.Seek not allowed', PLAYER_IFACE)
return
offset_in_milliseconds = offset // 1000
current_position = self.backend.playback.time_position.get()
current_position = self.core.playback.time_position.get()
new_position = current_position + offset_in_milliseconds
self.backend.playback.seek(new_position)
self.core.playback.seek(new_position)
@dbus.service.method(dbus_interface=PLAYER_IFACE)
def SetPosition(self, track_id, position):
@ -243,7 +232,7 @@ class MprisObject(dbus.service.Object):
logger.debug(u'%s.SetPosition not allowed', PLAYER_IFACE)
return
position = position // 1000
current_cp_track = self.backend.playback.current_cp_track.get()
current_cp_track = self.core.playback.current_cp_track.get()
if current_cp_track is None:
return
if track_id != self._get_track_id(current_cp_track):
@ -252,7 +241,7 @@ class MprisObject(dbus.service.Object):
return
if current_cp_track.track.length < position:
return
self.backend.playback.seek(position)
self.core.playback.seek(position)
@dbus.service.method(dbus_interface=PLAYER_IFACE)
def OpenUri(self, uri):
@ -264,17 +253,16 @@ class MprisObject(dbus.service.Object):
return
# NOTE Check if URI has MIME type known to the backend, if MIME support
# is added to the backend.
uri_schemes = self.backend.uri_schemes.get()
uri_schemes = self.core.uri_schemes.get()
if not any([uri.startswith(uri_scheme) for uri_scheme in uri_schemes]):
return
track = self.backend.library.lookup(uri).get()
track = self.core.library.lookup(uri).get()
if track is not None:
cp_track = self.backend.current_playlist.add(track).get()
self.backend.playback.play(cp_track)
cp_track = self.core.current_playlist.add(track).get()
self.core.playback.play(cp_track)
else:
logger.debug(u'Track with URI "%s" not found in library.', uri)
### Player interface signals
@dbus.service.signal(dbus_interface=PLAYER_IFACE, signature='x')
@ -282,11 +270,10 @@ class MprisObject(dbus.service.Object):
logger.debug(u'%s.Seeked signaled', PLAYER_IFACE)
# Do nothing, as just calling the method is enough to emit the signal.
### Player interface properties
def get_PlaybackStatus(self):
state = self.backend.playback.state.get()
state = self.core.playback.state.get()
if state == PlaybackState.PLAYING:
return 'Playing'
elif state == PlaybackState.PAUSED:
@ -295,8 +282,8 @@ class MprisObject(dbus.service.Object):
return 'Stopped'
def get_LoopStatus(self):
repeat = self.backend.playback.repeat.get()
single = self.backend.playback.single.get()
repeat = self.core.playback.repeat.get()
single = self.core.playback.single.get()
if not repeat:
return 'None'
else:
@ -310,14 +297,14 @@ class MprisObject(dbus.service.Object):
logger.debug(u'Setting %s.LoopStatus not allowed', PLAYER_IFACE)
return
if value == 'None':
self.backend.playback.repeat = False
self.backend.playback.single = False
self.core.playback.repeat = False
self.core.playback.single = False
elif value == 'Track':
self.backend.playback.repeat = True
self.backend.playback.single = True
self.core.playback.repeat = True
self.core.playback.single = True
elif value == 'Playlist':
self.backend.playback.repeat = True
self.backend.playback.single = False
self.core.playback.repeat = True
self.core.playback.single = False
def set_Rate(self, value):
if not self.get_CanControl():
@ -329,23 +316,23 @@ class MprisObject(dbus.service.Object):
self.Pause()
def get_Shuffle(self):
return self.backend.playback.random.get()
return self.core.playback.random.get()
def set_Shuffle(self, value):
if not self.get_CanControl():
logger.debug(u'Setting %s.Shuffle not allowed', PLAYER_IFACE)
return
if value:
self.backend.playback.random = True
self.core.playback.random = True
else:
self.backend.playback.random = False
self.core.playback.random = False
def get_Metadata(self):
current_cp_track = self.backend.playback.current_cp_track.get()
current_cp_track = self.core.playback.current_cp_track.get()
if current_cp_track is None:
return {'mpris:trackid': ''}
else:
(cpid, track) = current_cp_track
(_, track) = current_cp_track
metadata = {'mpris:trackid': self._get_track_id(current_cp_track)}
if track.length:
metadata['mpris:length'] = track.length * 1000
@ -370,7 +357,7 @@ class MprisObject(dbus.service.Object):
return dbus.Dictionary(metadata, signature='sv')
def get_Volume(self):
volume = self.backend.playback.volume.get()
volume = self.core.playback.volume.get()
if volume is None:
return 0
return volume / 100.0
@ -382,32 +369,35 @@ class MprisObject(dbus.service.Object):
if value is None:
return
elif value < 0:
self.backend.playback.volume = 0
self.core.playback.volume = 0
elif value > 1:
self.backend.playback.volume = 100
self.core.playback.volume = 100
elif 0 <= value <= 1:
self.backend.playback.volume = int(value * 100)
self.core.playback.volume = int(value * 100)
def get_Position(self):
return self.backend.playback.time_position.get() * 1000
return self.core.playback.time_position.get() * 1000
def get_CanGoNext(self):
if not self.get_CanControl():
return False
return (self.backend.playback.cp_track_at_next.get() !=
self.backend.playback.current_cp_track.get())
return (
self.core.playback.cp_track_at_next.get() !=
self.core.playback.current_cp_track.get())
def get_CanGoPrevious(self):
if not self.get_CanControl():
return False
return (self.backend.playback.cp_track_at_previous.get() !=
self.backend.playback.current_cp_track.get())
return (
self.core.playback.cp_track_at_previous.get() !=
self.core.playback.current_cp_track.get())
def get_CanPlay(self):
if not self.get_CanControl():
return False
return (self.backend.playback.current_track.get() is not None
or self.backend.playback.track_at_next.get() is not None)
return (
self.core.playback.current_track.get() is not None or
self.core.playback.track_at_next.get() is not None)
def get_CanPause(self):
if not self.get_CanControl():

View File

@ -13,8 +13,9 @@ class ImmutableObject(object):
def __init__(self, *args, **kwargs):
for key, value in kwargs.items():
if not hasattr(self, key):
raise TypeError('__init__() got an unexpected keyword ' + \
'argument \'%s\'' % key)
raise TypeError(
u"__init__() got an unexpected keyword argument '%s'" %
key)
self.__dict__[key] = value
def __setattr__(self, name, value):
@ -71,8 +72,8 @@ class ImmutableObject(object):
if hasattr(self, key):
data[key] = values.pop(key)
if values:
raise TypeError("copy() got an unexpected keyword argument '%s'"
% key)
raise TypeError(
u"copy() got an unexpected keyword argument '%s'" % key)
return self.__class__(**data)
def serialize(self):
@ -119,6 +120,8 @@ class Album(ImmutableObject):
:type artists: list of :class:`Artist`
:param num_tracks: number of tracks in album
:type num_tracks: integer
:param date: album release date (YYYY or YYYY-MM-DD)
:type date: string
:param musicbrainz_id: MusicBrainz ID
:type musicbrainz_id: string
"""
@ -135,6 +138,9 @@ class Album(ImmutableObject):
#: The number of tracks in the album. Read-only.
num_tracks = 0
#: The album release date. Read-only.
date = None
#: The MusicBrainz ID of the album. Read-only.
musicbrainz_id = None
@ -202,14 +208,14 @@ class Track(ImmutableObject):
class Playlist(ImmutableObject):
"""
:param uri: playlist URI
:type uri: string
:param name: playlist name
:type name: string
:param tracks: playlist's tracks
:type tracks: list of :class:`Track` elements
:param last_modified: playlist's modification time
:type last_modified: :class:`datetime.datetime`
:param uri: playlist URI
:type uri: string
:param name: playlist name
:type name: string
:param tracks: playlist's tracks
:type tracks: list of :class:`Track` elements
:param last_modified: playlist's modification time
:type last_modified: :class:`datetime.datetime`
"""
#: The playlist URI. Read-only.

View File

@ -10,6 +10,7 @@ import datetime
from mopidy.utils.path import path_to_uri, find_files
from mopidy.models import Track, Artist, Album
def translator(data):
albumartist_kwargs = {}
album_kwargs = {}
@ -37,7 +38,8 @@ def translator(data):
_retrieve('musicbrainz-trackid', 'musicbrainz_id', track_kwargs)
_retrieve('musicbrainz-artistid', 'musicbrainz_id', artist_kwargs)
_retrieve('musicbrainz-albumid', 'musicbrainz_id', album_kwargs)
_retrieve('musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs)
_retrieve(
'musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs)
if albumartist_kwargs:
album_kwargs['artists'] = [Artist(**albumartist_kwargs)]
@ -52,7 +54,7 @@ def translator(data):
class Scanner(object):
def __init__(self, folder, data_callback, error_callback=None):
self.uris = [path_to_uri(f) for f in find_files(folder)]
self.files = find_files(folder)
self.data_callback = data_callback
self.error_callback = error_callback
self.loop = gobject.MainLoop()
@ -61,8 +63,8 @@ class Scanner(object):
self.uribin = gst.element_factory_make('uridecodebin')
self.uribin.set_property('caps', gst.Caps('audio/x-raw-int'))
self.uribin.connect('pad-added', self.process_new_pad,
fakesink.get_pad('sink'))
self.uribin.connect(
'pad-added', self.process_new_pad, fakesink.get_pad('sink'))
self.pipe = gst.element_factory_make('pipeline')
self.pipe.add(self.uribin)
@ -106,7 +108,7 @@ class Scanner(object):
self.next_uri()
def get_duration(self):
self.pipe.get_state() # Block until state change is done.
self.pipe.get_state() # Block until state change is done.
try:
return self.pipe.query_duration(
gst.FORMAT_TIME, None)[0] // gst.MSECOND
@ -114,18 +116,19 @@ class Scanner(object):
return None
def next_uri(self):
if not self.uris:
return self.stop()
try:
uri = path_to_uri(self.files.next())
except StopIteration:
self.stop()
return False
self.pipe.set_state(gst.STATE_NULL)
self.uribin.set_property('uri', self.uris.pop())
self.uribin.set_property('uri', uri)
self.pipe.set_state(gst.STATE_PAUSED)
return True
def start(self):
if not self.uris:
return
self.next_uri()
self.loop.run()
if self.next_uri():
self.loop.run()
def stop(self):
self.pipe.set_state(gst.STATE_NULL)

View File

@ -7,26 +7,26 @@ All available settings and their default values.
file called ``~/.config/mopidy/settings.py`` and redefine settings there.
"""
#: List of playback backends to use. See :mod:`mopidy.backends` for all
#: List of playback backends to use. See :ref:`backend-implementations` for all
#: available backends.
#:
#: When results from multiple backends are combined, they are combined in the
#: order the backends are listed here.
#:
#: Default::
#:
#: BACKENDS = (u'mopidy.backends.spotify.SpotifyBackend',)
#:
#: Other typical values::
#:
#: BACKENDS = (u'mopidy.backends.local.LocalBackend',)
#:
#: .. note::
#: Currently only the first backend in the list is used.
#: BACKENDS = (
#: u'mopidy.backends.local.LocalBackend',
#: u'mopidy.backends.spotify.SpotifyBackend',
#: )
BACKENDS = (
u'mopidy.backends.local.LocalBackend',
u'mopidy.backends.spotify.SpotifyBackend',
)
#: The log format used for informational logging.
#:
#: See http://docs.python.org/library/logging.html#formatter-objects for
#: See http://docs.python.org/2/library/logging.html#formatter-objects for
#: details on the format.
CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(message)s'
@ -54,7 +54,8 @@ DEBUG_LOG_FILENAME = u'mopidy.log'
#: DESKTOP_FILE = u'/usr/share/applications/mopidy.desktop'
DESKTOP_FILE = u'/usr/share/applications/mopidy.desktop'
#: List of server frontends to use.
#: List of server frontends to use. See :ref:`frontend-implementations` for
#: available frontends.
#:
#: Default::
#:
@ -85,9 +86,8 @@ LASTFM_PASSWORD = u''
#:
#: Default::
#:
#: # Defaults to asking glib where music is stored, fallback is ~/music
#: LOCAL_MUSIC_PATH = None
LOCAL_MUSIC_PATH = None
#: LOCAL_MUSIC_PATH = u'$XDG_MUSIC_DIR'
LOCAL_MUSIC_PATH = u'$XDG_MUSIC_DIR'
#: Path to playlist folder with m3u files for local music.
#:
@ -95,8 +95,8 @@ LOCAL_MUSIC_PATH = None
#:
#: Default::
#:
#: LOCAL_PLAYLIST_PATH = None # Implies $XDG_DATA_DIR/mopidy/playlists
LOCAL_PLAYLIST_PATH = None
#: LOCAL_PLAYLIST_PATH = u'$XDG_DATA_DIR/mopidy/playlists'
LOCAL_PLAYLIST_PATH = u'$XDG_DATA_DIR/mopidy/playlists'
#: Path to tag cache for local music.
#:
@ -104,22 +104,23 @@ LOCAL_PLAYLIST_PATH = None
#:
#: Default::
#:
#: LOCAL_TAG_CACHE_FILE = None # Implies $XDG_DATA_DIR/mopidy/tag_cache
LOCAL_TAG_CACHE_FILE = None
#: LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache'
LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache'
#: Sound mixer to use.
#: Audio mixer to use.
#:
#: Expects a GStreamer mixer to use, typical values are:
#: ``alsamixer``, ``pulsemixer``, ``ossmixer``, and ``oss4mixer``.
#:
#: Setting this to :class:`None` turns off volume control.
#: Setting this to :class:`None` turns off volume control. ``software``
#: can be used to force software mixing in the application.
#:
#: Default::
#:
#: MIXER = u'autoaudiomixer'
MIXER = u'autoaudiomixer'
#: Sound mixer track to use.
#: Audio mixer track to use.
#:
#: Name of the mixer track to use. If this is not set we will try to find the
#: master output track. As an example, using ``alsamixer`` you would
@ -167,7 +168,11 @@ MPD_SERVER_PASSWORD = None
#: Default: 20
MPD_SERVER_MAX_CONNECTIONS = 20
#: Output to use. See :mod:`mopidy.outputs` for all available backends
#: Audio output to use.
#:
#: Expects a GStreamer sink. Typical values are ``autoaudiosink``,
#: ``alsasink``, ``osssink``, ``oss4sink``, ``pulsesink``, and ``shout2send``,
#: and additional arguments specific to each sink.
#:
#: Default::
#:
@ -177,7 +182,11 @@ OUTPUT = u'autoaudiosink'
#: Path to the Spotify cache.
#:
#: Used by :mod:`mopidy.backends.spotify`.
SPOTIFY_CACHE_PATH = None
#:
#: Default::
#:
#: SPOTIFY_CACHE_PATH = u'$XDG_CACHE_DIR/mopidy/spotify'
SPOTIFY_CACHE_PATH = u'$XDG_CACHE_DIR/mopidy/spotify'
#: Your Spotify Premium username.
#:
@ -194,7 +203,7 @@ SPOTIFY_PASSWORD = u''
#: Available values are 96, 160, and 320.
#:
#: Used by :mod:`mopidy.backends.spotify`.
#
#:
#: Default::
#:
#: SPOTIFY_BITRATE = 160

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