Release v0.9.0
11
.travis.yml
@ -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
|
||||
|
||||
17
README.rst
@ -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>`_
|
||||
|
||||
@ -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
|
After Width: | Height: | Size: 175 KiB |
BIN
docs/_static/mpd-client-mpad.jpg
vendored
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
docs/_static/mpd-client-mpdroid.jpg
vendored
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
docs/_static/mpd-client-mpod.jpg
vendored
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
docs/_static/mpd-client-ncmpcpp.png
vendored
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
docs/_static/mpd-client-sonata.png
vendored
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
docs/_static/raspberry-pi-by-jwrodgers.jpg
vendored
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
docs/_static/ubuntu-sound-menu.png
vendored
Normal file
|
After Width: | Height: | Size: 88 KiB |
@ -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:
|
||||
|
||||
@ -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
|
||||
=======================
|
||||
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
========================
|
||||
|
||||
@ -11,4 +11,3 @@ API reference
|
||||
core
|
||||
audio
|
||||
frontends
|
||||
listeners
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
************
|
||||
Listener API
|
||||
************
|
||||
|
||||
.. automodule:: mopidy.listeners
|
||||
:synopsis: Listener API
|
||||
:members:
|
||||
273
docs/changes.rst
@ -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**
|
||||
|
||||
|
||||
@ -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
@ -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
@ -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>`_.
|
||||
48
docs/conf.py
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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`
|
||||
|
||||
|
||||
@ -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'
|
||||
@ -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>`.
|
||||
|
||||
@ -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
|
||||
264
docs/installation/raspberrypi.rst
Normal 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.
|
||||
6
docs/modules/audio/mixers/auto.rst
Normal 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
|
||||
6
docs/modules/audio/mixers/fake.rst
Normal file
@ -0,0 +1,6 @@
|
||||
*********************************************
|
||||
:mod:`mopidy.audio.mixers.fake` -- Fake mixer
|
||||
*********************************************
|
||||
|
||||
.. automodule:: mopidy.audio.mixers.fake
|
||||
:synopsis: Fake mixer for use in tests
|
||||
6
docs/modules/audio/mixers/nad.rst
Normal 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
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -4,4 +4,3 @@
|
||||
|
||||
.. automodule:: mopidy.frontends.lastfm
|
||||
:synopsis: Last.fm scrobbler frontend
|
||||
:members:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
.. _mpris-frontend:
|
||||
|
||||
***********************************************
|
||||
:mod:`mopidy.frontends.mpris` -- MPRIS frontend
|
||||
***********************************************
|
||||
|
||||
.. automodule:: mopidy.frontends.mpris
|
||||
:synopsis: MPRIS frontend
|
||||
:members:
|
||||
|
||||
@ -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
|
||||
==================
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
@ -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
@ -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
@ -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
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
37
mopidy/audio/mixers/utils.py
Normal 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()
|
||||
@ -0,0 +1 @@
|
||||
from __future__ import unicode_literals
|
||||
233
mopidy/backends/base.py
Normal 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
|
||||
@ -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 = []
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
@ -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
|
||||
@ -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)
|
||||
32
mopidy/backends/listener.py
Normal 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
|
||||
@ -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
|
||||
|
||||
23
mopidy/backends/local/actor.py
Normal 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']
|
||||
112
mopidy/backends/local/library.py
Normal 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')
|
||||
119
mopidy/backends/local/playlists.py
Normal 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))
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
49
mopidy/backends/spotify/actor.py
Normal 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()
|
||||
@ -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.
|
||||
|
||||
@ -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 []
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
|
||||
22
mopidy/backends/spotify/playlists.py
Normal 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
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
@ -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()])
|
||||
|
||||
@ -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
@ -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
|
||||
@ -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')
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
@ -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
@ -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
|
||||
@ -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
@ -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
@ -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
|
||||
@ -0,0 +1 @@
|
||||
from __future__ import unicode_literals
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
53
mopidy/frontends/mpd/actor.py
Normal 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')
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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 = []
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||