Release v0.9.0

This commit is contained in:
Stein Magnus Jodal 2012-11-21 01:43:46 +01:00
commit 5a463cfef9
200 changed files with 10243 additions and 6771 deletions

View File

@ -3,10 +3,19 @@ language: python
install:
- "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 update || true"
- "sudo apt-get install $(apt-cache depends mopidy | awk '$2 !~ /mopidy/ {print $2}')"
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>`_

View File

@ -1,38 +1,5 @@
#!/usr/bin/env python
#! /usr/bin/env python
import sys
import logging
from mopidy import settings
from mopidy.utils.log import setup_console_logging, setup_root_logger
from mopidy.scanner import Scanner, translator
from mopidy.frontends.mpd.translator import tracks_to_tag_cache_format
setup_root_logger()
setup_console_logging(2)
tracks = []
def store(data):
track = translator(data)
tracks.append(track)
logging.debug(u'Added %s', track.uri)
def debug(uri, error, debug):
logging.error(u'Failed %s: %s - %s', uri, error, debug)
logging.info(u'Scanning %s', settings.LOCAL_MUSIC_PATH)
scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug)
try:
scanner.start()
except KeyboardInterrupt:
scanner.stop()
logging.info(u'Done')
for a in tracks_to_tag_cache_format(tracks):
if len(a) == 1:
print (u'%s' % a).encode('utf-8')
else:
print (u'%s: %s' % a).encode('utf-8')
if __name__ == '__main__':
from mopidy.scanner import main
main()

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`.
@ -16,10 +19,10 @@ Playback provider
:members:
Stored playlists provider
=========================
Playlists provider
==================
.. autoclass:: mopidy.backends.base.BaseStoredPlaylistsProvider
.. autoclass:: mopidy.backends.base.BasePlaylistsProvider
:members:
@ -30,6 +33,15 @@ Library provider
:members:
Backend listener
================
.. autoclass:: mopidy.backends.listener.BackendListener
: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 tracklist, since it doesn't belong to a
specific backend.
See :ref:`core-api` for more details.
.. digraph:: core_architecture
Core -> "Tracklist\ncontroller"
Core -> "Library\ncontroller"
Core -> "Playback\ncontroller"
Core -> "Playlists\ncontroller"
"Library\ncontroller" -> "Local backend"
"Library\ncontroller" -> "Spotify backend"
"Playback\ncontroller" -> "Local backend"
"Playback\ncontroller" -> "Spotify backend"
"Playback\ncontroller" -> Audio
"Playlists\ncontroller" -> "Local backend"
"Playlists\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\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\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
@ -23,21 +26,21 @@ seek, and volume control.
:members:
Current playlist controller
===========================
Tracklist controller
====================
Manages everything related to the currently loaded playlist.
Manages everything related to the tracks we are currently playing.
.. autoclass:: mopidy.core.CurrentPlaylistController
.. autoclass:: mopidy.core.TracklistController
:members:
Stored playlists controller
===========================
Playlists controller
====================
Manages stored playlist.
Manages persistence of playlists.
.. autoclass:: mopidy.core.StoredPlaylistsController
.. autoclass:: mopidy.core.PlaylistsController
:members:
@ -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

@ -5,6 +5,248 @@ Changes
This change log is used to track all major changes to Mopidy.
v0.9.0 (2012-11-21)
===================
Support for using the local and Spotify backends simultaneously have for a very
long time been our most requested feature. Finally, it's here!
**Dependencies**
- pyspotify >= 1.9, < 1.10 is now required for Spotify support.
**Documentation**
- New :ref:`installation` guides, organized by OS and distribution so that you
can follow one concise list of instructions instead of jumping around the
docs to look for instructions for each dependency.
- Moved :ref:`raspberrypi-installation` howto from the wiki to the docs.
- Updated :ref:`mpd-clients` overview.
- Added :ref:`mpris-clients` and :ref:`upnp-clients` overview.
**Multiple backends support**
- 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.
**Spotify backend**
- The Spotify backend now includes release year and artist on albums.
- :issue:`233`: The Spotify backend now returns the track if you search for the
Spotify track URI.
- Added support for connecting to the Spotify service through an HTTP or SOCKS
proxy, which is supported by pyspotify >= 1.9.
- Subscriptions to other Spotify user's "starred" playlists are ignored, as
they currently isn't fully supported by pyspotify.
**Local backend**
- :issue:`236`: The ``mopidy-scan`` command failed to include tags from ALAC
files (Apple lossless) because it didn't support multiple tag messages from
GStreamer per track it scanned.
- Added support for search by filename to local backend.
**MPD frontend**
- :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now
accepts unquoted playlist names if they don't contain spaces.
- :issue:`246`: The MPD command ``list album artist ""`` and similar
``search``, ``find``, and ``list`` commands with empty filter values caused a
:exc:`LookupError`, but should have been ignored by the MPD server.
- The MPD frontend no longer lowercases search queries. This broke e.g. search
by URI, where casing may be essential.
- The MPD command ``plchanges`` always returned the entire playlist. It now
returns an empty response when the client has seen the latest version.
- The MPD commands ``search`` and ``find`` now allows the key ``file``, which
is used by ncmpcpp instead of ``filename``.
- The MPD commands ``search`` and ``find`` now allow search query values to be
empty strings.
- The MPD command ``listplaylists`` will no longer return playlists without a
name. This could crash ncmpcpp.
- The MPD command ``list`` will no longer return artist names, album names, or
dates that are blank.
- The MPD command ``decoders`` will now return an empty response instead of a
"not implemented" error to make the ncmpcpp browse view work the first time
it is opened.
**MPRIS frontend**
- The MPRIS playlists interface is now supported by our MPRIS frontend. This
means that you now can select playlists to queue and play from the Ubuntu
Sound Menu.
**Audio mixers**
- 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.
**Developer support**
- Added optional background thread for debugging deadlocks. When the feature is
enabled via the ``--debug-thread`` option or
:attr:`mopidy.settings.DEBUG_THREAD` setting a ``SIGUSR1`` signal will dump
the traceback for all running threads.
- The settings validator will now allow any setting prefixed with ``CUSTOM_``
to exist in the settings file.
**Internal changes**
Internally, Mopidy have seen a lot of changes to pave the way for multiple
backends and the future HTTP frontend.
- 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 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`.
See :ref:`concepts` for more details and illustrations of all the relations.
- 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.
- All properties in the core API now got getters, and setters if setting them
is allowed. They are not explictly listed in the docs as they have the same
behavior as the documented properties, but they are available and may be
used. This is useful for the future HTTP frontend.
*Models:*
- Added :attr:`mopidy.models.Album.date` attribute. It has the same format as
the existing :attr:`mopidy.models.Track.date`.
- Added :class:`mopidy.models.ModelJSONEncoder` and
:func:`mopidy.models.model_json_decoder` for automatic JSON serialization and
deserialization of data structures which contains Mopidy models. This is
useful for the future HTTP frontend.
*Library:*
- :meth:`mopidy.core.LibraryController.find_exact` and
:meth:`mopidy.core.LibraryController.search` now returns plain lists of
tracks instead of playlist objects.
- :meth:`mopidy.core.LibraryController.lookup` now returns a list of tracks
instead of a single track. This makes it possible to support lookup of
artist or album URIs which then can expand to a list of tracks.
*Playback:*
- 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. Any future
backend that just feeds URIs to GStreamer to play can also use the base
playback provider without any changes.
- Removed :attr:`mopidy.core.PlaybackController.track_at_previous`. Use
:attr:`mopidy.core.PlaybackController.tl_track_at_previous` instead.
- Removed :attr:`mopidy.core.PlaybackController.track_at_next`. Use
:attr:`mopidy.core.PlaybackController.tl_track_at_next` instead.
- Removed :attr:`mopidy.core.PlaybackController.track_at_eot`. Use
:attr:`mopidy.core.PlaybackController.tl_track_at_eot` instead.
- Removed :attr:`mopidy.core.PlaybackController.current_tlid`. Use
:attr:`mopidy.core.PlaybackController.current_tl_track` instead.
*Playlists:*
The playlists part of the core API has been revised to be more focused around
the playlist URI, and some redundant functionality has been removed:
- Renamed "stored playlists" to "playlists" everywhere, including the core API
used by frontends.
- :attr:`mopidy.core.PlaylistsController.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.PlaylistsController.delete` now accepts an URI, and not a
playlist object.
- :meth:`mopidy.core.PlaylistsController.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
:meth:`mopidy.core.PlaylistsController.save`.
- :meth:`mopidy.core.PlaylistsController.rename` has been removed, since
renaming can be done with :meth:`mopidy.core.PlaylistsController.save`.
- :meth:`mopidy.core.PlaylistsController.get` has been replaced by
:meth:`mopidy.core.PlaylistsController.filter`.
- The event :meth:`mopidy.core.CoreListener.playlist_changed` has been changed
to include the playlist that was changed.
*Tracklist:*
- Renamed "current playlist" to "tracklist" everywhere, including the core API
used by frontends.
- Removed :meth:`mopidy.core.TracklistController.append`. Use
:meth:`mopidy.core.TracklistController.add` instead, which is now capable of
adding multiple tracks.
- :meth:`mopidy.core.TracklistController.get` has been replaced by
:meth:`mopidy.core.TracklistController.filter`.
- :meth:`mopidy.core.TracklistController.remove` can now remove multiple
tracks, and returns the tracks it removed.
- When the tracklist is changed, we now trigger the new
:meth:`mopidy.core.CoreListener.tracklist_changed` event. Previously we
triggered :meth:`mopidy.core.CoreListener.playlist_changed`, which is
intended for stored playlists, not the tracklist.
*Towards Python 3 support:*
- Make the entire code base use unicode strings by default, and only fall back
to bytestrings where it is required. Another step closer to Python 3.
v0.8.1 (2012-10-30)
===================
@ -23,7 +265,8 @@ to work with Pykka 1.0.
- :issue:`216`: Volume returned by the MPD command `status` contained a
floating point ``.0`` suffix. This bug was introduced with the large audio
outout and mixer changes in v0.8.0. It now returns an integer again.
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)
@ -313,7 +556,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
@ -454,8 +697,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**
@ -567,7 +809,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.
@ -594,7 +836,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.
@ -603,7 +845,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
@ -722,10 +964,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**
@ -790,12 +1032,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
@ -1050,7 +1291,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, plus the optional playlist interface. It does
not implement the optional tracklist interface.
.. _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.
@ -11,10 +12,12 @@
# All configuration values have a default; values that are commented out
# serve to show the default.
from __future__ import unicode_literals
import os
import re
import sys
class Mock(object):
def __init__(self, *args, **kwargs):
pass
@ -34,6 +37,7 @@ class Mock(object):
else:
return Mock()
MOCK_MODULES = [
'dbus',
'dbus.mainloop',
@ -63,12 +67,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']
@ -83,15 +91,15 @@ source_suffix = '.rst'
master_doc = 'index'
# General information about the project.
project = u'Mopidy'
copyright = u'2010-2012, Stein Magnus Jodal and contributors'
project = 'Mopidy'
copyright = '2010-2012, Stein Magnus Jodal and contributors'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The full version, including alpha/beta/rc tags.
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 +122,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 +144,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 +219,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 +227,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',
'Mopidy Documentation',
'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,74 @@ 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
==========
- Always import ``unicode_literals`` and use unicode literals for everything
except where you're explicitly working with bytes, which are marked with the
``b`` prefix.
Do this::
from __future__ import unicode_literals
foo = 'I am a unicode string, which is a sane default'
bar = b'I am a bytestring'
Not this::
foo = u'I am a unicode string'
bar = 'I am a bytestring, but was it intentional?'
- 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 +150,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 +180,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
@ -201,10 +271,50 @@ both to use ``tests/data/advanced_tag_cache`` for their tag cache and
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.
Debugging deadlocks
===================
Between the numerous Pykka threads and GStreamer interactions there can
sometimes be a potential for deadlocks. In an effort to make these slightly
simpler to debug the setting :attr:`mopidy.settings.DEBUG_THREAD` or the option
``--debug-thread`` can be used to turn on an extra debug thread. This thread is
not linked to the regular program flow, and it's only task is to dump traceback
showing the other threads state when we get a ``SIGUSR1``.
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.
@ -247,32 +357,3 @@ Creating releases
python setup.py sdist upload
#. Spread the word.
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.

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,98 +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
============================
We have been working with `Homebrew <https://github.com/mxcl/homebrew>`_ for a
to make all the GStreamer packages easily installable on OS X.
#. Install `Homebrew <https://github.com/mxcl/homebrew>`_.
#. 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 >= 1.0::
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
have support for this in a future 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 ! audioresample ! 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
==================================================
@ -171,6 +200,21 @@ can use with the ``gst-launch-0.10`` command can be plugged into
:attr:`mopidy.settings.OUTPUT`.
Custom settings
===============
Mopidy's settings validator will stop you from defining any settings in your
settings file that Mopidy doesn't know about. This may sound obnoxious, but it
helps you detect typos in your settings, and deprecated settings that should be
removed or updated.
If you're extending Mopidy in some way, and want to use Mopidy's settings
system, you can prefix your settings with ``CUSTOM_`` to get around the
settings validator. We recommend that you choose names like
``CUSTOM_MYAPP_MYSETTING`` so that multiple custom extensions to Mopidy can be
used at the same time without any danger of naming collisions.
Available settings
==================

View File

@ -1,75 +1,30 @@
from __future__ import unicode_literals
# 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, but found %s' %
'Mopidy requires Python >= 2.6, < 3, but found %s' %
'.'.join(map(str, sys.version_info[:3])))
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__)
'Mopidy requires Pykka >= 1.0, < 2, but found %s' % pykka.__version__)
import os
import platform
from subprocess import PIPE, Popen
import glib
warnings.filterwarnings('ignore', 'could not open display')
__version__ = '0.8.1'
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')
__version__ = '0.9.0'
def get_version():
try:
return get_git_version()
except EnvironmentError:
return __version__
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

@ -1,3 +1,5 @@
from __future__ import unicode_literals
import logging
import optparse
import os
@ -24,127 +26,162 @@ 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)
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():
signal.signal(signal.SIGTERM, exit_handler)
signal.signal(signal.SIGTERM, process.exit_handler)
loop = gobject.MainLoop()
options = parse_options()
if options.debug_thread or settings.DEBUG_THREAD:
debug_thread = process.DebugThread()
debug_thread.start()
signal.signal(signal.SIGUSR1, debug_thread.handler)
try:
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)
logger.info('Interrupted. Exiting...')
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='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')
parser.add_option(
'--debug-thread',
action='store_true', dest='debug_thread',
help='run background thread that dumps tracebacks on SIGUSR1')
return parser.parse_args(args=mopidy_args)[0]
def check_old_folders():
old_settings_folder = os.path.expanduser(u'~/.mopidy')
old_settings_folder = os.path.expanduser('~/.mopidy')
if not os.path.isdir(old_settings_folder):
return
logger.warning(u'Old settings folder found at %s, settings.py should be '
'moved to %s, any cache data should be deleted. See release notes '
'for further instructions.', old_settings_folder, SETTINGS_PATH)
logger.warning(
'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, 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('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,407 +1,6 @@
import pygst
pygst.require('0.10')
import gst
import gobject
from __future__ import unicode_literals
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._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('Output set to %s', settings.OUTPUT)
except gobject.GError as ex:
logger.error('Failed to create output "%s": %s',
settings.OUTPUT, ex)
process.exit_process()
def _setup_mixer(self):
if not settings.MIXER:
logger.info('Not setting up mixer.')
return
if settings.MIXER == 'software':
self._software_mixing = True
logger.info('Mixer set to 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 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
self._mixer_track = 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:
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._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._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 volume: 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: 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._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 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._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 = utils.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 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)
# flake8: noqa
from .actor import Audio
from .listener import AudioListener
from .constants import PlaybackState

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

@ -0,0 +1,459 @@
from __future__ import unicode_literals
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 .constants import PlaybackState
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`
"""
#: The GStreamer state mapped to :class:`mopidy.audio.PlaybackState`
state = PlaybackState.STOPPED
def __init__(self):
super(Audio, self).__init__()
self._playbin = None
self._mixer = None
self._mixer_track = None
self._software_mixing = False
self._appsrc = None
self._notify_source_signal_id = None
self._about_to_finish_id = None
self._message_signal_id = None
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._about_to_finish_id = self._playbin.connect(
'about-to-finish', self._on_about_to_finish)
self._notify_source_signal_id = self._playbin.connect(
'notify::source', self._on_new_source)
def _on_about_to_finish(self, element):
self._appsrc = None
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(
b'audio/x-raw-int, endianness=(int)1234, channels=(int)2, '
b'width=(int)16, depth=(int)16, signed=(boolean)true, '
b'rate=(int)44100')
source = element.get_property('source')
source.set_property('caps', default_caps)
# GStreamer does not like unicode
source.set_property('format', b'time')
self._appsrc = source
def _teardown_playbin(self):
if self._about_to_finish_id:
self._playbin.disconnect(self._about_to_finish_id)
if self._notify_source_signal_id:
self._playbin.disconnect(self._notify_source_signal_id)
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(b'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()
self._message_signal_id = bus.connect('message', self._on_message)
def _teardown_message_processor(self):
if self._message_signal_id:
bus = self._playbin.get_bus()
bus.disconnect(self._message_signal_id)
bus.remove_signal_watch()
def _on_message(self, bus, message):
if (message.type == gst.MESSAGE_STATE_CHANGED
and message.src == self._playbin):
old_state, new_state, pending_state = message.parse_state_changed()
self._on_playbin_state_changed(old_state, new_state, pending_state)
elif message.type == gst.MESSAGE_EOS:
self._on_end_of_stream()
elif message.type == gst.MESSAGE_ERROR:
error, debug = message.parse_error()
logger.error('%s %s', error, debug)
self.stop_playback()
elif message.type == gst.MESSAGE_WARNING:
error, debug = message.parse_warning()
logger.warning('%s %s', error, debug)
def _on_playbin_state_changed(self, old_state, new_state, pending_state):
if new_state == gst.STATE_READY and pending_state == gst.STATE_NULL:
# XXX: We're not called on the last state change when going down to
# NULL, so we rewrite the second to last call to get the expected
# behavior.
new_state = gst.STATE_NULL
pending_state = gst.STATE_VOID_PENDING
if pending_state != gst.STATE_VOID_PENDING:
return # Ignore intermediate state changes
if new_state == gst.STATE_READY:
return # Ignore READY state as it's GStreamer specific
if new_state == gst.STATE_PLAYING:
new_state = PlaybackState.PLAYING
elif new_state == gst.STATE_PAUSED:
new_state = PlaybackState.PAUSED
elif new_state == gst.STATE_NULL:
new_state = PlaybackState.STOPPED
old_state, self.state = self.state, new_state
logger.debug(
'Triggering event: state_changed(old_state=%s, new_state=%s)',
old_state, new_state)
AudioListener.send(
'state_changed', old_state=old_state, new_state=new_state)
def _on_end_of_stream(self):
logger.debug('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, buffer_):
"""
Call this to deliver raw audio data to be played.
Note that the uri must be set to ``appsrc://`` for this to work.
Returns true if data was delivered.
:param buffer_: buffer to pass to appsrc
:type buffer_: :class:`gst.Buffer`
:rtype: boolean
"""
if not self._appsrc:
return False
return self._appsrc.emit('push-buffer', buffer_) == gst.FLOW_OK
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 int(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] = ' '
taglist[gst.TAG_TITLE] = ' '
taglist[gst.TAG_ALBUM] = ' '
if artists:
taglist[gst.TAG_ARTIST] = ', '.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)

16
mopidy/audio/constants.py Normal file
View File

@ -0,0 +1,16 @@
from __future__ import unicode_literals
class PlaybackState(object):
"""
Enum of playback states.
"""
#: Constant representing the paused state.
PAUSED = 'paused'
#: Constant representing the playing state.
PLAYING = 'playing'
#: Constant representing the stopped state.
STOPPED = 'stopped'

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

@ -0,0 +1,45 @@
from __future__ import unicode_literals
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
def state_changed(self, old_state, new_state):
"""
Called after the playback state have changed.
Will be called for both immediate and async state changes in GStreamer.
*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

View File

@ -1,43 +1,22 @@
from __future__ import unicode_literals
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()
# 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,21 @@
"""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.
"""
from __future__ import unicode_literals
import pygst
pygst.require('0.10')
import gobject
import gst
import logging
@ -10,16 +25,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 +84,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,41 @@
"""Fake mixer for use in tests.
**Dependencies:**
- None
**Settings:**
- Set :attr:`mopidy.settings.MIXER` to ``fakemixer`` to use this mixer.
"""
from __future__ import unicode_literals
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 +52,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,52 @@
"""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')
"""
from __future__ import unicode_literals
import logging
import pygst
@ -8,41 +57,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):
@ -60,7 +109,7 @@ class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer):
def do_change_state(self, transition):
if transition == gst.STATE_CHANGE_NULL_TO_READY:
if serial is None:
logger.warning(u'nadmixer dependency python-serial not found')
logger.warning('nadmixer dependency python-serial not found')
return gst.STATE_CHANGE_FAILURE
self._start_nad_talker()
return gst.STATE_CHANGE_SUCCESS
@ -74,13 +123,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 +166,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('NAD amplifier: Connecting through "%s"', self.port)
self._device = serial.Serial(
port=self.port,
baudrate=self.BAUDRATE,
@ -137,11 +181,11 @@ 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')
logger.info(u'NAD amplifier: Connected to model "%s"', model)
logger.info('NAD amplifier: Connected to model "%s"', model)
return model
def _power_device_on(self):
@ -163,19 +207,26 @@ 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('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('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
# target volume.
logger.debug(u'Setting volume to %d' % volume)
logger.debug('Setting volume to %d' % volume)
target_nad_volume = int(round(volume * self.VOLUME_LEVELS / 100.0))
if self._nad_volume is None:
return # Calibration needed
@ -200,11 +251,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(
'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(
'NAD amplifier: Gave up on setting "%s" to "%s"',
key, value)
def _ask_device(self, key):

View File

@ -0,0 +1,37 @@
from __future__ import unicode_literals
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()

View File

@ -0,0 +1 @@
from __future__ import unicode_literals

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

@ -0,0 +1,233 @@
from __future__ import unicode_literals
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`, or :class:`None` if
#: the backend doesn't provide a library.
library = None
#: The playback provider. An instance of
#: :class:`mopidy.backends.base.BasePlaybackProvider`, or :class:`None` if
#: the backend doesn't provide playback.
playback = None
#: The playlists provider. An instance of
#: :class:`mopidy.backends.base.BasePlaylistsProvider`, or class:`None` if
#: the backend doesn't provide playlists.
playlists = None
#: List of URI schemes this backend can handle.
uri_schemes = []
# Because the providers is marked as pykka_traversible, we can't get() them
# from another actor, and need helper methods to check if the providers are
# set or None.
def has_library(self):
return self.library is not None
def has_playback(self):
return self.playback is not None
def has_playlists(self):
return self.playlists is not None
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 BasePlaylistsProvider(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 available 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.PlaylistsController.create`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def delete(self, uri):
"""
See :meth:`mopidy.core.PlaylistsController.delete`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def lookup(self, uri):
"""
See :meth:`mopidy.core.PlaylistsController.lookup`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def refresh(self):
"""
See :meth:`mopidy.core.PlaylistsController.refresh`.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def save(self, playlist):
"""
See :meth:`mopidy.core.PlaylistsController.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

111
mopidy/backends/dummy.py Normal file
View File

@ -0,0 +1,111 @@
"""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
"""
from __future__ import unicode_literals
import pykka
from mopidy.backends import base
from mopidy.models import Playlist
class DummyBackend(pykka.ThreadingActor, base.Backend):
def __init__(self, audio):
super(DummyBackend, self).__init__()
self.library = DummyLibraryProvider(backend=self)
self.playback = DummyPlaybackProvider(audio=audio, backend=self)
self.playlists = DummyPlaylistsProvider(backend=self)
self.uri_schemes = ['dummy']
class DummyLibraryProvider(base.BaseLibraryProvider):
def __init__(self, *args, **kwargs):
super(DummyLibraryProvider, self).__init__(*args, **kwargs)
self.dummy_library = []
self.dummy_find_exact_result = []
self.dummy_search_result = []
def find_exact(self, **query):
return self.dummy_find_exact_result
def lookup(self, uri):
return filter(lambda t: uri == t.uri, self.dummy_library)
def refresh(self, uri=None):
pass
def search(self, **query):
return self.dummy_search_result
class DummyPlaybackProvider(base.BasePlaybackProvider):
def __init__(self, *args, **kwargs):
super(DummyPlaybackProvider, self).__init__(*args, **kwargs)
self._time_position = 0
def pause(self):
return True
def play(self, track):
"""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_time_position(self):
return self._time_position
class DummyPlaylistsProvider(base.BasePlaylistsProvider):
def create(self, name):
playlist = Playlist(name=name, uri='dummy:%s' % name)
self._playlists.append(playlist)
return playlist
def delete(self, uri):
playlist = self.lookup(uri)
if playlist:
self._playlists.remove(playlist)
def lookup(self, uri):
for playlist in self._playlists:
if playlist.uri == uri:
return playlist
def refresh(self):
pass
def save(self, playlist):
old_playlist = self.lookup(playlist.uri)
if old_playlist is not None:
index = self._playlists.index(old_playlist)
self._playlists[index] = playlist
else:
self._playlists.append(playlist)
return playlist

View File

@ -1,104 +0,0 @@
from pykka.actor import ThreadingActor
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.
Handles URIs starting with ``dummy:``.
"""
def __init__(self, *args, **kwargs):
super(DummyBackend, self).__init__()
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.uri_schemes = [u'dummy']
class DummyLibraryProvider(base.BaseLibraryProvider):
def __init__(self, *args, **kwargs):
super(DummyLibraryProvider, self).__init__(*args, **kwargs)
self.dummy_library = []
def find_exact(self, **query):
return Playlist()
def lookup(self, uri):
matches = filter(lambda t: uri == t.uri, self.dummy_library)
if matches:
return matches[0]
def refresh(self, uri=None):
pass
def search(self, **query):
return Playlist()
class DummyPlaybackProvider(base.BasePlaybackProvider):
def __init__(self, *args, **kwargs):
super(DummyPlaybackProvider, self).__init__(*args, **kwargs)
self._volume = None
def pause(self):
return True
def play(self, track):
"""Pass None as track to force failure"""
return track is not None
def resume(self):
return True
def seek(self, time_position):
return True
def stop(self):
return True
def get_volume(self):
return self._volume
def set_volume(self, volume):
self._volume = volume
class DummyStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider):
def create(self, name):
playlist = Playlist(name=name)
self._playlists.append(playlist)
return playlist
def delete(self, playlist):
self._playlists.remove(playlist)
def lookup(self, uri):
return filter(lambda p: p.uri == uri, self._playlists)
def refresh(self):
pass
def rename(self, playlist, new_name):
self._playlists[self._playlists.index(playlist)] = \
playlist.copy(name=new_name)
def save(self, playlist):
self._playlists.append(playlist)

View File

@ -0,0 +1,32 @@
from __future__ import unicode_literals
import pykka
class BackendListener(object):
"""
Marker interface for recipients of events sent by the backend actors.
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.
Normally, only the Core actor should mix in this class.
"""
@staticmethod
def send(event, **kwargs):
"""Helper to allow calling of backend listener events"""
listeners = pykka.ActorRegistry.get_by_class(BackendListener)
for listener in listeners:
getattr(listener.proxy(), event)(**kwargs)
def playlists_loaded(self):
"""
Called when playlists are loaded or refreshed.
*MAY* be implemented by actor.
"""
pass

View File

@ -1,243 +1,26 @@
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
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
**Dependencies:**
class LocalBackend(ThreadingActor, base.Backend):
"""
A backend for playing music from a local music archive.
- None
**Dependencies:**
**Settings:**
- None
- :attr:`mopidy.settings.LOCAL_MUSIC_PATH`
- :attr:`mopidy.settings.LOCAL_PLAYLIST_PATH`
- :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE`
"""
**Settings:**
from __future__ import unicode_literals
- :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__()
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
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, settings.LOCAL_MUSIC_PATH):
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):
tracks = parse_mpd_tag_cache(settings.LOCAL_TAG_CACHE_FILE,
settings.LOCAL_MUSIC_PATH)
logger.info('Loading tracks in %s from %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 "%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,23 @@
from __future__ import unicode_literals
import logging
import pykka
from mopidy.backends import base
from .library import LocalLibraryProvider
from .playlists import LocalPlaylistsProvider
logger = logging.getLogger('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.playlists = LocalPlaylistsProvider(backend=self)
self.uri_schemes = ['file']

View File

@ -0,0 +1,112 @@
from __future__ import unicode_literals
import logging
from mopidy import settings
from mopidy.backends import base
from mopidy.models import Album
from .translator import parse_mpd_tag_cache
logger = logging.getLogger('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('Failed to lookup %r', uri)
return []
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 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 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,119 @@
from __future__ import unicode_literals
import glob
import logging
import os
import shutil
from mopidy import settings
from mopidy.backends import base, listener
from mopidy.models import Playlist
from mopidy.utils import formatting, path
from .translator import parse_m3u
logger = logging.getLogger('mopidy.backends.local')
class LocalPlaylistsProvider(base.BasePlaylistsProvider):
def __init__(self, *args, **kwargs):
super(LocalPlaylistsProvider, 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 += 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
listener.BackendListener.send('playlists_loaded')
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,16 @@
import logging
import os
from __future__ import unicode_literals
logger = logging.getLogger('mopidy.backends.local.translator')
import logging
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
logger = logging.getLogger('mopidy.backends.local')
def parse_m3u(file_path, music_folder):
"""
r"""
Convert M3U file list of uris
Example M3U data::
@ -51,6 +53,7 @@ def parse_m3u(file_path, music_folder):
return uris
def parse_mpd_tag_cache(tag_cache, music_dir=''):
"""
Converts a MPD tag_cache into a lists of tracks, artists and albums.
@ -67,19 +70,19 @@ def parse_mpd_tag_cache(tag_cache, music_dir=''):
current = {}
state = None
for line in contents.split('\n'):
if line == 'songList begin':
for line in contents.split(b'\n'):
if line == b'songList begin':
state = 'songs'
continue
elif line == 'songList end':
elif line == b'songList end':
state = None
continue
elif not state:
continue
key, value = line.split(': ', 1)
key, value = line.split(b': ', 1)
if key == 'key':
if key == b'key':
_convert_mpd_data(current, tracks, music_dir)
current.clear()
@ -89,6 +92,7 @@ def parse_mpd_tag_cache(tag_cache, music_dir=''):
return tracks
def _convert_mpd_data(data, tracks, music_dir):
if not data:
return
@ -128,7 +132,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:]
@ -142,7 +147,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,36 @@
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.9, < 1.10 (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:**
from __future__ import unicode_literals
- :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__()
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,49 @@
from __future__ import unicode_literals
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 .playlists import SpotifyPlaylistsProvider
self.library = SpotifyLibraryProvider(backend=self)
self.playback = SpotifyPlaybackProvider(audio=audio, backend=self)
self.playlists = SpotifyPlaylistsProvider(backend=self)
self.uri_schemes = ['spotify']
# Fail early if settings are not present
username = settings.SPOTIFY_USERNAME
password = settings.SPOTIFY_PASSWORD
proxy = settings.SPOTIFY_PROXY_HOST
proxy_username = settings.SPOTIFY_PROXY_USERNAME
proxy_password = settings.SPOTIFY_PROXY_PASSWORD
self.spotify = SpotifySessionManager(
username, password, audio=audio, backend_ref=self.actor_ref,
proxy=proxy, proxy_username=proxy_username,
proxy_password=proxy_password)
def on_start(self):
logger.info('Mopidy uses SPOTIFY(R) CORE')
logger.debug('Connecting to Spotify')
self.spotify.start()
def on_stop(self):
self.spotify.logout()

View File

@ -1,9 +1,12 @@
from __future__ import unicode_literals
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):
@ -12,29 +15,29 @@ class SpotifyContainerManager(PyspotifyContainerManager):
def container_loaded(self, container, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: playlist container loaded')
logger.debug('Callback called: playlist container loaded')
self.session_manager.refresh_stored_playlists()
self.session_manager.refresh_playlists()
count = 0
for playlist in self.session_manager.session.playlist_container():
if playlist.type() == 'playlist':
self.session_manager.playlist_manager.watch(playlist)
count += 1
logger.debug(u'Watching %d playlist(s) for changes', count)
logger.debug('Watching %d playlist(s) for changes', count)
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(
'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',
'Callback called: playlist "%s" moved from position %d to %d',
playlist.name(), old_position, new_position)
# container_loaded() is called after this callback, so we do not need
# to handle this callback.
@ -42,7 +45,7 @@ class SpotifyContainerManager(PyspotifyContainerManager):
def playlist_removed(self, container, playlist, position, userdata):
"""Callback used by pyspotify"""
logger.debug(
u'Callback called: playlist "%s" removed from position %d',
'Callback called: playlist "%s" removed from position %d',
playlist.name(), position)
# container_loaded() is called after this callback, so we do not need
# to handle this callback.

View File

@ -1,20 +1,24 @@
from __future__ import unicode_literals
import logging
import Queue
from spotify import Link, SpotifyError
from mopidy.backends.base import BaseLibraryProvider
from mopidy.backends.spotify.translator import SpotifyTranslator
from mopidy.models import Track, Playlist
from mopidy.backends import base
from mopidy.models import Track
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._unloaded_track = Track(uri=uri, name='[loading...]')
self._track = None
@property
@ -22,7 +26,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,49 +51,56 @@ class SpotifyTrack(Track):
return self._proxy.copy(**values)
class SpotifyLibraryProvider(BaseLibraryProvider):
class SpotifyLibraryProvider(base.BaseLibraryProvider):
def find_exact(self, **query):
return self.search(**query)
def lookup(self, uri):
try:
return SpotifyTrack(uri)
return [SpotifyTrack(uri)]
except SpotifyError as e:
logger.debug(u'Failed to lookup "%s": %s', uri, e)
return None
logger.debug('Failed to lookup "%s": %s', uri, e)
return []
def refresh(self, uri=None):
pass # TODO
pass # TODO
def search(self, **query):
if not query:
# Since we can't search for the entire Spotify library, we return
# all tracks in the stored playlists when the query is empty.
# all tracks in the playlists when the query is empty.
tracks = []
for playlist in self.backend.stored_playlists.playlists:
for playlist in self.backend.playlists.playlists:
tracks += playlist.tracks
return Playlist(tracks=tracks)
return tracks
spotify_query = []
for (field, values) in query.iteritems():
if field == u'track':
field = u'title'
if field == u'date':
field = u'year'
if field == 'uri':
tracks = []
for value in values:
track = self.lookup(value)
if track:
tracks.append(track)
return tracks
elif field == 'track':
field = 'title'
elif field == 'date':
field = 'year'
if not hasattr(values, '__iter__'):
values = [values]
for value in values:
if field == u'any':
if field == 'any':
spotify_query.append(value)
elif field == u'year':
value = int(value.split('-')[0]) # Extract year
spotify_query.append(u'%s:%d' % (field, value))
elif field == 'year':
value = int(value.split('-')[0]) # Extract year
spotify_query.append('%s:%d' % (field, value))
else:
spotify_query.append(u'%s:"%s"' % (field, value))
spotify_query = u' '.join(spotify_query)
logger.debug(u'Spotify search query: %s' % spotify_query)
spotify_query.append('%s:"%s"' % (field, value))
spotify_query = ' '.join(spotify_query)
logger.debug('Spotify search query: %s' % spotify_query)
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=[])
return []

View File

@ -1,40 +1,113 @@
from __future__ import unicode_literals
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()
self.audio.prepare_change()
result = self.seek(time_position)
self.audio.start_playback()
return result
def seek(self, time_position):
self.backend.audio.prepare_change()
self.backend.spotify.session.seek(time_position)
self.backend.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

@ -1,9 +1,12 @@
from __future__ import unicode_literals
import datetime
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,83 +15,91 @@ class SpotifyPlaylistManager(PyspotifyPlaylistManager):
def tracks_added(self, playlist, tracks, position, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: '
u'%d track(s) added to position %d in playlist "%s"',
logger.debug(
'Callback called: '
'%d track(s) added to position %d in playlist "%s"',
len(tracks), position, playlist.name())
self.session_manager.refresh_stored_playlists()
self.session_manager.refresh_playlists()
def tracks_moved(self, playlist, tracks, new_position, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: '
u'%d track(s) moved to position %d in playlist "%s"',
logger.debug(
'Callback called: '
'%d track(s) moved to position %d in playlist "%s"',
len(tracks), new_position, playlist.name())
self.session_manager.refresh_stored_playlists()
self.session_manager.refresh_playlists()
def tracks_removed(self, playlist, tracks, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: '
u'%d track(s) removed from playlist "%s"',
logger.debug(
'Callback called: '
'%d track(s) removed from playlist "%s"',
len(tracks), playlist.name())
self.session_manager.refresh_stored_playlists()
self.session_manager.refresh_playlists()
def playlist_renamed(self, playlist, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: Playlist renamed to "%s"',
playlist.name())
self.session_manager.refresh_stored_playlists()
logger.debug(
'Callback called: Playlist renamed to "%s"', playlist.name())
self.session_manager.refresh_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(
'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(
'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(
'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(
'Callback called: Metadata updated for playlist "%s"',
playlist.name())
def track_created_changed(self, playlist, position, user, when, userdata):
"""Callback used by pyspotify"""
when = datetime.datetime.fromtimestamp(when)
logger.debug(
u'Callback called: Created by/when for track %d in playlist '
u'"%s" changed to user "N/A" and time "%s"',
'Callback called: Created by/when for track %d in playlist '
'"%s" changed to user "N/A" and time "%s"',
position, playlist.name(), when)
def track_message_changed(self, playlist, position, message, userdata):
"""Callback used by pyspotify"""
logger.debug(
u'Callback called: Message for track %d in playlist '
u'"%s" changed to "%s"', position, playlist.name(), message)
'Callback called: Message for track %d in playlist '
'"%s" changed to "%s"', position, playlist.name(), message)
def track_seen_changed(self, playlist, position, seen, userdata):
"""Callback used by pyspotify"""
logger.debug(
u'Callback called: Seen attribute for track %d in playlist '
u'"%s" changed to "%s"', position, playlist.name(), seen)
'Callback called: Seen attribute for track %d in playlist '
'"%s" changed to "%s"', position, playlist.name(), seen)
def description_changed(self, playlist, description, userdata):
"""Callback used by pyspotify"""
logger.debug(
u'Callback called: Description changed for playlist "%s" to "%s"',
'Callback called: Description changed for playlist "%s" to "%s"',
playlist.name(), description)
def subscribers_changed(self, playlist, userdata):
"""Callback used by pyspotify"""
logger.debug(
u'Callback called: Subscribers changed for playlist "%s"',
'Callback called: Subscribers changed for playlist "%s"',
playlist.name())
def image_changed(self, playlist, image, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: Image changed for playlist "%s"',
logger.debug(
'Callback called: Image changed for playlist "%s"',
playlist.name())

View File

@ -0,0 +1,22 @@
from __future__ import unicode_literals
from mopidy.backends import base
class SpotifyPlaylistsProvider(base.BasePlaylistsProvider):
def create(self, name):
pass # TODO
def delete(self, uri):
pass # TODO
def lookup(self, uri):
for playlist in self._playlists:
if playlist.uri == uri:
return playlist
def refresh(self):
pass # TODO
def save(self, playlist):
pass # TODO

View File

@ -1,39 +1,49 @@
from __future__ import unicode_literals
import pygst
pygst.require('0.10')
import gst
import logging
import os
import threading
from spotify.manager import SpotifySessionManager as PyspotifySessionManager
from pykka.registry import ActorRegistry
from mopidy import audio, settings
from mopidy.backends.listener import BackendListener
from mopidy.utils import process, versioning
from mopidy import audio, get_version, settings
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.models import Playlist
from mopidy.utils.process import BaseThread
from . import translator
from .container_manager import SpotifyContainerManager
from .playlist_manager import SpotifyPlaylistManager
logger = logging.getLogger('mopidy.backends.spotify.session_manager')
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):
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):
PyspotifySessionManager.__init__(self, username, password)
BaseThread.__init__(self)
def __init__(self, username, password, audio, backend_ref, proxy=None,
proxy_username=None, proxy_password=None):
PyspotifySessionManager.__init__(
self, username, password, proxy=proxy,
proxy_username=proxy_username,
proxy_password=proxy_password)
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
@ -44,29 +54,20 @@ 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:
logger.error(u'Spotify login error: %s', error)
logger.error('Spotify login error: %s', error)
return
logger.info(u'Connected to Spotify')
logger.info('Connected to Spotify')
self.session = session
logger.debug(u'Preferred Spotify bitrate is %s kbps',
logger.debug(
'Preferred Spotify bitrate is %s kbps',
settings.SPOTIFY_BITRATE)
self.session.set_preferred_bitrate(BITRATES[settings.SPOTIFY_BITRATE])
@ -79,30 +80,31 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
def logged_out(self, session):
"""Callback used by pyspotify"""
logger.info(u'Disconnected from Spotify')
logger.info('Disconnected from Spotify')
def metadata_updated(self, session):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: Metadata updated')
logger.debug('Callback called: Metadata updated')
def connection_error(self, session, error):
"""Callback used by pyspotify"""
if error is None:
logger.info(u'Spotify connection OK')
logger.info('Spotify connection OK')
else:
logger.error(u'Spotify connection error: %s', error)
self.backend.playback.pause()
logger.error('Spotify connection error: %s', error)
if self.audio.state.get() == audio.PlaybackState.PLAYING:
self.backend.playback.pause()
def message_to_user(self, session, message):
"""Callback used by pyspotify"""
logger.debug(u'User message: %s', message.strip())
logger.debug('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)
assert sample_type == 0, u'Expects 16-bit signed integer samples'
assert sample_type == 0, 'Expects 16-bit signed integer samples'
capabilites = """
audio/x-raw-int,
endianness=(int)1234,
@ -115,45 +117,50 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
'sample_rate': sample_rate,
'channels': channels,
}
self.audio.emit_data(capabilites, bytes(frames))
return num_frames
buffer_ = gst.Buffer(bytes(frames))
buffer_.set_caps(gst.caps_from_string(capabilites))
if self.audio.emit_data(buffer_).get():
return num_frames
else:
return 0
def play_token_lost(self, session):
"""Callback used by pyspotify"""
logger.debug(u'Play token lost')
logger.debug('Play token lost')
self.backend.playback.pause()
def log_message(self, session, data):
"""Callback used by pyspotify"""
logger.debug(u'System message: %s' % data.strip())
logger.debug('System message: %s' % data.strip())
if 'offline-mgr' in data and 'files unlocked' in data:
# XXX This is a very very fragile and ugly hack, but we get no
# proper event when libspotify is done with initial data loading.
# We delay the expensive refresh of Mopidy's stored playlists until
# this message arrives. This way, we avoid doing the refresh once
# for every playlist or other change. This reduces the time from
# We delay the expensive refresh of Mopidy's playlists until this
# message arrives. This way, we avoid doing the refresh once for
# every playlist or other change. This reduces the time from
# startup until the Spotify backend is ready from 35s to 12s in one
# test with clean Spotify cache. In cases with an outdated cache
# the time improvements should be a lot better.
# the time improvements should be a lot greater.
self._initial_data_receive_completed = True
self.refresh_stored_playlists()
self.refresh_playlists()
def end_of_track(self, session):
"""Callback used by pyspotify"""
logger.debug(u'End of data stream reached')
logger.debug('End of data stream reached')
self.audio.emit_end_of_stream()
def refresh_stored_playlists(self):
"""Refresh the stored playlists in the backend with fresh meta data
from Spotify"""
def refresh_playlists(self):
"""Refresh the playlists in the backend with data from Spotify"""
if not self._initial_data_receive_completed:
logger.debug(u'Still getting data; skipped refresh of playlists')
logger.debug('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.info(u'Loaded %d Spotify playlist(s)', len(playlists))
self.backend.playlists.playlists = playlists
logger.info('Loaded %d Spotify playlist(s)', len(playlists))
BackendListener.send('playlists_loaded')
def search(self, query, queue):
"""Search method used by Mopidy backend"""
@ -161,16 +168,15 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
# TODO Include results from results.albums(), etc. too
# TODO Consider launching a second search if results.total_tracks()
# is larger than len(results.tracks())
playlist = Playlist(tracks=[
SpotifyTranslator.to_mopidy_track(t)
for t in results.tracks()])
queue.put(playlist)
tracks = [
translator.to_mopidy_track(t) for t in results.tracks()]
queue.put(tracks)
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"""
logger.debug(u'Logging out from Spotify')
logger.debug('Logging out from Spotify')
if self.session:
self.session.logout()

View File

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

View File

@ -1,63 +1,69 @@
import logging
from __future__ import unicode_literals
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='[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='[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='[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='[loading...]')
if not spotify_playlist.name():
# Other user's "starred" playlists isn't handled properly by pyspotify
# See https://github.com/mopidy/pyspotify/issues/81
return
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,9 @@
from .current_playlist import CurrentPlaylistController
from __future__ import unicode_literals
# flake8: noqa
from .actor import Core
from .library import LibraryController
from .listener import CoreListener
from .playback import PlaybackController, PlaybackState
from .stored_playlists import StoredPlaylistsController
from .playlists import PlaylistsController
from .tracklist import TracklistController

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

@ -0,0 +1,112 @@
from __future__ import unicode_literals
import itertools
import pykka
from mopidy.audio import AudioListener, PlaybackState
from mopidy.backends.listener import BackendListener
from .library import LibraryController
from .listener import CoreListener
from .playback import PlaybackController
from .playlists import PlaylistsController
from .tracklist import TracklistController
class Core(pykka.ThreadingActor, AudioListener, BackendListener):
#: 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 playlists controller. An instance of
#: :class:`mopidy.core.PlaylistsController`.
playlists = None
#: The tracklist controller. An instance of
#: :class:`mopidy.core.TracklistController`.
tracklist = None
def __init__(self, audio=None, backends=None):
super(Core, self).__init__()
self.backends = Backends(backends)
self.library = LibraryController(backends=self.backends, core=self)
self.playback = PlaybackController(
audio=audio, backends=self.backends, core=self)
self.playlists = PlaylistsController(
backends=self.backends, core=self)
self.tracklist = TracklistController(core=self)
def get_uri_schemes(self):
futures = [b.uri_schemes for b in self.backends]
results = pykka.get_all(futures)
uri_schemes = itertools.chain(*results)
return sorted(uri_schemes)
uri_schemes = property(get_uri_schemes)
"""List of URI schemes we can handle"""
def reached_end_of_stream(self):
self.playback.on_end_of_track()
def state_changed(self, old_state, new_state):
# XXX: This is a temporary fix for issue #232 while we wait for a more
# permanent solution with the implementation of issue #234. When the
# Spotify play token is lost, the Spotify backend pauses audio
# playback, but mopidy.core doesn't know this, so we need to update
# mopidy.core's state to match the actual state in mopidy.audio. If we
# don't do this, clients will think that we're still playing.
if (new_state == PlaybackState.PAUSED
and self.playback.state != PlaybackState.PAUSED):
self.playback.state = new_state
self.playback._trigger_track_playback_paused()
def playlists_loaded(self):
# Forward event from backend to frontends
CoreListener.send('playlists_loaded')
class Backends(list):
def __init__(self, backends):
super(Backends, self).__init__(backends)
# These lists keeps the backends in the original order, but only
# includes those which implements the required backend provider. Since
# it is important to keep the order, we can't simply use .values() on
# the X_by_uri_scheme dicts below.
self.with_library = [b for b in backends if b.has_library().get()]
self.with_playback = [b for b in backends if b.has_playback().get()]
self.with_playlists = [
b for b in backends if b.has_playlists().get()]
self.by_uri_scheme = {}
for backend in backends:
for uri_scheme in backend.uri_schemes.get():
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
self.with_library_by_uri_scheme = {}
self.with_playback_by_uri_scheme = {}
self.with_playlists_by_uri_scheme = {}
for uri_scheme, backend in self.by_uri_scheme.items():
if backend.has_library().get():
self.with_library_by_uri_scheme[uri_scheme] = backend
if backend.has_playback().get():
self.with_playback_by_uri_scheme[uri_scheme] = backend
if backend.has_playlists().get():
self.with_playlists_by_uri_scheme[uri_scheme] = backend

View File

@ -1,243 +0,0 @@
from copy import copy
import logging
import random
from mopidy.listeners import BackendListener
from mopidy.models import CpTrack
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
self.cp_id = 0
self._cp_tracks = []
self._version = 0
@property
def cp_tracks(self):
"""
List of two-tuples of (CPID integer, :class:`mopidy.models.Track`).
Read-only.
"""
return [copy(cp_track) for cp_track in self._cp_tracks]
@property
def tracks(self):
"""
List of :class:`mopidy.models.Track` in the current playlist.
Read-only.
"""
return [cp_track.track for cp_track in self._cp_tracks]
@property
def length(self):
"""
Length of the current playlist.
"""
return len(self._cp_tracks)
@property
def version(self):
"""
The current playlist version. Integer which is increased every time the
current playlist is changed. Is not reset before Mopidy is restarted.
"""
return self._version
@version.setter
def version(self, version):
self._version = version
self.backend.playback.on_current_playlist_change()
self._trigger_playlist_changed()
def add(self, track, at_position=None, increase_version=True):
"""
Add the track to the end of, or at the given position in the current
playlist.
:param track: track to add
:type track: :class:`mopidy.models.Track`
:param at_position: position in current playlist to add track
:type at_position: int or :class:`None`
:rtype: two-tuple of (CPID integer, :class:`mopidy.models.Track`) that
was added to the current playlist playlist
"""
assert at_position <= len(self._cp_tracks), \
u'at_position can not be greater than playlist length'
cp_track = CpTrack(self.cp_id, track)
if at_position is not None:
self._cp_tracks.insert(at_position, cp_track)
else:
self._cp_tracks.append(cp_track)
if increase_version:
self.version += 1
self.cp_id += 1
return cp_track
def append(self, tracks):
"""
Append the given tracks to the current playlist.
:param tracks: tracks to append
:type tracks: list of :class:`mopidy.models.Track`
"""
for track in tracks:
self.add(track, increase_version=False)
if tracks:
self.version += 1
def clear(self):
"""Clear the current playlist."""
self._cp_tracks = []
self.version += 1
def get(self, **criteria):
"""
Get track by given criterias from current playlist.
Raises :exc:`LookupError` if a unique match is not found.
Examples::
get(cpid=7) # Returns track with CPID 7
# (current playlist ID)
get(id=1) # Returns track with ID 1
get(uri='xyz') # Returns track with URI 'xyz'
get(id=1, uri='xyz') # Returns track with ID 1 and URI 'xyz'
:param criteria: on or more criteria to match by
:type criteria: dict
:rtype: two-tuple (CPID integer, :class:`mopidy.models.Track`)
"""
matches = self._cp_tracks
for (key, value) in criteria.iteritems():
if key == 'cpid':
matches = filter(lambda ct: ct.cpid == value, matches)
else:
matches = filter(lambda ct: getattr(ct.track, key) == value,
matches)
if len(matches) == 1:
return matches[0]
criteria_string = ', '.join(
['%s=%s' % (k, v) for (k, v) in criteria.iteritems()])
if len(matches) == 0:
raise LookupError(u'"%s" match no tracks' % criteria_string)
else:
raise LookupError(u'"%s" match multiple tracks' % criteria_string)
def index(self, cp_track):
"""
Get index of the given (CPID integer, :class:`mopidy.models.Track`)
two-tuple in the current playlist.
Raises :exc:`ValueError` if not found.
:param cp_track: track to find the index of
:type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`)
:rtype: int
"""
return self._cp_tracks.index(cp_track)
def move(self, start, end, to_position):
"""
Move the tracks in the slice ``[start:end]`` to ``to_position``.
:param start: position of first track to move
:type start: int
:param end: position after last track to move
:type end: int
:param to_position: new position for the tracks
:type to_position: int
"""
if start == end:
end += 1
cp_tracks = self._cp_tracks
assert start < end, 'start must be smaller than end'
assert start >= 0, 'start must be at least zero'
assert end <= len(cp_tracks), \
'end can not be larger than playlist length'
assert to_position >= 0, 'to_position must be at least zero'
assert to_position <= len(cp_tracks), \
'to_position can not be larger than playlist length'
new_cp_tracks = cp_tracks[:start] + cp_tracks[end:]
for cp_track in cp_tracks[start:end]:
new_cp_tracks.insert(to_position, cp_track)
to_position += 1
self._cp_tracks = new_cp_tracks
self.version += 1
def remove(self, **criteria):
"""
Remove the track from the current playlist.
Uses :meth:`get()` to lookup the track to remove.
:param criteria: on or more criteria to match by
:type criteria: dict
"""
cp_track = self.get(**criteria)
position = self._cp_tracks.index(cp_track)
del self._cp_tracks[position]
self.version += 1
def shuffle(self, start=None, end=None):
"""
Shuffles the entire playlist. If ``start`` and ``end`` is given only
shuffles the slice ``[start:end]``.
:param start: position of first track to shuffle
:type start: int or :class:`None`
:param end: position after last track to shuffle
:type end: int or :class:`None`
"""
cp_tracks = self._cp_tracks
if start is not None and end is not None:
assert start < end, 'start must be smaller than end'
if start is not None:
assert start >= 0, 'start must be at least zero'
if end is not None:
assert end <= len(cp_tracks), 'end can not be larger than ' + \
'playlist length'
before = cp_tracks[:start or 0]
shuffled = cp_tracks[start:end]
after = cp_tracks[end or len(cp_tracks):]
random.shuffle(shuffled)
self._cp_tracks = before + shuffled + after
self.version += 1
def slice(self, start, end):
"""
Returns a slice of the current playlist, limited by the given
start and end positions.
:param start: position of first track to include in slice
:type start: int
:param end: position after last track to include in slice
:type end: int
:rtype: two-tuple of (CPID integer, :class:`mopidy.models.Track`)
"""
return [copy(cp_track) for cp_track in self._cp_tracks[start:end]]
def _trigger_playlist_changed(self):
logger.debug(u'Triggering playlist changed event')
BackendListener.send('playlist_changed')

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`
"""
from __future__ import unicode_literals
import itertools
import urlparse
import pykka
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.with_library_by_uri_scheme.get(uri_scheme, None)
def find_exact(self, **query):
"""
@ -27,19 +32,29 @@ class LibraryController(object):
:param query: one or more queries to search for
:type query: dict
:rtype: :class:`mopidy.models.Playlist`
:rtype: list of :class:`mopidy.models.Track`
"""
return self.provider.find_exact(**query)
futures = [
b.library.find_exact(**query) for b in self.backends.with_library]
results = pykka.get_all(futures)
return list(itertools.chain(*results))
def lookup(self, uri):
"""
Lookup track with given URI. Returns :class:`None` if not found.
Lookup the given URI.
If the URI expands to multiple tracks, the returned list will contain
them all.
:param uri: track URI
:type uri: string
:rtype: :class:`mopidy.models.Track` or :class:`None`
:rtype: list of :class:`mopidy.models.Track`
"""
return self.provider.lookup(uri)
backend = self._get_backend(uri)
if backend:
return backend.library.lookup(uri).get()
else:
return []
def refresh(self, uri=None):
"""
@ -48,7 +63,14 @@ 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.with_library]
pykka.get_all(futures)
def search(self, **query):
"""
@ -65,6 +87,9 @@ class LibraryController(object):
:param query: one or more queries to search for
:type query: dict
:rtype: :class:`mopidy.models.Playlist`
:rtype: list of :class:`mopidy.models.Track`
"""
return self.provider.search(**query)
futures = [
b.library.search(**query) for b in self.backends.with_library]
results = pykka.get_all(futures)
return list(itertools.chain(*results))

View File

@ -1,11 +1,14 @@
from pykka import registry
from __future__ import unicode_literals
class BackendListener(object):
import pykka
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 +16,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 +47,6 @@ class BackendListener(object):
"""
pass
def track_playback_started(self, track):
"""
Called whenever a new track starts playing.
@ -74,19 +71,43 @@ 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
def tracklist_changed(self):
"""
Called whenever the tracklist is changed.
*MAY* be implemented by actor.
"""
pass
def playlist_changed(self):
def playlists_loaded(self):
"""
Called when playlists are loaded or refreshed.
*MAY* be implemented by actor.
"""
pass
def playlist_changed(self, playlist):
"""
Called whenever a playlist is changed.
*MAY* be implemented by actor.
:param playlist: the changed playlist
:type playlist: :class:`mopidy.models.Playlist`
"""
pass
@ -106,11 +127,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,359 +1,309 @@
from __future__ import unicode_literals
import logging
import random
import time
import urlparse
from mopidy.listeners import BackendListener
from mopidy.audio import PlaybackState
from . import listener
logger = logging.getLogger('mopidy.backends.base')
def option_wrapper(name, default):
def get_option(self):
return getattr(self, name, default)
def set_option(self, value):
if getattr(self, name, default) != value:
self._trigger_options_changed()
return setattr(self, name, value)
return property(get_option, set_option)
class PlaybackState(object):
"""
Enum of playback states.
"""
#: Constant representing the paused state.
PAUSED = u'paused'
#: Constant representing the playing state.
PLAYING = u'playing'
#: Constant representing the stopped state.
STOPPED = u'stopped'
logger = logging.getLogger('mopidy.core')
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
pykka_traversable = True
#: :class:`True`
#: Tracks are removed from the playlist when they have been played.
#: :class:`False`
#: Tracks are not removed from the playlist.
consume = option_wrapper('_consume', False)
def __init__(self, audio, backends, core):
self.audio = audio
self.backends = backends
self.core = core
#: The currently playing or selected track.
#:
#: A two-tuple of (CPID integer, :class:`mopidy.models.Track`) or
#: :class:`None`.
current_cp_track = None
#: :class:`True`
#: Tracks are selected at random from the playlist.
#: :class:`False`
#: Tracks are played in the order of the playlist.
random = option_wrapper('_random', False)
#: :class:`True`
#: The current playlist is played repeatedly. To repeat a single track,
#: select both :attr:`repeat` and :attr:`single`.
#: :class:`False`
#: The current playlist is played once.
repeat = option_wrapper('_repeat', False)
#: :class:`True`
#: Playback is stopped after current song, unless in :attr:`repeat`
#: mode.
#: :class:`False`
#: Playback continues after current song.
single = option_wrapper('_single', False)
def __init__(self, backend, provider):
self.backend = backend
self.provider = provider
self._state = PlaybackState.STOPPED
self._shuffled = []
self._first_shuffle = True
self.play_time_accumulated = 0
self.play_time_started = 0
self._volume = None
def _get_cpid(self, cp_track):
if cp_track is None:
def _get_backend(self):
if self.current_tl_track is None:
return None
return cp_track.cpid
uri = self.current_tl_track.track.uri
uri_scheme = urlparse.urlparse(uri).scheme
return self.backends.with_playback_by_uri_scheme.get(uri_scheme, None)
def _get_track(self, cp_track):
if cp_track is None:
return None
return cp_track.track
### Properties
@property
def current_cpid(self):
"""
The CPID (current playlist ID) of the currently playing or selected
track.
def get_consume(self):
return getattr(self, '_consume', False)
Read-only. Extracted from :attr:`current_cp_track` for convenience.
"""
return self._get_cpid(self.current_cp_track)
def set_consume(self, value):
if self.get_consume() != value:
self._trigger_options_changed()
return setattr(self, '_consume', value)
@property
def current_track(self):
"""
The currently playing or selected :class:`mopidy.models.Track`.
consume = property(get_consume, set_consume)
"""
:class:`True`
Tracks are removed from the playlist when they have been played.
:class:`False`
Tracks are not removed from the playlist.
"""
Read-only. Extracted from :attr:`current_cp_track` for convenience.
"""
return self._get_track(self.current_cp_track)
current_tl_track = None
"""
The currently playing or selected :class:`mopidy.models.TlTrack`, or
:class:`None`.
"""
@property
def current_playlist_position(self):
"""
The position of the current track in the current playlist.
def get_current_track(self):
return self.current_tl_track and self.current_tl_track.track
Read-only.
"""
if self.current_cp_track is None:
current_track = property(get_current_track)
"""
The currently playing or selected :class:`mopidy.models.Track`.
Read-only. Extracted from :attr:`current_tl_track` for convenience.
"""
def get_random(self):
return getattr(self, '_random', False)
def set_random(self, value):
if self.get_random() != value:
self._trigger_options_changed()
return setattr(self, '_random', value)
random = property(get_random, set_random)
"""
:class:`True`
Tracks are selected at random from the playlist.
:class:`False`
Tracks are played in the order of the playlist.
"""
def get_repeat(self):
return getattr(self, '_repeat', False)
def set_repeat(self, value):
if self.get_repeat() != value:
self._trigger_options_changed()
return setattr(self, '_repeat', value)
repeat = property(get_repeat, set_repeat)
"""
:class:`True`
The current playlist is played repeatedly. To repeat a single track,
select both :attr:`repeat` and :attr:`single`.
:class:`False`
The current playlist is played once.
"""
def get_single(self):
return getattr(self, '_single', False)
def set_single(self, value):
if self.get_single() != value:
self._trigger_options_changed()
return setattr(self, '_single', value)
single = property(get_single, set_single)
"""
:class:`True`
Playback is stopped after current song, unless in :attr:`repeat`
mode.
:class:`False`
Playback continues after current song.
"""
def get_state(self):
return self._state
def set_state(self, new_state):
(old_state, self._state) = (self.state, new_state)
logger.debug('Changing state: %s -> %s', old_state, new_state)
self._trigger_playback_state_changed(old_state, new_state)
state = property(get_state, set_state)
"""
The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or
:attr:`STOPPED`.
Possible states and transitions:
.. digraph:: state_transitions
"STOPPED" -> "PLAYING" [ label="play" ]
"STOPPED" -> "PAUSED" [ label="pause" ]
"PLAYING" -> "STOPPED" [ label="stop" ]
"PLAYING" -> "PAUSED" [ label="pause" ]
"PLAYING" -> "PLAYING" [ label="play" ]
"PAUSED" -> "PLAYING" [ label="resume" ]
"PAUSED" -> "STOPPED" [ label="stop" ]
"""
def get_time_position(self):
backend = self._get_backend()
if backend:
return backend.playback.get_time_position().get()
else:
return 0
time_position = property(get_time_position)
"""Time position in milliseconds."""
def get_tracklist_position(self):
if self.current_tl_track is None:
return None
try:
return self.backend.current_playlist.cp_tracks.index(
self.current_cp_track)
return self.core.tracklist.tl_tracks.index(self.current_tl_track)
except ValueError:
return None
@property
def track_at_eot(self):
"""
The track that will be played at the end of the current track.
tracklist_position = property(get_tracklist_position)
"""
The position of the current track in the tracklist.
Read-only. A :class:`mopidy.models.Track` extracted from
:attr:`cp_track_at_eot` for convenience.
"""
return self._get_track(self.cp_track_at_eot)
Read-only.
"""
@property
def cp_track_at_eot(self):
"""
The track that will be played at the end of the current track.
Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`).
Not necessarily the same track as :attr:`cp_track_at_next`.
"""
def get_tl_track_at_eot(self):
# pylint: disable = R0911
# Too many return statements
cp_tracks = self.backend.current_playlist.cp_tracks
tl_tracks = self.core.tracklist.tl_tracks
if not cp_tracks:
if not tl_tracks:
return None
if self.random and not self._shuffled:
if self.repeat or self._first_shuffle:
logger.debug('Shuffling tracks')
self._shuffled = cp_tracks
self._shuffled = tl_tracks
random.shuffle(self._shuffled)
self._first_shuffle = False
if self.random and self._shuffled:
return self._shuffled[0]
if self.current_cp_track is None:
return cp_tracks[0]
if self.current_tl_track is None:
return tl_tracks[0]
if self.repeat and self.single:
return cp_tracks[self.current_playlist_position]
return tl_tracks[self.tracklist_position]
if self.repeat and not self.single:
return cp_tracks[
(self.current_playlist_position + 1) % len(cp_tracks)]
return tl_tracks[(self.tracklist_position + 1) % len(tl_tracks)]
try:
return cp_tracks[self.current_playlist_position + 1]
return tl_tracks[self.tracklist_position + 1]
except IndexError:
return None
@property
def track_at_next(self):
"""
The track that will be played if calling :meth:`next()`.
tl_track_at_eot = property(get_tl_track_at_eot)
"""
The track that will be played at the end of the current track.
Read-only. A :class:`mopidy.models.Track` extracted from
:attr:`cp_track_at_next` for convenience.
"""
return self._get_track(self.cp_track_at_next)
Read-only. A :class:`mopidy.models.TlTrack`.
@property
def cp_track_at_next(self):
"""
The track that will be played if calling :meth:`next()`.
Not necessarily the same track as :attr:`tl_track_at_next`.
"""
Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`).
def get_tl_track_at_next(self):
tl_tracks = self.core.tracklist.tl_tracks
For normal playback this is the next track in the playlist. If repeat
is enabled the next track can loop around the playlist. When random is
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
if not cp_tracks:
if not tl_tracks:
return None
if self.random and not self._shuffled:
if self.repeat or self._first_shuffle:
logger.debug('Shuffling tracks')
self._shuffled = cp_tracks
self._shuffled = tl_tracks
random.shuffle(self._shuffled)
self._first_shuffle = False
if self.random and self._shuffled:
return self._shuffled[0]
if self.current_cp_track is None:
return cp_tracks[0]
if self.current_tl_track is None:
return tl_tracks[0]
if self.repeat:
return cp_tracks[
(self.current_playlist_position + 1) % len(cp_tracks)]
return tl_tracks[(self.tracklist_position + 1) % len(tl_tracks)]
try:
return cp_tracks[self.current_playlist_position + 1]
return tl_tracks[self.tracklist_position + 1]
except IndexError:
return None
@property
def track_at_previous(self):
"""
The track that will be played if calling :meth:`previous()`.
tl_track_at_next = property(get_tl_track_at_next)
"""
The track that will be played if calling :meth:`next()`.
Read-only. A :class:`mopidy.models.Track` extracted from
:attr:`cp_track_at_previous` for convenience.
"""
return self._get_track(self.cp_track_at_previous)
Read-only. A :class:`mopidy.models.TlTrack`.
@property
def cp_track_at_previous(self):
"""
The track that will be played if calling :meth:`previous()`.
For normal playback this is the next track in the playlist. If repeat
is enabled the next track can loop around the playlist. When random is
enabled this should be a random track, all tracks should be played once
before the list repeats.
"""
A two-tuple of (CPID integer, :class:`mopidy.models.Track`).
For normal playback this is the previous track in the playlist. If
random and/or consume is enabled it should return the current track
instead.
"""
def get_tl_track_at_previous(self):
if self.repeat or self.consume or self.random:
return self.current_cp_track
return self.current_tl_track
if self.current_playlist_position in (None, 0):
if self.tracklist_position in (None, 0):
return None
return self.backend.current_playlist.cp_tracks[
self.current_playlist_position - 1]
return self.core.tracklist.tl_tracks[self.tracklist_position - 1]
@property
def state(self):
"""
The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or
:attr:`STOPPED`.
tl_track_at_previous = property(get_tl_track_at_previous)
"""
The track that will be played if calling :meth:`previous()`.
Possible states and transitions:
A :class:`mopidy.models.TlTrack`.
.. digraph:: state_transitions
For normal playback this is the previous track in the playlist. If
random and/or consume is enabled it should return the current track
instead.
"""
"STOPPED" -> "PLAYING" [ label="play" ]
"STOPPED" -> "PAUSED" [ label="pause" ]
"PLAYING" -> "STOPPED" [ label="stop" ]
"PLAYING" -> "PAUSED" [ label="pause" ]
"PLAYING" -> "PLAYING" [ label="play" ]
"PAUSED" -> "PLAYING" [ label="resume" ]
"PAUSED" -> "STOPPED" [ label="stop" ]
"""
return self._state
def get_volume(self):
if self.audio:
return self.audio.get_volume().get()
else:
# For testing
return self._volume
@state.setter
def state(self, new_state):
(old_state, self._state) = (self.state, new_state)
logger.debug(u'Changing state: %s -> %s', old_state, new_state)
def set_volume(self, volume):
if self.audio:
self.audio.set_volume(volume)
else:
# For testing
self._volume = volume
self._trigger_playback_state_changed()
volume = property(get_volume, set_volume)
"""Volume as int in range [0..100] or :class:`None`"""
# 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()
### Methods
@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:
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)
@property
def volume(self):
return self.provider.get_volume()
@volume.setter
def volume(self, volume):
self.provider.set_volume(volume)
def change_track(self, cp_track, on_error_step=1):
def change_track(self, tl_track, on_error_step=1):
"""
Change to the given track, keeping the current playback state.
:param cp_track: track to change to
:type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`)
or :class:`None`
:param tl_track: track to change to
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
:param on_error_step: direction to step at play error, 1 for next
track (default), -1 for previous track
:type on_error_step: int, -1 or 1
"""
old_state = self.state
self.stop()
self.current_cp_track = cp_track
self.current_tl_track = tl_track
if old_state == PlaybackState.PLAYING:
self.play(on_error_step=on_error_step)
elif old_state == PlaybackState.PAUSED:
@ -362,33 +312,35 @@ class PlaybackController(object):
def on_end_of_track(self):
"""
Tell the playback controller that end of track is reached.
Used by event handler in :class:`mopidy.core.Core`.
"""
if self.state == PlaybackState.STOPPED:
return
original_cp_track = self.current_cp_track
original_tl_track = self.current_tl_track
if self.cp_track_at_eot:
if self.tl_track_at_eot:
self._trigger_track_playback_ended()
self.play(self.cp_track_at_eot)
self.play(self.tl_track_at_eot)
else:
self.stop(clear_current_track=True)
if self.consume:
self.backend.current_playlist.remove(cpid=original_cp_track.cpid)
self.core.tracklist.remove(tlid=original_tl_track.tlid)
def on_current_playlist_change(self):
def on_tracklist_change(self):
"""
Tell the playback controller that the current playlist has changed.
Used by :class:`mopidy.backends.base.CurrentPlaylistController`.
Used by :class:`mopidy.core.TracklistController`.
"""
self._first_shuffle = True
self._shuffled = []
if (not self.backend.current_playlist.cp_tracks or
self.current_cp_track not in
self.backend.current_playlist.cp_tracks):
if (not self.core.tracklist.tl_tracks or
self.current_tl_track not in
self.core.tracklist.tl_tracks):
self.stop(clear_current_track=True)
def next(self):
@ -398,57 +350,58 @@ class PlaybackController(object):
The current playback state will be kept. If it was playing, playing
will continue. If it was paused, it will still be paused, etc.
"""
if self.cp_track_at_next:
if self.tl_track_at_next:
self._trigger_track_playback_ended()
self.change_track(self.cp_track_at_next)
self.change_track(self.tl_track_at_next)
else:
self.stop(clear_current_track=True)
def pause(self):
"""Pause playback."""
if self.provider.pause():
backend = self._get_backend()
if not backend or backend.playback.pause().get():
self.state = PlaybackState.PAUSED
self._trigger_track_playback_paused()
def play(self, cp_track=None, on_error_step=1):
def play(self, tl_track=None, on_error_step=1):
"""
Play the given track, or if the given track is :class:`None`, play the
currently active track.
:param cp_track: track to play
:type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`)
or :class:`None`
:param tl_track: track to play
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
:param on_error_step: direction to step at play error, 1 for next
track (default), -1 for previous track
:type on_error_step: int, -1 or 1
"""
if cp_track is not None:
assert cp_track in self.backend.current_playlist.cp_tracks
elif cp_track is None:
if tl_track is not None:
assert tl_track in self.core.tracklist.tl_tracks
elif tl_track is None:
if self.state == PlaybackState.PAUSED:
return self.resume()
elif self.current_cp_track is not None:
cp_track = self.current_cp_track
elif self.current_cp_track is None and on_error_step == 1:
cp_track = self.cp_track_at_next
elif self.current_cp_track is None and on_error_step == -1:
cp_track = self.cp_track_at_previous
elif self.current_tl_track is not None:
tl_track = self.current_tl_track
elif self.current_tl_track is None and on_error_step == 1:
tl_track = self.tl_track_at_next
elif self.current_tl_track is None and on_error_step == -1:
tl_track = self.tl_track_at_previous
if cp_track is not None:
self.current_cp_track = cp_track
if tl_track is not None:
self.current_tl_track = tl_track
self.state = PlaybackState.PLAYING
if not self.provider.play(cp_track.track):
backend = self._get_backend()
if not backend or not backend.playback.play(tl_track.track).get():
# Track is not playable
if self.random and self._shuffled:
self._shuffled.remove(cp_track)
self._shuffled.remove(tl_track)
if on_error_step == 1:
self.next()
elif on_error_step == -1:
self.previous()
if self.random and self.current_cp_track in self._shuffled:
self._shuffled.remove(self.current_cp_track)
if self.random and self.current_tl_track in self._shuffled:
self._shuffled.remove(self.current_tl_track)
self._trigger_track_playback_started()
@ -460,11 +413,14 @@ class PlaybackController(object):
will continue. If it was paused, it will still be paused, etc.
"""
self._trigger_track_playback_ended()
self.change_track(self.cp_track_at_previous, on_error_step=-1)
self.change_track(self.tl_track_at_previous, on_error_step=-1)
def resume(self):
"""If paused, resume playing the current track."""
if self.state == PlaybackState.PAUSED and self.provider.resume():
if self.state != PlaybackState.PAUSED:
return
backend = self._get_backend()
if backend and backend.playback.resume().get():
self.state = PlaybackState.PLAYING
self._trigger_track_playback_resumed()
@ -476,7 +432,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.tracklist.tracks:
return False
if self.state == PlaybackState.STOPPED:
@ -490,12 +446,13 @@ class PlaybackController(object):
self.next()
return True
self.play_time_started = self._current_wall_time
self.play_time_accumulated = time_position
backend = self._get_backend()
if not backend:
return False
success = self.provider.seek(time_position)
success = 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,51 +464,54 @@ class PlaybackController(object):
:type clear_current_track: boolean
"""
if self.state != PlaybackState.STOPPED:
if self.provider.stop():
backend = self._get_backend()
if not backend or backend.playback.stop().get():
self._trigger_track_playback_ended()
self.state = PlaybackState.STOPPED
if clear_current_track:
self.current_cp_track = None
self.current_tl_track = None
def _trigger_track_playback_paused(self):
logger.debug(u'Triggering track playback paused event')
logger.debug('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')
logger.debug('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')
logger.debug('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')
logger.debug('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):
logger.debug(u'Triggering playback state change event')
BackendListener.send('playback_state_changed')
def _trigger_playback_state_changed(self, old_state, new_state):
logger.debug('Triggering playback state change event')
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')
logger.debug('Triggering options changed event')
listener.CoreListener.send('options_changed')
def _trigger_seeked(self):
logger.debug(u'Triggering seeked event')
BackendListener.send('seeked')
def _trigger_seeked(self, time_position):
logger.debug('Triggering seeked event')
listener.CoreListener.send('seeked', time_position=time_position)

164
mopidy/core/playlists.py Normal file
View File

@ -0,0 +1,164 @@
from __future__ import unicode_literals
import itertools
import urlparse
import pykka
from . import listener
class PlaylistsController(object):
pykka_traversable = True
def __init__(self, backends, core):
self.backends = backends
self.core = core
def get_playlists(self):
futures = [
b.playlists.playlists for b in self.backends.with_playlists]
results = pykka.get_all(futures)
return list(itertools.chain(*results))
playlists = property(get_playlists)
"""
The available playlists.
Read-only. List of :class:`mopidy.models.Playlist`.
"""
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`
"""
if uri_scheme in self.backends.with_playlists_by_uri_scheme:
backend = self.backends.by_uri_scheme[uri_scheme]
else:
backend = self.backends.with_playlists[0]
playlist = backend.playlists.create(name).get()
listener.CoreListener.send('playlist_changed', playlist=playlist)
return playlist
def delete(self, uri):
"""
Delete playlist identified by the URI.
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
"""
uri_scheme = urlparse.urlparse(uri).scheme
backend = self.backends.with_playlists_by_uri_scheme.get(
uri_scheme, None)
if backend:
backend.playlists.delete(uri).get()
def filter(self, **criteria):
"""
Filter playlists by the given criterias.
Examples::
filter(name='a') # Returns track with name 'a'
filter(uri='xyz') # Returns track with URI 'xyz'
filter(name='a', uri='xyz') # Returns track with name 'a' and URI
# 'xyz'
:param criteria: one or more criteria to match by
:type criteria: dict
:rtype: list of :class:`mopidy.models.Playlist`
"""
matches = self.playlists
for (key, value) in criteria.iteritems():
matches = filter(lambda p: getattr(p, key) == value, matches)
return matches
def lookup(self, uri):
"""
Lookup playlist with given URI in both the set of playlists and in any
other playlist sources. Returns :class:`None` if not found.
:param uri: playlist URI
:type uri: string
:rtype: :class:`mopidy.models.Playlist` or :class:`None`
"""
uri_scheme = urlparse.urlparse(uri).scheme
backend = self.backends.with_playlists_by_uri_scheme.get(
uri_scheme, None)
if backend:
return backend.playlists.lookup(uri).get()
else:
return None
def refresh(self, uri_scheme=None):
"""
Refresh the playlists in :attr:`playlists`.
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 uri_scheme: limit to the backend matching the URI scheme
:type uri_scheme: string
"""
if uri_scheme is None:
futures = [
b.playlists.refresh() for b in self.backends.with_playlists]
pykka.get_all(futures)
listener.CoreListener.send('playlists_loaded')
else:
backend = self.backends.with_playlists_by_uri_scheme.get(
uri_scheme, None)
if backend:
backend.playlists.refresh().get()
listener.CoreListener.send('playlists_loaded')
def save(self, playlist):
"""
Save the playlist.
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`
"""
if playlist.uri is None:
return
uri_scheme = urlparse.urlparse(playlist.uri).scheme
backend = self.backends.with_playlists_by_uri_scheme.get(
uri_scheme, None)
if backend:
playlist = backend.playlists.save(playlist).get()
listener.CoreListener.send('playlist_changed', playlist=playlist)
return playlist

View File

@ -1,113 +0,0 @@
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`
"""
pykka_traversable = True
def __init__(self, backend, provider):
self.backend = backend
self.provider = provider
@property
def playlists(self):
"""
Currently stored playlists.
Read/write. List of :class:`mopidy.models.Playlist`.
"""
return self.provider.playlists
@playlists.setter
def playlists(self, playlists):
self.provider.playlists = playlists
def create(self, name):
"""
Create a new playlist.
:param name: name of the new playlist
:type name: string
:rtype: :class:`mopidy.models.Playlist`
"""
return self.provider.create(name)
def delete(self, playlist):
"""
Delete playlist.
:param playlist: the playlist to delete
:type playlist: :class:`mopidy.models.Playlist`
"""
return self.provider.delete(playlist)
def get(self, **criteria):
"""
Get playlist by given criterias from the set of stored playlists.
Raises :exc:`LookupError` if a unique match is not found.
Examples::
get(name='a') # Returns track with name 'a'
get(uri='xyz') # Returns track with URI 'xyz'
get(name='a', uri='xyz') # Returns track with name 'a' and URI
# 'xyz'
:param criteria: one or more criteria to match by
:type criteria: dict
:rtype: :class:`mopidy.models.Playlist`
"""
matches = self.playlists
for (key, value) in criteria.iteritems():
matches = filter(lambda p: getattr(p, key) == value, matches)
if len(matches) == 1:
return matches[0]
criteria_string = ', '.join(
['%s=%s' % (k, v) for (k, v) in criteria.iteritems()])
if len(matches) == 0:
raise LookupError('"%s" match no playlists' % criteria_string)
else:
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.
:param uri: playlist URI
:type uri: string
:rtype: :class:`mopidy.models.Playlist`
"""
return self.provider.lookup(uri)
def refresh(self):
"""
Refresh the stored playlists in
:attr:`mopidy.backends.base.StoredPlaylistsController.playlists`.
"""
return self.provider.refresh()
def rename(self, playlist, new_name):
"""
Rename playlist.
:param playlist: the playlist
:type playlist: :class:`mopidy.models.Playlist`
:param new_name: the new name
:type new_name: string
"""
return self.provider.rename(playlist, new_name)
def save(self, playlist):
"""
Save the playlist to the set of stored playlists.
:param playlist: the playlist
:type playlist: :class:`mopidy.models.Playlist`
"""
return self.provider.save(playlist)

240
mopidy/core/tracklist.py Normal file
View File

@ -0,0 +1,240 @@
from __future__ import unicode_literals
import logging
import random
from mopidy.models import TlTrack
from . import listener
logger = logging.getLogger('mopidy.core')
class TracklistController(object):
pykka_traversable = True
def __init__(self, core):
self._core = core
self._next_tlid = 0
self._tl_tracks = []
self._version = 0
def get_tl_tracks(self):
return self._tl_tracks[:]
tl_tracks = property(get_tl_tracks)
"""
List of :class:`mopidy.models.TlTrack`.
Read-only.
"""
def get_tracks(self):
return [tl_track.track for tl_track in self._tl_tracks]
tracks = property(get_tracks)
"""
List of :class:`mopidy.models.Track` in the tracklist.
Read-only.
"""
def get_length(self):
return len(self._tl_tracks)
length = property(get_length)
"""Length of the tracklist."""
def get_version(self):
return self._version
def _increase_version(self):
self._version += 1
self._core.playback.on_tracklist_change()
self._trigger_tracklist_changed()
version = property(get_version)
"""
The tracklist version.
Read-only. Integer which is increased every time the tracklist is changed.
Is not reset before Mopidy is restarted.
"""
def add(self, tracks, at_position=None):
"""
Add the track or list of tracks to the tracklist.
If ``at_position`` is given, the tracks placed at the given position in
the tracklist. If ``at_position`` is not given, the tracks are appended
to the end of the tracklist.
Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event.
:param tracks: tracks to add
:type tracks: list of :class:`mopidy.models.Track`
:param at_position: position in tracklist to add track
:type at_position: int or :class:`None`
:rtype: list of :class:`mopidy.models.TlTrack`
"""
tl_tracks = []
for track in tracks:
tl_track = TlTrack(self._next_tlid, track)
self._next_tlid += 1
if at_position is not None:
self._tl_tracks.insert(at_position, tl_track)
at_position += 1
else:
self._tl_tracks.append(tl_track)
tl_tracks.append(tl_track)
if tl_tracks:
self._increase_version()
return tl_tracks
def clear(self):
"""
Clear the tracklist.
Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event.
"""
self._tl_tracks = []
self._increase_version()
def filter(self, **criteria):
"""
Filter the tracklist by the given criterias.
Examples::
filter(tlid=7) # Returns track with TLID 7 (tracklist ID)
filter(id=1) # Returns track with ID 1
filter(uri='xyz') # Returns track with URI 'xyz'
filter(id=1, uri='xyz') # Returns track with ID 1 and URI 'xyz'
:param criteria: on or more criteria to match by
:type criteria: dict
:rtype: list of :class:`mopidy.models.TlTrack`
"""
matches = self._tl_tracks
for (key, value) in criteria.iteritems():
if key == 'tlid':
matches = filter(lambda ct: ct.tlid == value, matches)
else:
matches = filter(
lambda ct: getattr(ct.track, key) == value, matches)
return matches
def index(self, tl_track):
"""
Get index of the given :class:`mopidy.models.TlTrack` in the tracklist.
Raises :exc:`ValueError` if not found.
:param tl_track: track to find the index of
:type tl_track: :class:`mopidy.models.TlTrack`
:rtype: int
"""
return self._tl_tracks.index(tl_track)
def move(self, start, end, to_position):
"""
Move the tracks in the slice ``[start:end]`` to ``to_position``.
Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event.
:param start: position of first track to move
:type start: int
:param end: position after last track to move
:type end: int
:param to_position: new position for the tracks
:type to_position: int
"""
if start == end:
end += 1
tl_tracks = self._tl_tracks
assert start < end, 'start must be smaller than end'
assert start >= 0, 'start must be at least zero'
assert end <= len(tl_tracks), \
'end can not be larger than tracklist length'
assert to_position >= 0, 'to_position must be at least zero'
assert to_position <= len(tl_tracks), \
'to_position can not be larger than tracklist length'
new_tl_tracks = tl_tracks[:start] + tl_tracks[end:]
for tl_track in tl_tracks[start:end]:
new_tl_tracks.insert(to_position, tl_track)
to_position += 1
self._tl_tracks = new_tl_tracks
self._increase_version()
def remove(self, **criteria):
"""
Remove the matching tracks from the tracklist.
Uses :meth:`filter()` to lookup the tracks to remove.
Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event.
:param criteria: on or more criteria to match by
:type criteria: dict
:rtype: list of :class:`mopidy.models.TlTrack` that was removed
"""
tl_tracks = self.filter(**criteria)
for tl_track in tl_tracks:
position = self._tl_tracks.index(tl_track)
del self._tl_tracks[position]
self._increase_version()
return tl_tracks
def shuffle(self, start=None, end=None):
"""
Shuffles the entire tracklist. If ``start`` and ``end`` is given only
shuffles the slice ``[start:end]``.
Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event.
:param start: position of first track to shuffle
:type start: int or :class:`None`
:param end: position after last track to shuffle
:type end: int or :class:`None`
"""
tl_tracks = self._tl_tracks
if start is not None and end is not None:
assert start < end, 'start must be smaller than end'
if start is not None:
assert start >= 0, 'start must be at least zero'
if end is not None:
assert end <= len(tl_tracks), 'end can not be larger than ' + \
'tracklist length'
before = tl_tracks[:start or 0]
shuffled = tl_tracks[start:end]
after = tl_tracks[end or len(tl_tracks):]
random.shuffle(shuffled)
self._tl_tracks = before + shuffled + after
self._increase_version()
def slice(self, start, end):
"""
Returns a slice of the tracklist, limited by the given start and end
positions.
:param start: position of first track to include in slice
:type start: int
:param end: position after last track to include in slice
:type end: int
:rtype: :class:`mopidy.models.TlTrack`
"""
return self._tl_tracks[start:end]
def _trigger_tracklist_changed(self):
logger.debug('Triggering event: tracklist_changed()')
listener.CoreListener.send('tracklist_changed')

24
mopidy/exceptions.py Normal file
View File

@ -0,0 +1,24 @@
from __future__ import unicode_literals
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

@ -0,0 +1 @@
from __future__ import unicode_literals

View File

@ -1,42 +1,50 @@
"""
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.
"""
from __future__ import unicode_literals
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
@ -48,21 +56,21 @@ class LastfmFrontend(ThreadingActor, BackendListener):
self.lastfm = pylast.LastFMNetwork(
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:
logger.info(u'Last.fm scrobbler not started')
logger.debug(u'Last.fm settings error: %s', e)
logger.info('Connected to Last.fm')
except exceptions.SettingsError as e:
logger.info('Last.fm scrobbler not started')
logger.debug('Last.fm settings error: %s', e)
self.stop()
except (pylast.NetworkError, pylast.MalformedResponseError,
pylast.WSError) as e:
logger.error(u'Error during Last.fm setup: %s', e)
logger.error('Error during Last.fm setup: %s', e)
self.stop()
def track_playback_started(self, track):
artists = ', '.join([a.name for a in track.artists])
duration = track.length and track.length // 1000 or 0
self.last_start_time = int(time.time())
logger.debug(u'Now playing track: %s - %s', artists, track.name)
logger.debug('Now playing track: %s - %s', artists, track.name)
try:
self.lastfm.update_now_playing(
artists,
@ -73,22 +81,22 @@ class LastfmFrontend(ThreadingActor, BackendListener):
mbid=(track.musicbrainz_id or ''))
except (pylast.ScrobblingError, pylast.NetworkError,
pylast.MalformedResponseError, pylast.WSError) as e:
logger.warning(u'Error submitting playing track to Last.fm: %s', e)
logger.warning('Error submitting playing track to Last.fm: %s', e)
def track_playback_ended(self, track, time_position):
artists = ', '.join([a.name for a in track.artists])
duration = track.length and track.length // 1000 or 0
time_position = time_position // 1000
if duration < 30:
logger.debug(u'Track too short to scrobble. (30s)')
logger.debug('Track too short to scrobble. (30s)')
return
if time_position < duration // 2 and time_position < 240:
logger.debug(
u'Track not played long enough to scrobble. (50% or 240s)')
'Track not played long enough to scrobble. (50% or 240s)')
return
if self.last_start_time is None:
self.last_start_time = int(time.time()) - duration
logger.debug(u'Scrobbling track: %s - %s', artists, track.name)
logger.debug('Scrobbling track: %s - %s', artists, track.name)
try:
self.lastfm.scrobble(
artists,
@ -100,4 +108,4 @@ class LastfmFrontend(ThreadingActor, BackendListener):
mbid=(track.musicbrainz_id or ''))
except (pylast.ScrobblingError, pylast.NetworkError,
pylast.MalformedResponseError, pylast.WSError) as e:
logger.warning(u'Error submitting played track to Last.fm: %s', e)
logger.warning('Error submitting played track to Last.fm: %s', e)

View File

@ -1,110 +1,27 @@
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`
"""
from __future__ import unicode_literals
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,53 @@
from __future__ import unicode_literals
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(
'MPD server startup failed: %s',
encoding.locale_decode(error))
sys.exit(1)
logger.info('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 tracklist_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,18 @@
from __future__ import unicode_literals
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 +22,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."""
@ -59,8 +54,8 @@ class MpdDispatcher(object):
response = []
for subsystem in subsystems:
response.append(u'changed: %s' % subsystem)
response.append(u'OK')
response.append('changed: %s' % subsystem)
response.append('OK')
self.context.subscriptions = set()
self.context.events = set()
self.context.session.send_lines(response)
@ -72,7 +67,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 +77,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 +88,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):
@ -113,30 +105,31 @@ class MpdDispatcher(object):
response = self._call_next_filter(request, response, filter_chain)
if (self._is_receiving_command_list(request) or
self._is_processing_command_list(request)):
if response and response[-1] == u'OK':
if response and response[-1] == 'OK':
response = response[:-1]
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 != '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 != '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(
'Client sent us %s, only %s is allowed while in '
'the idle state', repr(request), repr('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,18 +141,16 @@ 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):
response = self._call_next_filter(request, response, filter_chain)
if not self._has_error(response):
response.append(u'OK')
response.append('OK')
return response
def _has_error(self, response):
return response and response[-1].startswith(u'ACK')
return response and response[-1].startswith('ACK')
### Filter: call handler
@ -167,8 +158,8 @@ class MpdDispatcher(object):
try:
response = self._format_response(self._call_handler(request))
return self._call_next_filter(request, response, filter_chain)
except ActorDeadError as e:
logger.warning(u'Tried to communicate with dead actor.')
except pykka.ActorDeadError as e:
logger.warning('Tried to communicate with dead actor.')
raise exceptions.MpdSystemError(e)
def _call_handler(self, request):
@ -176,14 +167,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(
'incorrect arguments', command=command_name)
raise exceptions.MpdUnknownCommand(command=command_name)
def _format_response(self, response):
@ -196,17 +188,26 @@ 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):
return [u'%s: %s' % (key, value) for (key, value) in line.items()]
return ['%s: %s' % (key, value) for (key, value) in line.items()]
if isinstance(line, tuple):
(key, value) = line
return [u'%s: %s' % (key, value)]
return ['%s: %s' % (key, value)]
return [line]
@ -222,27 +223,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,7 @@
from mopidy import MopidyException
from __future__ import unicode_literals
from mopidy.exceptions import MopidyException
class MpdAckError(MopidyException):
"""See fields on this class for available MPD error codes"""
@ -18,7 +21,7 @@ class MpdAckError(MopidyException):
error_code = 0
def __init__(self, message=u'', index=0, command=u''):
def __init__(self, message='', index=0, command=''):
super(MpdAckError, self).__init__(message, index, command)
self.message = message
self.index = index
@ -30,39 +33,46 @@ class MpdAckError(MopidyException):
ACK [%(error_code)i@%(index)i] {%(command)s} description
"""
return u'ACK [%i@%i] {%s} %s' % (
return '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
def __init__(self, *args, **kwargs):
super(MpdPermissionError, self).__init__(*args, **kwargs)
self.message = u'you don\'t have permission for "%s"' % self.command
self.message = 'you don\'t have permission for "%s"' % self.command
class MpdUnknownCommand(MpdAckError):
error_code = MpdAckError.ACK_ERROR_UNKNOWN
def __init__(self, *args, **kwargs):
super(MpdUnknownCommand, self).__init__(*args, **kwargs)
self.message = u'unknown command "%s"' % self.command
self.command = u''
self.message = 'unknown command "%s"' % self.command
self.command = ''
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
def __init__(self, *args, **kwargs):
super(MpdNotImplemented, self).__init__(*args, **kwargs)
self.message = u'Not implemented'
self.message = 'Not implemented'

View File

@ -10,25 +10,29 @@ implement our own MPD server which is compatible with the numerous existing
`MPD clients <http://mpd.wikia.com/wiki/Clients>`_.
"""
from __future__ import unicode_literals
from collections import namedtuple
import re
#: The MPD protocol uses UTF-8 for encoding all data.
ENCODING = u'UTF-8'
ENCODING = 'UTF-8'
#: The MPD protocol uses ``\n`` as line terminator.
LINE_TERMINATOR = u'\n'
LINE_TERMINATOR = '\n'
#: The MPD protocol version is 0.16.0.
VERSION = u'0.16.0'
VERSION = '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.
@ -52,11 +56,24 @@ def handle_request(pattern, auth_required=True):
if match is not None:
mpd_commands.add(
MpdCommand(name=match.group(), auth_required=auth_required))
if pattern in request_handlers:
raise ValueError(u'Tried to redefine handler for %s with %s' % (
compiled_pattern = re.compile(pattern, flags=re.UNICODE)
if compiled_pattern in request_handlers:
raise ValueError('Tried to redefine handler for %s with %s' % (
pattern, func))
request_handlers[pattern] = func
request_handlers[compiled_pattern] = func
func.__doc__ = ' - *Pattern:* ``%s``\n\n%s' % (
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,9 @@
from __future__ import unicode_literals
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 +13,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 +25,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,9 @@
from __future__ import unicode_literals
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 +21,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 = []
@ -37,14 +42,16 @@ def command_list_end(context):
command, current_command_list_index=index)
command_list_response.extend(response)
if (command_list_response and
command_list_response[-1].startswith(u'ACK')):
command_list_response[-1].startswith('ACK')):
return command_list_response
if command_list_ok:
command_list_response.append(u'list_OK')
command_list_response.append('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,10 @@
from __future__ import unicode_literals
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 +17,7 @@ def close(context):
"""
context.session.close()
@handle_request(r'^kill$')
def kill(context):
"""
@ -23,7 +27,8 @@ def kill(context):
Kills MPD.
"""
raise MpdPermissionError(command=u'kill')
raise MpdPermissionError(command='kill')
@handle_request(r'^password "(?P<password>[^"]+)"$', auth_required=False)
def password_(context, password):
@ -38,7 +43,8 @@ def password_(context, password):
if password == settings.MPD_SERVER_PASSWORD:
context.dispatcher.authenticated = True
else:
raise MpdPasswordError(u'incorrect password', command=u'password')
raise MpdPasswordError('incorrect password', command='password')
@handle_request(r'^ping$', auth_required=False)
def ping(context):

View File

@ -1,8 +1,10 @@
from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError,
MpdNotImplemented)
from __future__ import unicode_literals
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,14 +22,12 @@ def add(context, uri):
"""
if not uri:
return
for uri_scheme in context.backend.uri_schemes.get():
if uri.startswith(uri_scheme):
track = context.backend.library.lookup(uri).get()
if track is not None:
context.backend.current_playlist.add(track)
return
raise MpdNoExistError(
u'directory or file not found', command=u'add')
tracks = context.core.library.lookup(uri).get()
if tracks:
context.core.tracklist.add(tracks)
return
raise MpdNoExistError('directory or file not found', command='add')
@handle_request(r'^addid "(?P<uri>[^"]*)"( "(?P<songpos>\d+)")*$')
def addid(context, uri, songpos=None):
@ -49,17 +49,17 @@ def addid(context, uri, songpos=None):
- ``addid ""`` should return an error.
"""
if not uri:
raise MpdNoExistError(u'No such song', command=u'addid')
raise MpdNoExistError('No such song', command='addid')
if songpos is not None:
songpos = int(songpos)
track = context.backend.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():
raise MpdArgError(u'Bad song index', command=u'addid')
cp_track = context.backend.current_playlist.add(track,
at_position=songpos).get()
return ('Id', cp_track.cpid)
tracks = context.core.library.lookup(uri).get()
if not tracks:
raise MpdNoExistError('No such song', command='addid')
if songpos and songpos > context.core.tracklist.length.get():
raise MpdArgError('Bad song index', command='addid')
tl_tracks = context.core.tracklist.add(tracks, at_position=songpos).get()
return ('Id', tl_tracks[0].tlid)
@handle_request(r'^delete "(?P<start>\d+):(?P<end>\d+)*"$')
def delete_range(context, start, end=None):
@ -74,26 +74,28 @@ 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()
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)
end = context.core.tracklist.length.get()
tl_tracks = context.core.tracklist.slice(start, end).get()
if not tl_tracks:
raise MpdArgError('Bad song index', command='delete')
for (tlid, _) in tl_tracks:
context.core.tracklist.remove(tlid=tlid)
@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(
(tlid, _) = context.core.tracklist.slice(
songpos, songpos + 1).get()[0]
context.backend.current_playlist.remove(cpid=cpid)
context.core.tracklist.remove(tlid=tlid)
except IndexError:
raise MpdArgError(u'Bad song index', command=u'delete')
raise MpdArgError('Bad song index', command='delete')
@handle_request(r'^deleteid "(?P<cpid>\d+)"$')
def deleteid(context, cpid):
@handle_request(r'^deleteid "(?P<tlid>\d+)"$')
def deleteid(context, tlid):
"""
*musicpd.org, current playlist section:*
@ -101,13 +103,14 @@ def deleteid(context, cpid):
Deletes the song ``SONGID`` from the playlist
"""
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()
except LookupError:
raise MpdNoExistError(u'No such song', command=u'deleteid')
tlid = int(tlid)
tl_track = context.core.playback.current_tl_track.get()
if tl_track and tl_track.tlid == tlid:
context.core.playback.next()
tl_tracks = context.core.tracklist.remove(tlid=tlid).get()
if not tl_tracks:
raise MpdNoExistError('No such song', command='deleteid')
@handle_request(r'^clear$')
def clear(context):
@ -118,7 +121,8 @@ def clear(context):
Clears the current playlist.
"""
context.backend.current_playlist.clear()
context.core.tracklist.clear()
@handle_request(r'^move "(?P<start>\d+):(?P<end>\d+)*" "(?P<to>\d+)"$')
def move_range(context, start, to, end=None):
@ -131,21 +135,23 @@ 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.tracklist.length.get()
start = int(start)
end = int(end)
to = int(to)
context.backend.current_playlist.move(start, end, to)
context.core.tracklist.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.tracklist.move(songpos, songpos + 1, to)
@handle_request(r'^moveid "(?P<cpid>\d+)" "(?P<to>\d+)"$')
def moveid(context, cpid, to):
@handle_request(r'^moveid "(?P<tlid>\d+)" "(?P<to>\d+)"$')
def moveid(context, tlid, to):
"""
*musicpd.org, current playlist section:*
@ -155,11 +161,14 @@ def moveid(context, cpid, to):
the playlist. If ``TO`` is negative, it is relative to the current
song in the playlist (if there is one).
"""
cpid = int(cpid)
tlid = int(tlid)
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)
tl_tracks = context.core.tracklist.filter(tlid=tlid).get()
if not tl_tracks:
raise MpdNoExistError('No such song', command='moveid')
position = context.core.tracklist.index(tl_tracks[0]).get()
context.core.tracklist.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):
@ -191,16 +201,16 @@ def playlistfind(context, tag, needle):
- does not add quotes around the tag.
"""
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)
except LookupError:
tl_tracks = context.core.tracklist.filter(uri=needle).get()
if not tl_tracks:
return None
raise MpdNotImplemented # TODO
position = context.core.tracklist.index(tl_tracks[0]).get()
return translator.track_to_mpd_format(tl_tracks[0], position=position)
raise MpdNotImplemented # TODO
@handle_request(r'^playlistid( "(?P<cpid>\d+)")*$')
def playlistid(context, cpid=None):
@handle_request(r'^playlistid( "(?P<tlid>\d+)")*$')
def playlistid(context, tlid=None):
"""
*musicpd.org, current playlist section:*
@ -209,24 +219,22 @@ def playlistid(context, cpid=None):
Displays a list of songs in the playlist. ``SONGID`` is optional
and specifies a single song to display info for.
"""
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)
except LookupError:
raise MpdNoExistError(u'No such song', command=u'playlistid')
if tlid is not None:
tlid = int(tlid)
tl_tracks = context.core.tracklist.filter(tlid=tlid).get()
if not tl_tracks:
raise MpdNoExistError('No such song', command='playlistid')
position = context.core.tracklist.index(tl_tracks[0]).get()
return translator.track_to_mpd_format(tl_tracks[0], position=position)
else:
return tracks_to_mpd_format(
context.backend.current_playlist.cp_tracks.get())
return translator.tracks_to_mpd_format(
context.core.tracklist.tl_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:*
@ -241,25 +249,28 @@ def playlistinfo(context, songpos=None,
- uses negative indexes, like ``playlistinfo "-1"``, to request
the entire playlist
"""
if songpos == '-1':
songpos = None
if songpos is not None:
songpos = int(songpos)
cp_track = context.backend.current_playlist.cp_tracks.get()[songpos]
return track_to_mpd_format(cp_track, position=songpos)
tl_track = context.core.tracklist.tl_tracks.get()[songpos]
return translator.track_to_mpd_format(tl_track, position=songpos)
else:
if start is None:
start = 0
start = int(start)
if not (0 <= start <= context.backend.current_playlist.length.get()):
raise MpdArgError(u'Bad song index', command=u'playlistinfo')
if not (0 <= start <= context.core.tracklist.length.get()):
raise MpdArgError('Bad song index', command='playlistinfo')
if end is not None:
end = int(end)
if end > context.backend.current_playlist.length.get():
if end > context.core.tracklist.length.get():
end = None
cp_tracks = context.backend.current_playlist.cp_tracks.get()
return tracks_to_mpd_format(cp_tracks, start, end)
tl_tracks = context.core.tracklist.tl_tracks.get()
return translator.tracks_to_mpd_format(tl_tracks, start, end)
@handle_request(r'^playlistsearch "(?P<tag>[^"]+)" "(?P<needle>[^"]+)"$')
@handle_request(r'^playlistsearch (?P<tag>\S+) "(?P<needle>[^"]+)"$')
@handle_request(r'^playlistsearch (?P<tag>\w+) "(?P<needle>[^"]+)"$')
def playlistsearch(context, tag, needle):
"""
*musicpd.org, current playlist section:*
@ -274,7 +285,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 +306,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.tracklist.version.get():
return translator.tracks_to_mpd_format(
context.core.tracklist.tl_tracks.get())
@handle_request(r'^plchangesposid "(?P<version>\d+)"$')
def plchangesposid(context, version):
@ -313,14 +326,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.tracklist.version.get():
result = []
for (position, (cpid, _)) in enumerate(
context.backend.current_playlist.cp_tracks.get()):
result.append((u'cpos', position))
result.append((u'Id', cpid))
for (position, (tlid, _)) in enumerate(
context.core.tracklist.tl_tracks.get()):
result.append(('cpos', position))
result.append(('Id', tlid))
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 +350,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.tracklist.shuffle(start, end)
@handle_request(r'^swap "(?P<songpos1>\d+)" "(?P<songpos2>\d+)"$')
def swap(context, songpos1, songpos2):
@ -349,18 +364,19 @@ def swap(context, songpos1, songpos2):
"""
songpos1 = int(songpos1)
songpos2 = int(songpos2)
tracks = context.backend.current_playlist.tracks.get()
tracks = context.core.tracklist.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.tracklist.clear()
context.core.tracklist.add(tracks)
@handle_request(r'^swapid "(?P<cpid1>\d+)" "(?P<cpid2>\d+)"$')
def swapid(context, cpid1, cpid2):
@handle_request(r'^swapid "(?P<tlid1>\d+)" "(?P<tlid2>\d+)"$')
def swapid(context, tlid1, tlid2):
"""
*musicpd.org, current playlist section:*
@ -368,10 +384,12 @@ def swapid(context, cpid1, cpid2):
Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids).
"""
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()
tlid1 = int(tlid1)
tlid2 = int(tlid2)
tl_tracks1 = context.core.tracklist.filter(tlid=tlid1).get()
tl_tracks2 = context.core.tracklist.filter(tlid=tlid2).get()
if not tl_tracks1 or not tl_tracks2:
raise MpdNoExistError('No such song', command='swapid')
position1 = context.core.tracklist.index(tl_tracks1[0]).get()
position2 = context.core.tracklist.index(tl_tracks2[0]).get()
swap(context, position1, position2)

View File

@ -1,5 +1,8 @@
from __future__ import unicode_literals
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

@ -1,34 +1,42 @@
from __future__ import unicode_literals
import re
import shlex
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
from mopidy.frontends.mpd.translator import tracks_to_mpd_format
def _build_query(mpd_query):
"""
Parses a MPD query string and converts it to the Mopidy query format.
"""
query_pattern = (
r'"?(?:[Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? "[^"]+"')
r'"?(?:[Aa]lbum|[Aa]rtist|[Ff]ile[name]*|[Tt]itle|[Aa]ny)"? "[^"]+"')
query_parts = re.findall(query_pattern, mpd_query)
query_part_pattern = (
r'"?(?P<field>([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny))"? '
r'"?(?P<field>([Aa]lbum|[Aa]rtist|[Ff]ile[name]*|[Tt]itle|[Aa]ny))"? '
r'"(?P<what>[^"]+)"')
query = {}
for query_part in query_parts:
m = re.match(query_part_pattern, query_part)
field = m.groupdict()['field'].lower()
if field == u'title':
field = u'track'
field = str(field) # Needed for kwargs keys on OS X and Windows
what = m.groupdict()['what'].lower()
if field == 'title':
field = 'track'
elif field in ('file', 'filename'):
field = 'uri'
field = str(field) # Needed for kwargs keys on OS X and Windows
what = m.groupdict()['what']
if not what:
raise ValueError
if field in query:
query[field].append(what)
else:
query[field] = [what]
return query
@handle_request(r'^count "(?P<tag>[^"]+)" "(?P<needle>[^"]*)"$')
def count(context, tag, needle):
"""
@ -39,11 +47,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]ile[name]*|'
r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$')
def find(context, mpd_query):
"""
*musicpd.org, music database section:*
@ -67,14 +76,20 @@ def find(context, mpd_query):
*ncmpcpp:*
- also uses the search type "date".
- uses "file" instead of "filename".
"""
query = _build_query(mpd_query)
return playlist_to_mpd_format(
context.backend.library.find_exact(**query).get())
try:
query = _build_query(mpd_query)
except ValueError:
return
return tracks_to_mpd_format(
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 +103,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:*
@ -100,9 +117,7 @@ def list_(context, field, mpd_query=None):
``artist``, ``date``, or ``genre``.
``ARTIST`` is an optional parameter when type is ``album``,
``date``, or ``genre``.
This filters the result list by an artist.
``date``, or ``genre``. This filters the result list by an artist.
*Clarifications:*
@ -175,15 +190,19 @@ def list_(context, field, mpd_query=None):
- capitalizes the field argument.
"""
field = field.lower()
query = _list_build_query(field, mpd_query)
if field == u'artist':
try:
query = _list_build_query(field, mpd_query)
except ValueError:
return
if field == 'artist':
return _list_artist(context, query)
elif field == u'album':
elif field == 'album':
return _list_album(context, query)
elif field == u'date':
elif field == 'date':
return _list_date(context, query)
elif field == u'genre':
pass # TODO We don't have genre in our internal data structures yet
elif field == 'genre':
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."""
@ -194,57 +213,66 @@ def _list_build_query(field, mpd_query):
tokens = shlex.split(mpd_query.encode('utf-8'))
except ValueError as error:
if str(error) == 'No closing quotation':
raise MpdArgError(u'Invalid unquoted character', command=u'list')
raise MpdArgError('Invalid unquoted character', command='list')
else:
raise
tokens = [t.decode('utf-8') for t in tokens]
if len(tokens) == 1:
if field == u'album':
if field == 'album':
if not tokens[0]:
raise ValueError
return {'artist': [tokens[0]]}
else:
raise MpdArgError(
u'should be "Album" for 3 arguments', command=u'list')
'should be "Album" for 3 arguments', command='list')
elif len(tokens) % 2 == 0:
query = {}
while tokens:
key = tokens[0].lower()
key = str(key) # Needed for kwargs keys on OS X and Windows
key = str(key) # Needed for kwargs keys on OS X and Windows
value = tokens[1]
tokens = tokens[2:]
if key not in (u'artist', u'album', u'date', u'genre'):
raise MpdArgError(u'not able to parse args', command=u'list')
if key not in ('artist', 'album', 'date', 'genre'):
raise MpdArgError('not able to parse args', command='list')
if not value:
raise ValueError
if key in query:
query[key].append(value)
else:
query[key] = [value]
return query
else:
raise MpdArgError(u'not able to parse args', command=u'list')
raise MpdArgError('not able to parse args', command='list')
def _list_artist(context, query):
artists = set()
playlist = context.backend.library.find_exact(**query).get()
for track in playlist.tracks:
tracks = context.core.library.find_exact(**query).get()
for track in tracks:
for artist in track.artists:
artists.add((u'Artist', artist.name))
if artist.name:
artists.add(('Artist', artist.name))
return artists
def _list_album(context, query):
albums = set()
playlist = context.backend.library.find_exact(**query).get()
for track in playlist.tracks:
if track.album is not None:
albums.add((u'Album', track.album.name))
tracks = context.core.library.find_exact(**query).get()
for track in tracks:
if track.album and track.album.name:
albums.add(('Album', track.album.name))
return albums
def _list_date(context, query):
dates = set()
playlist = context.backend.library.find_exact(**query).get()
for track in playlist.tracks:
if track.date is not None:
dates.add((u'Date', track.date))
tracks = context.core.library.find_exact(**query).get()
for track in tracks:
if track.date:
dates.add(('Date', track.date))
return dates
@handle_request(r'^listall "(?P<uri>[^"]+)"')
def listall(context, uri):
"""
@ -254,7 +282,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 +295,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>[^"]*)"$')
@ -286,9 +316,10 @@ def lsinfo(context, uri=None):
directories located at the root level, for both ``lsinfo``, ``lsinfo
""``, and ``lsinfo "/"``.
"""
if uri is None or uri == u'/' or uri == u'':
if uri is None or uri == '/' or uri == '':
return stored_playlists.listplaylists(context)
raise MpdNotImplemented # TODO
raise MpdNotImplemented # TODO
@handle_request(r'^rescan( "(?P<uri>[^"]+)")*$')
def rescan(context, uri=None):
@ -301,9 +332,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]ile[name]*|'
r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$')
def search(context, mpd_query):
"""
*musicpd.org, music database section:*
@ -330,10 +362,15 @@ def search(context, mpd_query):
*ncmpcpp:*
- also uses the search type "date".
- uses "file" instead of "filename".
"""
query = _build_query(mpd_query)
return playlist_to_mpd_format(
context.backend.library.search(**query).get())
try:
query = _build_query(mpd_query)
except ValueError:
return
return tracks_to_mpd_format(
context.core.library.search(**query).get())
@handle_request(r'^update( "(?P<uri>[^"]+)")*$')
def update(context, uri=None, rescan_unmodified_files=False):
@ -352,4 +389,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,10 @@
from __future__ import unicode_literals
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 +19,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 +34,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 +92,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 +110,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,11 +126,12 @@ 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>-?\d+)"$')
def playid(context, cpid):
@handle_request(r'^playid (?P<tlid>-?\d+)$')
@handle_request(r'^playid "(?P<tlid>-?\d+)"$')
def playid(context, tlid):
"""
*musicpd.org, playback section:*
@ -140,14 +148,14 @@ def playid(context, cpid):
- ``playid "-1"`` when stopped without a current track, e.g. after playlist
replacement, starts playback at the first track.
"""
cpid = int(cpid)
if cpid == -1:
tlid = int(tlid)
if tlid == -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()
except LookupError:
raise MpdNoExistError(u'No such song', command=u'playid')
tl_tracks = context.core.tracklist.filter(tlid=tlid).get()
if not tl_tracks:
raise MpdNoExistError('No such song', command='playid')
return context.core.playback.play(tl_tracks[0]).get()
@handle_request(r'^play (?P<songpos>-?\d+)$')
@handle_request(r'^play "(?P<songpos>-?\d+)"$')
@ -176,25 +184,26 @@ def playpos(context, songpos):
if songpos == -1:
return _play_minus_one(context)
try:
cp_track = context.backend.current_playlist.slice(
songpos, songpos + 1).get()[0]
return context.backend.playback.play(cp_track).get()
tl_track = context.core.tracklist.slice(songpos, songpos + 1).get()[0]
return context.core.playback.play(tl_track).get()
except IndexError:
raise MpdArgError(u'Bad song index', command=u'play')
raise MpdArgError('Bad song index', command='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_tl_track.get() is not None:
tl_track = context.core.playback.current_tl_track.get()
return context.core.playback.play(tl_track).get()
elif context.core.tracklist.slice(0, 1).get():
tl_track = context.core.tracklist.slice(0, 1).get()[0]
return context.core.playback.play(tl_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 'off' # TODO
@handle_request(r'^seek (?P<songpos>\d+) (?P<seconds>\d+)$')
@handle_request(r'^seek "(?P<songpos>\d+)" "(?P<seconds>\d+)"$')
@ -315,12 +329,13 @@ 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.tracklist_position.get() != songpos:
playpos(context, songpos)
context.backend.playback.seek(int(seconds) * 1000)
context.core.playback.seek(int(seconds) * 1000).get()
@handle_request(r'^seekid "(?P<cpid>\d+)" "(?P<seconds>\d+)"$')
def seekid(context, cpid, seconds):
@handle_request(r'^seekid "(?P<tlid>\d+)" "(?P<seconds>\d+)"$')
def seekid(context, tlid, seconds):
"""
*musicpd.org, playback section:*
@ -328,9 +343,11 @@ def seekid(context, cpid, seconds):
Seeks to the position ``TIME`` (in seconds) of song ``SONGID``.
"""
if context.backend.playback.current_cpid != cpid:
playid(context, cpid)
context.backend.playback.seek(int(seconds) * 1000)
tl_track = context.core.playback.current_tl_track.get()
if not tl_track or tl_track.tlid != tlid:
playid(context, tlid)
context.core.playback.seek(int(seconds) * 1000).get()
@handle_request(r'^setvol (?P<volume>[-+]*\d+)$')
@handle_request(r'^setvol "(?P<volume>[-+]*\d+)"$')
@ -351,7 +368,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 +384,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 +398,4 @@ def stop(context):
Stops playing.
"""
context.backend.playback.stop()
context.core.playback.stop()

View File

@ -1,5 +1,7 @@
from __future__ import unicode_literals
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 +15,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):
@ -40,8 +46,16 @@ def decoders(context):
mime_type: audio/mpeg
plugin: mpcdec
suffix: mpc
*Clarifications:*
- ncmpcpp asks for decoders the first time you open the browse view. By
returning nothing and OK instead of an not implemented error, we avoid
"Not implemented" showing up in the ncmpcpp interface, and we get the
list of playlists without having to enter the browse interface twice.
"""
raise MpdNotImplemented # TODO
return # TODO
@handle_request(r'^notcommands$', auth_required=False)
def notcommands(context):
@ -55,13 +69,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 +88,8 @@ def tagtypes(context):
Shows a list of available song metadata.
"""
pass # TODO
pass # TODO
@handle_request(r'^urlhandlers$')
def urlhandlers(context):
@ -83,5 +100,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 [
('handler', uri_scheme)
for uri_scheme in context.core.uri_schemes.get()]

View File

@ -1,4 +1,6 @@
import pykka.future
from __future__ import unicode_literals
import pykka
from mopidy.core import PlaybackState
from mopidy.frontends.mpd.exceptions import MpdNotImplemented
@ -6,8 +8,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 +23,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,10 +36,11 @@ 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()
if current_cp_track is not None:
position = context.backend.playback.current_playlist_position.get()
return track_to_mpd_format(current_cp_track, position=position)
current_tl_track = context.core.playback.current_tl_track.get()
if current_tl_track is not None:
position = context.core.playback.tracklist_position.get()
return track_to_mpd_format(current_tl_track, position=position)
@handle_request(r'^idle$')
@handle_request(r'^idle (?P<subsystems>.+)$')
@ -90,9 +96,10 @@ def idle(context, subsystems=None):
context.subscriptions = set()
for subsystem in active:
response.append(u'changed: %s' % subsystem)
response.append('changed: %s' % subsystem)
return response
@handle_request(r'^noidle$')
def noidle(context):
"""See :meth:`_status_idle`."""
@ -102,6 +109,7 @@ def noidle(context):
context.events = set()
context.session.prevent_timeout = False
@handle_request(r'^stats$')
def stats(context):
"""
@ -119,15 +127,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 +162,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 +175,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,
'tracklist.length': context.core.tracklist.length,
'tracklist.version': context.core.tracklist.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_tl_track': context.core.playback.current_tl_track,
'playback.tracklist_position': (
context.core.playback.tracklist_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)),
@ -191,20 +200,22 @@ def status(context):
('xfade', _status_xfade(futures)),
('state', _status_state(futures)),
]
if futures['playback.current_cp_track'].get() is not None:
if futures['playback.current_tl_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
current_tl_track = futures['playback.current_tl_track'].get()
if current_tl_track is not None:
return current_tl_track.track.bitrate
def _status_consume(futures):
if futures['playback.consume'].get():
@ -212,55 +223,68 @@ def _status_consume(futures):
else:
return 0
def _status_playlist_length(futures):
return futures['current_playlist.length'].get()
return futures['tracklist.length'].get()
def _status_playlist_version(futures):
return futures['current_playlist.version'].get()
return futures['tracklist.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:
return current_cp_track.cpid
current_tl_track = futures['playback.current_tl_track'].get()
if current_tl_track is not None:
return current_tl_track.tlid
else:
return _status_songpos(futures)
def _status_songpos(futures):
return futures['playback.current_playlist_position'].get()
return futures['playback.tracklist_position'].get()
def _status_state(futures):
state = futures['playback.state'].get()
if state == PlaybackState.PLAYING:
return u'play'
return 'play'
elif state == PlaybackState.STOPPED:
return u'stop'
return 'stop'
elif state == PlaybackState.PAUSED:
return u'pause'
return 'pause'
def _status_time(futures):
return u'%d:%d' % (futures['playback.time_position'].get() // 1000,
return '%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)
return '%.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:
current_tl_track = futures['playback.current_tl_track'].get()
if current_tl_track is None:
return 0
elif current_cp_track.track.length is None:
elif current_tl_track.track.length is None:
return 0
else:
return current_cp_track.track.length
return current_tl_track.track.length
def _status_volume(futures):
volume = futures['playback.volume'].get()
@ -269,5 +293,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,11 @@
from __future__ import unicode_literals
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 +16,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 +32,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 +46,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 +58,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 +73,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

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