Release v0.8.0
This commit is contained in:
commit
587616ebf7
4
.mailmap
Normal file
4
.mailmap
Normal file
@ -0,0 +1,4 @@
|
||||
Kristian Klette <klette@samfundet.no>
|
||||
Johannes Knutsen <johannes@knutseninfo.no> <johannes@iterate.no>
|
||||
Johannes Knutsen <johannes@knutseninfo.no> <johannes@barbarmaclin.(none)>
|
||||
John Bäckstrand <sopues@gmail.com> <sandos@XBMCLive.(none)>
|
||||
12
.travis.yml
Normal file
12
.travis.yml
Normal file
@ -0,0 +1,12 @@
|
||||
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 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
|
||||
@ -2,6 +2,8 @@
|
||||
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
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
#! /usr/bin/env python
|
||||
|
||||
if __name__ == '__main__':
|
||||
from mopidy.core import main
|
||||
from mopidy.__main__ import main
|
||||
main()
|
||||
|
||||
19
docs/api/audio.rst
Normal file
19
docs/api/audio.rst
Normal file
@ -0,0 +1,19 @@
|
||||
.. _audio-api:
|
||||
|
||||
*********
|
||||
Audio API
|
||||
*********
|
||||
|
||||
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:
|
||||
|
||||
|
||||
.. autoclass:: mopidy.audio.Audio
|
||||
:members:
|
||||
@ -1,12 +1,12 @@
|
||||
.. _backend-provider-api:
|
||||
.. _backend-api:
|
||||
|
||||
********************
|
||||
Backend provider API
|
||||
********************
|
||||
***********
|
||||
Backend API
|
||||
***********
|
||||
|
||||
The backend provider 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:`backend-controller-api`.
|
||||
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`.
|
||||
|
||||
|
||||
Playback provider
|
||||
@ -30,8 +30,8 @@ Library provider
|
||||
:members:
|
||||
|
||||
|
||||
Backend provider implementations
|
||||
================================
|
||||
Backend implementations
|
||||
=======================
|
||||
|
||||
* :mod:`mopidy.backends.dummy`
|
||||
* :mod:`mopidy.backends.spotify`
|
||||
@ -1,60 +0,0 @@
|
||||
.. _backend-controller-api:
|
||||
|
||||
**********************
|
||||
Backend controller API
|
||||
**********************
|
||||
|
||||
|
||||
The backend controller API is the interface that is used by frontends like
|
||||
:mod:`mopidy.frontends.mpd`. If you want to implement your own backend, see the
|
||||
:ref:`backend-provider-api`.
|
||||
|
||||
|
||||
The backend
|
||||
===========
|
||||
|
||||
.. autoclass:: mopidy.backends.base.Backend
|
||||
:members:
|
||||
|
||||
|
||||
Playback controller
|
||||
===================
|
||||
|
||||
Manages playback, with actions like play, pause, stop, next, previous, and
|
||||
seek.
|
||||
|
||||
.. autoclass:: mopidy.backends.base.PlaybackController
|
||||
:members:
|
||||
|
||||
|
||||
Mixer controller
|
||||
================
|
||||
|
||||
Manages volume. See :class:`mopidy.mixers.base.BaseMixer`.
|
||||
|
||||
|
||||
Current playlist controller
|
||||
===========================
|
||||
|
||||
Manages everything related to the currently loaded playlist.
|
||||
|
||||
.. autoclass:: mopidy.backends.base.CurrentPlaylistController
|
||||
:members:
|
||||
|
||||
|
||||
Stored playlists controller
|
||||
===========================
|
||||
|
||||
Manages stored playlist.
|
||||
|
||||
.. autoclass:: mopidy.backends.base.StoredPlaylistsController
|
||||
:members:
|
||||
|
||||
|
||||
Library controller
|
||||
==================
|
||||
|
||||
Manages the music library, e.g. searching for tracks to be added to a playlist.
|
||||
|
||||
.. autoclass:: mopidy.backends.base.LibraryController
|
||||
:members:
|
||||
@ -1,4 +1,4 @@
|
||||
.. _backend-concepts:
|
||||
.. _concepts:
|
||||
|
||||
**********************************************
|
||||
The backend, controller, and provider concepts
|
||||
@ -12,11 +12,11 @@ Controllers:
|
||||
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:`backend-controller-api` for more details.
|
||||
: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-provider-api` for more details.
|
||||
providers. See :ref:`backend-api` for more details.
|
||||
|
||||
.. digraph:: backend_relations
|
||||
|
||||
@ -27,4 +27,3 @@ Providers:
|
||||
"Playback\ncontroller" -> "Playback\nproviders"
|
||||
Backend -> "Stored\nplaylists\ncontroller"
|
||||
"Stored\nplaylists\ncontroller" -> "Stored\nplaylist\nproviders"
|
||||
Backend -> Mixer
|
||||
50
docs/api/core.rst
Normal file
50
docs/api/core.rst
Normal file
@ -0,0 +1,50 @@
|
||||
.. _core-api:
|
||||
|
||||
********
|
||||
Core API
|
||||
********
|
||||
|
||||
|
||||
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
|
||||
backends.
|
||||
|
||||
|
||||
Playback controller
|
||||
===================
|
||||
|
||||
Manages playback, with actions like play, pause, stop, next, previous,
|
||||
seek, and volume control.
|
||||
|
||||
.. autoclass:: mopidy.core.PlaybackState
|
||||
:members:
|
||||
|
||||
.. autoclass:: mopidy.core.PlaybackController
|
||||
:members:
|
||||
|
||||
|
||||
Current playlist controller
|
||||
===========================
|
||||
|
||||
Manages everything related to the currently loaded playlist.
|
||||
|
||||
.. autoclass:: mopidy.core.CurrentPlaylistController
|
||||
:members:
|
||||
|
||||
|
||||
Stored playlists controller
|
||||
===========================
|
||||
|
||||
Manages stored playlist.
|
||||
|
||||
.. autoclass:: mopidy.core.StoredPlaylistsController
|
||||
:members:
|
||||
|
||||
|
||||
Library controller
|
||||
==================
|
||||
|
||||
Manages the music library, e.g. searching for tracks to be added to a playlist.
|
||||
|
||||
.. autoclass:: mopidy.core.LibraryController
|
||||
:members:
|
||||
@ -5,7 +5,10 @@ API reference
|
||||
.. toctree::
|
||||
:glob:
|
||||
|
||||
backends/concepts
|
||||
backends/controllers
|
||||
backends/providers
|
||||
*
|
||||
concepts
|
||||
models
|
||||
backends
|
||||
core
|
||||
audio
|
||||
frontends
|
||||
listeners
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
*********
|
||||
Mixer API
|
||||
*********
|
||||
|
||||
Mixers are responsible for controlling volume. Clients of the mixers will
|
||||
simply instantiate a mixer and read/write to the ``volume`` attribute::
|
||||
|
||||
>>> from mopidy.mixers.alsa import AlsaMixer
|
||||
>>> mixer = AlsaMixer()
|
||||
>>> mixer.volume
|
||||
100
|
||||
>>> mixer.volume = 80
|
||||
>>> mixer.volume
|
||||
80
|
||||
|
||||
Most users will use one of the internal mixers which controls the volume on the
|
||||
computer running Mopidy. If you do not specify which mixer you want to use in
|
||||
the settings, Mopidy will choose one for you based upon what OS you run. See
|
||||
:attr:`mopidy.settings.MIXER` for the defaults.
|
||||
|
||||
Mopidy also supports controlling volume on other hardware devices instead of on
|
||||
the computer running Mopidy through the use of custom mixer implementations. To
|
||||
enable one of the hardware device mixers, you must the set
|
||||
:attr:`mopidy.settings.MIXER` setting to point to one of the classes found
|
||||
below, and possibly add some extra settings required by the mixer you choose.
|
||||
|
||||
All mixers should subclass :class:`mopidy.mixers.base.BaseMixer` and override
|
||||
methods as described below.
|
||||
|
||||
.. automodule:: mopidy.mixers.base
|
||||
:synopsis: Mixer API
|
||||
:members:
|
||||
|
||||
|
||||
Mixer implementations
|
||||
=====================
|
||||
|
||||
* :mod:`mopidy.mixers.alsa`
|
||||
* :mod:`mopidy.mixers.denon`
|
||||
* :mod:`mopidy.mixers.dummy`
|
||||
* :mod:`mopidy.mixers.gstreamer_software`
|
||||
* :mod:`mopidy.mixers.osa`
|
||||
* :mod:`mopidy.mixers.nad`
|
||||
@ -1,18 +0,0 @@
|
||||
.. _output-api:
|
||||
|
||||
**********
|
||||
Output API
|
||||
**********
|
||||
|
||||
Outputs are used by :mod:`mopidy.gstreamer` to output audio in some way.
|
||||
|
||||
.. autoclass:: mopidy.outputs.BaseOutput
|
||||
:members:
|
||||
|
||||
|
||||
Output implementations
|
||||
======================
|
||||
|
||||
* :class:`mopidy.outputs.custom.CustomOutput`
|
||||
* :class:`mopidy.outputs.local.LocalOutput`
|
||||
* :class:`mopidy.outputs.shoutcast.ShoutcastOutput`
|
||||
@ -9,6 +9,9 @@ Contributors to Mopidy in the order of appearance:
|
||||
- Thomas Adamcik <adamcik@samfundet.no>
|
||||
- Kristian Klette <klette@klette.us>
|
||||
|
||||
A complete list of persons with commits accepted into the Mopidy repo can be
|
||||
found at `GitHub <https://github.com/mopidy/mopidy/graphs/contributors>`_.
|
||||
|
||||
|
||||
Showing your appreciation
|
||||
=========================
|
||||
@ -17,13 +20,3 @@ If you already enjoy Mopidy, or don't enjoy it and want to help us making
|
||||
Mopidy better, the best way to do so is to contribute back to the community.
|
||||
You can contribute code, documentation, tests, bug reports, or help other
|
||||
users, spreading the word, etc.
|
||||
|
||||
If you want to show your appreciation in a less time consuming way, you can
|
||||
`flattr us <https://flattr.com/thing/82288/Mopidy>`_, or `donate money
|
||||
<http://pledgie.com/campaigns/12647>`_ to Mopidy's development.
|
||||
|
||||
We promise that any money donated -- to Pledgie, not Flattr, due to the size of
|
||||
the amounts -- will be used to cover costs related to Mopidy development, like
|
||||
service subscriptions (Spotify, Last.fm, etc.) and hardware devices like an
|
||||
used iPod Touch for testing Mopidy with MPod.
|
||||
|
||||
|
||||
117
docs/changes.rst
117
docs/changes.rst
@ -4,6 +4,109 @@ Changes
|
||||
|
||||
This change log is used to track all major changes to Mopidy.
|
||||
|
||||
|
||||
v0.8.0 (2012-09-20)
|
||||
===================
|
||||
|
||||
This release does not include any major new features. We've done a major
|
||||
cleanup of how audio outputs and audio mixers work, and on the way we've
|
||||
resolved a bunch of related issues.
|
||||
|
||||
**Audio output and mixer changes**
|
||||
|
||||
- Removed multiple outputs support. Having this feature currently seems to be
|
||||
more trouble than what it is worth. The :attr:`mopidy.settings.OUTPUTS`
|
||||
setting is no longer supported, and has been replaced with
|
||||
:attr:`mopidy.settings.OUTPUT` which is a GStreamer bin description string in
|
||||
the same format as ``gst-launch`` expects. Default value is
|
||||
``autoaudiosink``. (Fixes: :issue:`81`, :issue:`115`, :issue:`121`,
|
||||
:issue:`159`)
|
||||
|
||||
- Switch to pure GStreamer based mixing. This implies that users setup a
|
||||
GStreamer bin with a mixer in it in :attr:`mopidy.settings.MIXER`. The
|
||||
default value is ``autoaudiomixer``, a custom mixer that attempts to find a
|
||||
mixer that will work on your system. If this picks the wrong mixer you can of
|
||||
course override it. Setting the mixer to :class:`None` is also supported. MPD
|
||||
protocol support for volume has also been updated to return -1 when we have
|
||||
no mixer set. ``software`` can be used to force software mixing.
|
||||
|
||||
- Removed the Denon hardware mixer, as it is not maintained.
|
||||
|
||||
- Updated the NAD hardware mixer to work in the new GStreamer based mixing
|
||||
regime. Settings are now passed as GStreamer element properties. In practice
|
||||
that means that the following old-style config::
|
||||
|
||||
MIXER = u'mopidy.mixers.nad.NadMixer'
|
||||
MIXER_EXT_PORT = u'/dev/ttyUSB0'
|
||||
MIXER_EXT_SOURCE = u'Aux'
|
||||
MIXER_EXT_SPEAKERS_A = u'On'
|
||||
MIXER_EXT_SPEAKERS_B = u'Off'
|
||||
|
||||
Now is reduced to simply::
|
||||
|
||||
MIXER = u'nadmixer port=/dev/ttyUSB0 source=aux speakers-a=on speakers-b=off'
|
||||
|
||||
The ``port`` property defaults to ``/dev/ttyUSB0``, and the rest of the
|
||||
properties may be left out if you don't want the mixer to adjust the settings
|
||||
on your NAD amplifier when Mopidy is started.
|
||||
|
||||
**Changes**
|
||||
|
||||
- When unknown settings are encountered, we now check if it's similar to a
|
||||
known setting, and suggests to the user what we think the setting should have
|
||||
been.
|
||||
|
||||
- Added :option:`--list-deps` option to the ``mopidy`` command that lists
|
||||
required and optional dependencies, their current versions, and some other
|
||||
information useful for debugging. (Fixes: :issue:`74`)
|
||||
|
||||
- Added ``tools/debug-proxy.py`` to tee client requests to two backends and
|
||||
diff responses. Intended as a developer tool for checking for MPD protocol
|
||||
changes and various client support. Requires gevent, which currently is not a
|
||||
dependency of Mopidy.
|
||||
|
||||
- Support tracks with only release year, and not a full release date, like e.g.
|
||||
Spotify tracks.
|
||||
|
||||
- Default value of ``LOCAL_MUSIC_PATH`` has been updated to be
|
||||
``$XDG_MUSIC_DIR``, which on most systems this is set to ``$HOME``. Users of
|
||||
local backend that relied on the old default ``~/music`` need to update their
|
||||
settings. Note that the code responsible for finding this music now also
|
||||
ignores UNIX hidden files and folders.
|
||||
|
||||
- File and path settings now support ``$XDG_CACHE_DIR``, ``$XDG_DATA_DIR`` and
|
||||
``$XDG_MUSIC_DIR`` substitution. Defaults for such settings have been updated
|
||||
to use this instead of hidden away defaults.
|
||||
|
||||
- Playback is now done using ``playbin2`` from GStreamer instead of rolling our
|
||||
own. This is the first step towards resolving :issue:`171`.
|
||||
|
||||
**Bug fixes**
|
||||
|
||||
- :issue:`72`: Created a Spotify track proxy that will switch to using loaded
|
||||
data as soon as it becomes available.
|
||||
|
||||
- :issue:`150`: Fix bug which caused some clients to block Mopidy completely.
|
||||
The bug was caused by some clients sending ``close`` and then shutting down
|
||||
the connection right away. This trigged a situation in which the connection
|
||||
cleanup code would wait for an response that would never come inside the
|
||||
event loop, blocking everything else.
|
||||
|
||||
- :issue:`162`: Fixed bug when the MPD command ``playlistinfo`` is used with a
|
||||
track position. Track position and CPID was intermixed, so it would cause a
|
||||
crash if a CPID matching the track position didn't exist.
|
||||
|
||||
- Fixed crash on lookup of unknown path when using local backend.
|
||||
|
||||
- :issue:`189`: ``LOCAL_MUSIC_PATH`` and path handling in rest of settings has
|
||||
been updated so all of the code now uses the correct value.
|
||||
|
||||
- Fixed incorrect track URIs generated by M3U playlist parsing code. Generated
|
||||
tracks are now relative to ``LOCAL_MUSIC_PATH``.
|
||||
|
||||
- :issue:`203`: Re-add support for software mixing.
|
||||
|
||||
|
||||
v0.7.3 (2012-08-11)
|
||||
===================
|
||||
|
||||
@ -543,9 +646,9 @@ to this problem.
|
||||
:class:`mopidy.models.Album`, and :class:`mopidy.models.Track`.
|
||||
|
||||
- Prepare for multi-backend support (see :issue:`40`) by introducing the
|
||||
:ref:`provider concept <backend-concepts>`. Split the backend API into a
|
||||
:ref:`backend controller API <backend-controller-api>` (for frontend use)
|
||||
and a :ref:`backend provider API <backend-provider-api>` (for backend
|
||||
:ref:`provider concept <concepts>`. Split the backend API into a
|
||||
:ref:`backend controller API <core-api>` (for frontend use)
|
||||
and a :ref:`backend provider API <backend-api>` (for backend
|
||||
implementation use), which includes the following changes:
|
||||
|
||||
- Rename ``BaseBackend`` to :class:`mopidy.backends.base.Backend`.
|
||||
@ -787,8 +890,8 @@ In the last two months, Mopidy's MPD frontend has gotten lots of stability
|
||||
fixes and error handling improvements, proper support for having the same track
|
||||
multiple times in a playlist, and support for IPv6. We have also fixed the
|
||||
choppy playback on the libspotify backend. For the road ahead of us, we got an
|
||||
updated :doc:`release roadmap <development/roadmap>` with our goals for the 0.1
|
||||
to 0.3 releases.
|
||||
updated :doc:`release roadmap <development>` with our goals for the 0.1 to 0.3
|
||||
releases.
|
||||
|
||||
Enjoy the best alpha relase of Mopidy ever :-)
|
||||
|
||||
@ -881,7 +984,7 @@ Since the previous release Mopidy has seen about 300 commits, more than 200 new
|
||||
tests, a libspotify release, and major feature additions to Spotify. The new
|
||||
releases from Spotify have lead to updates to our dependencies, and also to new
|
||||
bugs in Mopidy. Thus, this is primarily a bugfix release, even though the not
|
||||
yet finished work on a Gstreamer backend have been merged.
|
||||
yet finished work on a GStreamer backend have been merged.
|
||||
|
||||
All users are recommended to upgrade to 0.1.0a1, and should at the same time
|
||||
ensure that they have the latest versions of our dependencies: Despotify r508
|
||||
@ -906,7 +1009,7 @@ As always, report problems at our IRC channel or our issue tracker. Thanks!
|
||||
- Several new generic features, like shuffle, consume, and playlist repeat.
|
||||
(Fixes: :issue:`3`)
|
||||
- **[Work in Progress]** A new backend for playing music from a local music
|
||||
archive using the Gstreamer library.
|
||||
archive using the GStreamer library.
|
||||
|
||||
- Made :class:`mopidy.mixers.alsa.AlsaMixer` work on machines without a mixer
|
||||
named "Master".
|
||||
|
||||
@ -30,33 +30,13 @@ ncmpcpp
|
||||
A console client that generally works well with Mopidy, and is regularly used
|
||||
by Mopidy developers.
|
||||
|
||||
Search
|
||||
^^^^^^
|
||||
|
||||
Search only works for ncmpcpp versions 0.5.1 and higher, and in two of the
|
||||
three search modes:
|
||||
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.
|
||||
|
||||
If you run Ubuntu 10.04 or older, you can fetch an updated version of ncmpcpp
|
||||
from `Launchpad <https://launchpad.net/ubuntu/+source/ncmpcpp>`_.
|
||||
|
||||
Communication mode
|
||||
^^^^^^^^^^^^^^^^^^
|
||||
|
||||
In newer versions of ncmpcpp, like ncmpcpp 0.5.5 shipped with Ubuntu 11.04,
|
||||
ncmcpp defaults to "notifications" mode for MPD communications, which Mopidy
|
||||
did not support before Mopidy 0.6. To workaround this limitation in earlier
|
||||
versions of Mopidy, edit the ncmpcpp configuration file at
|
||||
``~/.ncmpcpp/config`` and add the following setting::
|
||||
|
||||
mpd_communication_mode = "polling"
|
||||
|
||||
If you use Mopidy 0.6 or newer, you don't need to change anything.
|
||||
|
||||
|
||||
Graphical clients
|
||||
=================
|
||||
@ -102,8 +82,8 @@ It generally works well with Mopidy.
|
||||
Android clients
|
||||
===============
|
||||
|
||||
We've tested all six MPD clients we could find for Android with Mopidy 0.3 on a
|
||||
HTC Hero with Android 2.1, using the following test procedure:
|
||||
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:
|
||||
|
||||
#. Connect to Mopidy
|
||||
#. Search for ``foo``, with search type "any" if it can be selected
|
||||
@ -127,157 +107,180 @@ HTC Hero with Android 2.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
|
||||
|
||||
In summary:
|
||||
We found that all four apps crashed on Android 4.1.1.
|
||||
|
||||
- BitMPC lacks finishing touches on its user interface but supports all
|
||||
features tested.
|
||||
- Droid MPD Client works well, but got a couple of bugs one can live with and
|
||||
does not expose stored playlist anywhere.
|
||||
- IcyBeats is not usable yet.
|
||||
- MPDroid is working well and looking good, but does not have search
|
||||
functionality.
|
||||
- PMix is just a lesser MPDroid, so use MPDroid instead.
|
||||
- ThreeMPD is too buggy to even get connected to Mopidy.
|
||||
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:
|
||||
|
||||
Our recommendation:
|
||||
- PMix can be ignored, because it is unmaintained and its fork MPDroid is
|
||||
better on all fronts.
|
||||
|
||||
- If you do not care about looks, use BitMPC.
|
||||
- If you do not care about stored playlists, use Droid MPD Client.
|
||||
- If you do not care about searching, use MPDroid.
|
||||
- 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.
|
||||
|
||||
- BitMPC is in our experience feature complete, but ugly.
|
||||
|
||||
- MPDroid, now that search is in place, is probably feature complete as well,
|
||||
and looks nicer than BitMPC.
|
||||
|
||||
In conclusion: MPD clients on Android 4.x is a sad affair. If you want to try
|
||||
anyway, try BitMPC and MPDroid.
|
||||
|
||||
|
||||
BitMPC
|
||||
------
|
||||
|
||||
We tested version 1.0.0, which at the time had 1k-5k downloads, <100 ratings,
|
||||
3.5 stars.
|
||||
Test date:
|
||||
2012-09-12
|
||||
Tested version:
|
||||
1.0.0 (released 2010-04-12)
|
||||
Downloads:
|
||||
5,000+
|
||||
Rating:
|
||||
3.7 stars from about 100 ratings
|
||||
|
||||
The user interface lacks some finishing touches. E.g. you can't enter a
|
||||
hostname for the server. Only IPv4 addresses are allowed.
|
||||
|
||||
All features exercised in the test procedure works. BitMPC lacks support for
|
||||
single mode and consume mode. BitMPC crashes if Mopidy is killed or crash.
|
||||
- The user interface lacks some finishing touches. E.g. you can't enter a
|
||||
hostname for the server. Only IPv4 addresses are allowed.
|
||||
|
||||
- When we last tested the same version of BitMPC using Android 2.1:
|
||||
|
||||
- All features exercised in the test procedure worked.
|
||||
|
||||
- BitMPC lacked support for single mode and consume mode.
|
||||
|
||||
- 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.
|
||||
|
||||
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
|
||||
it hasn't been maintained for 2.5 years.
|
||||
|
||||
|
||||
Droid MPD Client
|
||||
----------------
|
||||
|
||||
We tested version 0.4.0, which at the time had 5k-10k downloads, >200 ratings,
|
||||
4 stars.
|
||||
Test date:
|
||||
2012-09-12
|
||||
Tested version:
|
||||
1.4.0 (released 2011-12-20)
|
||||
Downloads:
|
||||
10,000+
|
||||
Rating:
|
||||
4.2 stars from 400+ ratings
|
||||
|
||||
To find the search functionality, you have to select the menu, then "Playlist
|
||||
manager", then the search tab. I do not understand why search is hidden inside
|
||||
"Playlist manager".
|
||||
- No intutive way to ask the app to connect to the server after adding the
|
||||
server hostname to the settings.
|
||||
|
||||
The user interface have some French remnants, like "Rechercher" in the search
|
||||
field.
|
||||
- To find the search functionality, you have to select the menu,
|
||||
then "Playlist manager", then the search tab. I do not understand why search
|
||||
is hidden inside "Playlist manager".
|
||||
|
||||
When selecting the artist tab, it issues the ``list Artist`` command and
|
||||
becomes stuck waiting for the results. Same thing happens for the album tab,
|
||||
which issues ``list Album``, and the folder tab, which issues ``lsinfo``.
|
||||
Mopidy returned zero hits immediately on all three commands. If Mopidy has
|
||||
loaded your stored playlists and returns more than zero hits on these commands,
|
||||
they artist and album tabs do not hang. The folder tab still freezes when
|
||||
``lsinfo`` returns a list of stored playlists, though zero files. Thus, we've
|
||||
discovered a couple of bugs in Droid MPD Client.
|
||||
- The tabs "Artists" and "Albums" did not contain anything, and did not cause
|
||||
any requests.
|
||||
|
||||
The volume control is very slick, with a turn knob, just like on an amplifier.
|
||||
It lends itself to showing off to friends when combined with Mopidy's external
|
||||
amplifier mixers. Everybody loves turning a knob on a touch screen and see the
|
||||
physical knob on the amplifier turn as well ;-)
|
||||
- The tab "Folders" showed a spinner and said "Updating data..." but did not
|
||||
send any requests.
|
||||
|
||||
Even though ``lsinfo`` returns the stored playlists for the folder tab, they
|
||||
are not displayed anywhere. Thus, we had to select an album in the album tab to
|
||||
complete the test procedure.
|
||||
- Searching for "foo" did nothing. No request was sent to the server.
|
||||
|
||||
At one point, I had problems turning off repeat mode. After I adjusted the
|
||||
volume and tried again, it worked.
|
||||
- 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. It does not
|
||||
detect that the server is killed/crashed. You'll only notice it by no actions
|
||||
having any effect, e.g. you can't turn the volume knob any more.
|
||||
- Droid MPD client does not support single mode or consume mode.
|
||||
|
||||
In conclusion, some bugs and caveats, but most of the test procedure was
|
||||
possible to perform.
|
||||
- Not able to complete the test procedure, due to the above problems.
|
||||
|
||||
|
||||
IcyBeats
|
||||
--------
|
||||
|
||||
We tested version 0.2, which at the time had 50-100 downloads, no ratings.
|
||||
The app was still in beta when we tried it.
|
||||
|
||||
IcyBeats successfully connected to Mopidy and I was able to adjust volume. When
|
||||
I was searching for some tracks, I could not figure out how to actually start
|
||||
the search, as there was no search button and pressing enter in the input field
|
||||
just added a new line. I was stuck. In other words, IcyBeats 0.2 is not usable
|
||||
with Mopidy.
|
||||
|
||||
IcyBeats does have something going for it: IcyBeats uses IPv6 to connect to
|
||||
Mopidy. The future is just around the corner!
|
||||
In conclusion, not a client we can recommend.
|
||||
|
||||
|
||||
MPDroid
|
||||
-------
|
||||
|
||||
We tested version 0.6.9, which at the time had 5k-10k downloads, <200 ratings,
|
||||
4.5 stars. MPDroid started out as a fork of PMix.
|
||||
Test date:
|
||||
2012-09-12
|
||||
Tested version:
|
||||
0.7 (released 2011-06-19)
|
||||
Downloads:
|
||||
10,000+
|
||||
Rating:
|
||||
4.5 stars from ~500 ratings
|
||||
|
||||
First of all, MPDroid's user interface looks nice.
|
||||
- MPDroid started out as a fork of PMix.
|
||||
|
||||
I couldn't find any search functionality, so I added the initial track using
|
||||
another client. Other than the missing search functionality, everything in the
|
||||
test procedure worked out flawlessly. Like all other Android clients, MPDroid
|
||||
does not support single mode or consume mode. When Mopidy is killed, MPDroid
|
||||
handles it gracefully and asks if you want to try to reconnect.
|
||||
- First of all, MPDroid's user interface looks nice.
|
||||
|
||||
All in all, MPDroid is a good MPD client without search support.
|
||||
- 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
|
||||
----
|
||||
|
||||
We tested version 0.4.0, which at the time had 10k-50k downloads, >200 ratings,
|
||||
4 stars.
|
||||
Test date:
|
||||
2012-09-12
|
||||
Tested version:
|
||||
0.4.0 (released 2010-03-06)
|
||||
Downloads:
|
||||
10,000+
|
||||
Rating:
|
||||
3.8 stars from >200 ratings
|
||||
|
||||
Add MPDroid is a fork from PMix, it is no surprise that PMix does not support
|
||||
search either. In addition, I could not find stored playlists. Other than that,
|
||||
I was able to complete the test procedure. PMix crashed once during testing,
|
||||
but handled the killing of Mopidy just as nicely as MPDroid. It does not
|
||||
support single mode or consume mode.
|
||||
- Using Android 4.1.1, PMix, which haven't been updated for 2.5 years, crashes
|
||||
as soon as it connects to Mopidy.
|
||||
|
||||
- Last time we tested the same version of PMix using Android 2.1, we found
|
||||
that:
|
||||
|
||||
- PMix does not support search.
|
||||
|
||||
- I could not find stored playlists.
|
||||
|
||||
- Other than that, I was able to complete the test procedure.
|
||||
|
||||
- PMix crashed once during testing.
|
||||
|
||||
- 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.
|
||||
|
||||
|
||||
ThreeMPD
|
||||
--------
|
||||
|
||||
We tested version 0.3.0, which at the time had 1k-5k downloads, <25 ratings,
|
||||
2.5 average. The developer request users to use MPDroid instead, due to limited
|
||||
time for maintenance. Does not support password authentication.
|
||||
|
||||
ThreeMPD froze during startup, so we were not able to test it.
|
||||
|
||||
|
||||
.. _ios_mpd_clients:
|
||||
|
||||
iPhone/iPod Touch clients
|
||||
=========================
|
||||
|
||||
impdclient
|
||||
----------
|
||||
|
||||
There's an open source MPD client for iOS called `impdclient
|
||||
<http://code.google.com/p/impdclient/>`_ which has not seen any updates since
|
||||
August 2008. So far, we've not heard of users trying it with Mopidy. Please
|
||||
notify us of your successes and/or problems if you do try it out.
|
||||
|
||||
iOS clients
|
||||
===========
|
||||
|
||||
MPod
|
||||
----
|
||||
|
||||
The `MPoD <http://www.katoemba.net/makesnosenseatall/mpod/>`_ client can be
|
||||
installed from the `iTunes Store
|
||||
Test date:
|
||||
2011-01-19
|
||||
Tested version:
|
||||
1.5.1
|
||||
|
||||
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>`_.
|
||||
|
||||
Users have reported varying success in using MPoD together with Mopidy. Thus,
|
||||
@ -321,3 +324,10 @@ we've tested a fresh install of MPoD 1.5.1 with Mopidy as of revision e7ed28d
|
||||
- **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`.
|
||||
|
||||
|
||||
MPaD
|
||||
----
|
||||
|
||||
The `MPaD <http://www.katoemba.net/makesnosenseatall/mpad/>`_ iPad app works
|
||||
with Mopidy. A complete review may appear here in the future.
|
||||
|
||||
12
docs/conf.py
12
docs/conf.py
@ -22,17 +22,19 @@ class Mock(object):
|
||||
def __call__(self, *args, **kwargs):
|
||||
return Mock()
|
||||
|
||||
def __or__(self, other):
|
||||
return Mock()
|
||||
|
||||
@classmethod
|
||||
def __getattr__(self, name):
|
||||
if name in ('__file__', '__path__'):
|
||||
return '/dev/null'
|
||||
elif name[0] == name[0].upper():
|
||||
elif name[0] == name[0].upper() and not name.startswith('MIXER_TRACK'):
|
||||
return type(name, (), {})
|
||||
else:
|
||||
return Mock()
|
||||
|
||||
MOCK_MODULES = [
|
||||
'alsaaudio',
|
||||
'dbus',
|
||||
'dbus.mainloop',
|
||||
'dbus.mainloop.glib',
|
||||
@ -51,11 +53,6 @@ MOCK_MODULES = [
|
||||
for mod_name in MOCK_MODULES:
|
||||
sys.modules[mod_name] = Mock()
|
||||
|
||||
def get_version():
|
||||
init_py = open('../mopidy/__init__.py').read()
|
||||
metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", init_py))
|
||||
return metadata['version']
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
@ -94,6 +91,7 @@ copyright = u'2010-2012, Stein Magnus Jodal and contributors'
|
||||
# built documents.
|
||||
#
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
from mopidy import get_version
|
||||
release = get_version()
|
||||
# The short X.Y version.
|
||||
version = '.'.join(release.split('.')[:2])
|
||||
|
||||
278
docs/development.rst
Normal file
278
docs/development.rst
Normal file
@ -0,0 +1,278 @@
|
||||
***********
|
||||
Development
|
||||
***********
|
||||
|
||||
Development of Mopidy is coordinated through the IRC channel ``#mopidy`` at
|
||||
``irc.freenode.net`` and through `GitHub <http://github.com/>`_.
|
||||
|
||||
|
||||
Release schedule
|
||||
================
|
||||
|
||||
We intend to have about one timeboxed feature release every month
|
||||
in periods of active development. The feature releases are numbered 0.x.0. The
|
||||
features added is a mix of what we feel is most important/requested of the
|
||||
missing features, and features we develop just because we find them fun to
|
||||
make, even though they may be useful for very few users or for a limited use
|
||||
case.
|
||||
|
||||
Bugfix releases, numbered 0.x.y, will be released whenever we discover bugs
|
||||
that are too serious to wait for the next feature release. We will only release
|
||||
bugfix releases for the last feature release. E.g. when 0.3.0 is released, we
|
||||
will no longer provide bugfix releases for the 0.2 series. In other words,
|
||||
there will be just a single supported release at any point in time.
|
||||
|
||||
|
||||
Feature wishlist
|
||||
================
|
||||
|
||||
We maintain our collection of sane or less sane ideas for future Mopidy
|
||||
features as `issues <https://github.com/mopidy/mopidy/issues>`_ at GitHub
|
||||
labeled with `the "wishlist" label
|
||||
<https://github.com/mopidy/mopidy/issues?labels=wishlist>`_. Feel free to vote
|
||||
up any feature you would love to see in Mopidy, but please refrain from adding
|
||||
a comment just to say "I want this too!". You are of course free to add
|
||||
comments if you have suggestions for how the feature should work or be
|
||||
implemented, and you may add new wishlist issues if your ideas are not already
|
||||
represented.
|
||||
|
||||
|
||||
Code style
|
||||
==========
|
||||
|
||||
- Follow :pep:`8` unless otherwise noted. `pep8.py
|
||||
<http://pypi.python.org/pypi/pep8/>`_ can be used to check your code against
|
||||
the guidelines, however remember that matching the style of the surrounding
|
||||
code is also important.
|
||||
|
||||
- Use four spaces for indentation, *never* tabs.
|
||||
|
||||
- Use CamelCase with initial caps for class names::
|
||||
|
||||
ClassNameWithCamelCase
|
||||
|
||||
- Use underscore to split variable, function and method names for
|
||||
readability. Don't use CamelCase.
|
||||
|
||||
::
|
||||
|
||||
lower_case_with_underscores
|
||||
|
||||
- Use the fact that empty strings, lists and tuples are :class:`False` and
|
||||
don't compare boolean values using ``==`` and ``!=``.
|
||||
|
||||
- Follow whitespace rules as described in :pep:`8`. Good examples::
|
||||
|
||||
spam(ham[1], {eggs: 2})
|
||||
spam(1)
|
||||
dict['key'] = list[index]
|
||||
|
||||
- Limit lines to 80 characters and avoid trailing whitespace. However note that
|
||||
wrapped lines should be *one* indentation level in from level above, except
|
||||
for ``if``, ``for``, ``with``, and ``while`` lines which should have two
|
||||
levels of indentation::
|
||||
|
||||
if (foo and bar ...
|
||||
baz and foobar):
|
||||
a = 1
|
||||
|
||||
from foobar import (foo, bar, ...
|
||||
baz)
|
||||
|
||||
- For consistency, prefer ``'`` over ``"`` for strings, unless the string
|
||||
contains ``'``.
|
||||
|
||||
- Take a look at :pep:`20` for a nice peek into a general mindset useful for
|
||||
Python coding.
|
||||
|
||||
|
||||
Commit guidelines
|
||||
=================
|
||||
|
||||
- We follow the development process described at http://nvie.com/git-model.
|
||||
|
||||
- Keep commits small and on topic.
|
||||
|
||||
- If a commit looks too big you should be working in a feature branch not a
|
||||
single commit.
|
||||
|
||||
- Merge feature branches with ``--no-ff`` to keep track of the merge.
|
||||
|
||||
|
||||
Running tests
|
||||
=============
|
||||
|
||||
To run tests, you need a couple of dependencies. They can be installed through
|
||||
Debian/Ubuntu package management::
|
||||
|
||||
sudo apt-get install python-coverage python-mock python-nose
|
||||
|
||||
Or, they can be installed using ``pip``::
|
||||
|
||||
sudo pip install -r requirements/tests.txt
|
||||
|
||||
Then, to run all tests, go to the project directory and run::
|
||||
|
||||
nosetests
|
||||
|
||||
For example::
|
||||
|
||||
$ nosetests
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
.......
|
||||
----------------------------------------------------------------------
|
||||
Ran 217 tests in 0.267s
|
||||
|
||||
OK
|
||||
|
||||
To run tests with test coverage statistics::
|
||||
|
||||
nosetests --with-coverage
|
||||
|
||||
For more documentation on testing, check out the `nose documentation
|
||||
<http://somethingaboutorange.com/mrl/projects/nose/>`_.
|
||||
|
||||
|
||||
Continuous integration
|
||||
======================
|
||||
|
||||
Mopidy uses the free service `Travis CI <http://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
|
||||
CI, and the build status will be visible in the GitHub pull request interface,
|
||||
making it easier to evaluate the quality of pull requests.
|
||||
|
||||
In addition, we run a Jenkins CI server at http://ci.mopidy.com/ that runs all
|
||||
test on multiple platforms (Ubuntu, OS X, x86, arm) for every commit we push to
|
||||
the ``develop`` branch in the main Mopidy repo on GitHub. Thus, new code isn't
|
||||
tested by Jenkins before it is merged into the ``develop`` branch, which is a
|
||||
bit late, but good enough to get broad testing before new code is released.
|
||||
|
||||
In addition to running tests, the Jenkins CI server also gathers coverage
|
||||
statistics and uses pylint to check for errors and possible improvements in our
|
||||
code. So, if you're out of work, the code coverage and pylint data at the CI
|
||||
server should give you a place to start.
|
||||
|
||||
|
||||
Protocol debugging
|
||||
==================
|
||||
|
||||
Since the main interface provided to Mopidy is through the MPD protocol, it is
|
||||
crucial that we try and stay in sync with protocol developments. In an attempt
|
||||
to make it easier to debug differences Mopidy and MPD protocol handling we have
|
||||
created ``tools/debug-proxy.py``.
|
||||
|
||||
This tool is proxy that sits in front of two MPD protocol aware servers and
|
||||
sends all requests to both, returning the primary response to the client and
|
||||
then printing any diff in the two responses.
|
||||
|
||||
Note that this tool depends on ``gevent`` unlike the rest of Mopidy at the time
|
||||
of writing. See ``--help`` for available options. Sample session::
|
||||
|
||||
[127.0.0.1]:59714
|
||||
listallinfo
|
||||
--- Reference response
|
||||
+++ Actual response
|
||||
@@ -1,16 +1,1 @@
|
||||
-file: uri1
|
||||
-Time: 4
|
||||
-Artist: artist1
|
||||
-Title: track1
|
||||
-Album: album1
|
||||
-file: uri2
|
||||
-Time: 4
|
||||
-Artist: artist2
|
||||
-Title: track2
|
||||
-Album: album2
|
||||
-file: uri3
|
||||
-Time: 4
|
||||
-Artist: artist3
|
||||
-Title: track3
|
||||
-Album: album3
|
||||
-OK
|
||||
+ACK [2@0] {listallinfo} incorrect arguments
|
||||
|
||||
To ensure that Mopidy and MPD have comparable state it is suggested you setup
|
||||
both to use ``tests/data/advanced_tag_cache`` for their tag cache and
|
||||
``tests/data/scanner/advanced/`` for the music folder and ``tests/data`` for
|
||||
playlists.
|
||||
|
||||
|
||||
Writing documentation
|
||||
=====================
|
||||
|
||||
To write documentation, we use `Sphinx <http://sphinx.pocoo.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.
|
||||
|
||||
You can install them through Debian/Ubuntu package management::
|
||||
|
||||
sudo apt-get install python-sphinx python-pygraphviz graphviz
|
||||
|
||||
Then, to generate docs::
|
||||
|
||||
cd docs/
|
||||
make # For help on available targets
|
||||
make html # To generate HTML docs
|
||||
|
||||
The documentation at http://docs.mopidy.com/ is automatically updated when a
|
||||
documentation update is pushed to ``mopidy/mopidy`` at GitHub.
|
||||
|
||||
|
||||
Creating releases
|
||||
=================
|
||||
|
||||
#. Update changelog and commit it.
|
||||
|
||||
#. Merge the release branch (``develop`` in the example) into master::
|
||||
|
||||
git checkout master
|
||||
git merge --no-ff -m "Release v0.2.0" develop
|
||||
|
||||
#. Tag the release::
|
||||
|
||||
git tag -a -m "Release v0.2.0" v0.2.0
|
||||
|
||||
#. Push to GitHub::
|
||||
|
||||
git push
|
||||
git push --tags
|
||||
|
||||
#. Build package and upload to PyPI::
|
||||
|
||||
rm MANIFEST # Will be regenerated by setup.py
|
||||
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.
|
||||
@ -1,165 +0,0 @@
|
||||
*****************
|
||||
How to contribute
|
||||
*****************
|
||||
|
||||
Development of Mopidy is coordinated through the IRC channel ``#mopidy`` at
|
||||
``irc.freenode.net`` and through `GitHub <http://github.com/>`_.
|
||||
|
||||
|
||||
Code style
|
||||
==========
|
||||
|
||||
- Follow :pep:`8` unless otherwise noted. `pep8.py
|
||||
<http://pypi.python.org/pypi/pep8/>`_ can be used to check your code against
|
||||
the guidelines, however remember that matching the style of the surrounding
|
||||
code is also important.
|
||||
|
||||
- Use four spaces for indentation, *never* tabs.
|
||||
|
||||
- Use CamelCase with initial caps for class names::
|
||||
|
||||
ClassNameWithCamelCase
|
||||
|
||||
- Use underscore to split variable, function and method names for
|
||||
readability. Don't use CamelCase.
|
||||
|
||||
::
|
||||
|
||||
lower_case_with_underscores
|
||||
|
||||
- Use the fact that empty strings, lists and tuples are :class:`False` and
|
||||
don't compare boolean values using ``==`` and ``!=``.
|
||||
|
||||
- Follow whitespace rules as described in :pep:`8`. Good examples::
|
||||
|
||||
spam(ham[1], {eggs: 2})
|
||||
spam(1)
|
||||
dict['key'] = list[index]
|
||||
|
||||
- Limit lines to 80 characters and avoid trailing whitespace. However note that
|
||||
wrapped lines should be *one* indentation level in from level above, except
|
||||
for ``if``, ``for``, ``with``, and ``while`` lines which should have two
|
||||
levels of indentation::
|
||||
|
||||
if (foo and bar ...
|
||||
baz and foobar):
|
||||
a = 1
|
||||
|
||||
from foobar import (foo, bar, ...
|
||||
baz)
|
||||
|
||||
- For consistency, prefer ``'`` over ``"`` for strings, unless the string
|
||||
contains ``'``.
|
||||
|
||||
- Take a look at :pep:`20` for a nice peek into a general mindset useful for
|
||||
Python coding.
|
||||
|
||||
|
||||
Commit guidelines
|
||||
=================
|
||||
|
||||
- We follow the development process described at http://nvie.com/git-model.
|
||||
|
||||
- Keep commits small and on topic.
|
||||
|
||||
- If a commit looks too big you should be working in a feature branch not a
|
||||
single commit.
|
||||
|
||||
- Merge feature branches with ``--no-ff`` to keep track of the merge.
|
||||
|
||||
|
||||
Running tests
|
||||
=============
|
||||
|
||||
To run tests, you need a couple of dependencies. They can be installed through
|
||||
Debian/Ubuntu package management::
|
||||
|
||||
sudo apt-get install python-coverage python-mock python-nose
|
||||
|
||||
Or, they can be installed using ``pip``::
|
||||
|
||||
sudo pip install -r requirements/tests.txt
|
||||
|
||||
Then, to run all tests, go to the project directory and run::
|
||||
|
||||
nosetests
|
||||
|
||||
For example::
|
||||
|
||||
$ nosetests
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
.......
|
||||
----------------------------------------------------------------------
|
||||
Ran 217 tests in 0.267s
|
||||
|
||||
OK
|
||||
|
||||
To run tests with test coverage statistics::
|
||||
|
||||
nosetests --with-coverage
|
||||
|
||||
For more documentation on testing, check out the `nose documentation
|
||||
<http://somethingaboutorange.com/mrl/projects/nose/>`_.
|
||||
|
||||
|
||||
Continuous integration server
|
||||
=============================
|
||||
|
||||
We run a continuous integration (CI) server at http://ci.mopidy.com/ that runs
|
||||
all test on multiple platforms (Ubuntu, OS X, etc.) for every commit we push to
|
||||
GitHub.
|
||||
|
||||
In addition to running tests, the CI server also gathers coverage statistics
|
||||
and uses pylint to check for errors and possible improvements in our code. So,
|
||||
if you're out of work, the code coverage and pylint data at the CI server
|
||||
should give you a place to start.
|
||||
|
||||
|
||||
Writing documentation
|
||||
=====================
|
||||
|
||||
To write documentation, we use `Sphinx <http://sphinx.pocoo.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.
|
||||
|
||||
You can install them through Debian/Ubuntu package management::
|
||||
|
||||
sudo apt-get install python-sphinx python-pygraphviz graphviz
|
||||
|
||||
Then, to generate docs::
|
||||
|
||||
cd docs/
|
||||
make # For help on available targets
|
||||
make html # To generate HTML docs
|
||||
|
||||
The documentation at http://docs.mopidy.com/ is automatically updated when a
|
||||
documentation update is pushed to ``mopidy/mopidy`` at GitHub.
|
||||
|
||||
|
||||
Creating releases
|
||||
=================
|
||||
|
||||
#. Update changelog and commit it.
|
||||
|
||||
#. Merge the release branch (``develop`` in the example) into master::
|
||||
|
||||
git checkout master
|
||||
git merge --no-ff -m "Release v0.2.0" develop
|
||||
|
||||
#. Tag the release::
|
||||
|
||||
git tag -a -m "Release v0.2.0" v0.2.0
|
||||
|
||||
#. Push to GitHub::
|
||||
|
||||
git push
|
||||
git push --tags
|
||||
|
||||
#. Build package and upload to PyPI::
|
||||
|
||||
rm MANIFEST # Will be regenerated by setup.py
|
||||
python setup.py sdist upload
|
||||
|
||||
#. Spread the word.
|
||||
@ -1,9 +0,0 @@
|
||||
***********
|
||||
Development
|
||||
***********
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 3
|
||||
|
||||
roadmap
|
||||
contributing
|
||||
@ -1,34 +0,0 @@
|
||||
*******
|
||||
Roadmap
|
||||
*******
|
||||
|
||||
|
||||
Release schedule
|
||||
================
|
||||
|
||||
We intend to have about one timeboxed feature release every month
|
||||
in periods of active development. The feature releases are numbered 0.x.0. The
|
||||
features added is a mix of what we feel is most important/requested of the
|
||||
missing features, and features we develop just because we find them fun to
|
||||
make, even though they may be useful for very few users or for a limited use
|
||||
case.
|
||||
|
||||
Bugfix releases, numbered 0.x.y, will be released whenever we discover bugs
|
||||
that are too serious to wait for the next feature release. We will only release
|
||||
bugfix releases for the last feature release. E.g. when 0.3.0 is released, we
|
||||
will no longer provide bugfix releases for the 0.2 series. In other words,
|
||||
there will be just a single supported release at any point in time.
|
||||
|
||||
|
||||
Feature wishlist
|
||||
================
|
||||
|
||||
We maintain our collection of sane or less sane ideas for future Mopidy
|
||||
features as `issues <https://github.com/mopidy/mopidy/issues>`_ at GitHub
|
||||
labeled with `the "wishlist" label
|
||||
<https://github.com/mopidy/mopidy/issues?labels=wishlist>`_. Feel free to vote
|
||||
up any feature you would love to see in Mopidy, but please refrain from adding
|
||||
a comment just to say "I want this too!". You are of course free to add
|
||||
comments if you have suggestions for how the feature should work or be
|
||||
implemented, and you may add new wishlist issues if your ideas are not already
|
||||
represented.
|
||||
@ -54,7 +54,7 @@ Development documentation
|
||||
.. toctree::
|
||||
:maxdepth: 3
|
||||
|
||||
development/index
|
||||
development
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
GStreamer installation
|
||||
**********************
|
||||
|
||||
To use the Mopidy, you first need to install GStreamer and the GStreamer Python
|
||||
To use Mopidy, you first need to install GStreamer and the GStreamer Python
|
||||
bindings.
|
||||
|
||||
|
||||
@ -54,15 +54,8 @@ Python bindings on OS X using Homebrew.
|
||||
|
||||
#. Install `Homebrew <https://github.com/mxcl/homebrew>`_.
|
||||
|
||||
#. Download our Homebrew formulas for ``pycairo``, ``pygobject``, ``pygtk``,
|
||||
and ``gst-python``::
|
||||
#. Download our Homebrew formula for ``gst-python``::
|
||||
|
||||
curl -o $(brew --prefix)/Library/Formula/pycairo.rb \
|
||||
https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/pycairo.rb
|
||||
curl -o $(brew --prefix)/Library/Formula/pygobject.rb \
|
||||
https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/pygobject.rb
|
||||
curl -o $(brew --prefix)/Library/Formula/pygtk.rb \
|
||||
https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/pygtk.rb
|
||||
curl -o $(brew --prefix)/Library/Formula/gst-python.rb \
|
||||
https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/gst-python.rb
|
||||
|
||||
@ -77,13 +70,13 @@ Python bindings on OS X using Homebrew.
|
||||
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.6/site-packages:$PYTHONPATH
|
||||
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.6/site-packages mopidy
|
||||
PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages mopidy
|
||||
|
||||
Note that you need to replace ``python2.6`` with ``python2.7`` if that's
|
||||
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
|
||||
@ -112,12 +105,9 @@ Using a custom audio sink
|
||||
=========================
|
||||
|
||||
If you for some reason want to use some other GStreamer audio sink than
|
||||
``autoaudiosink``, you can add ``mopidy.outputs.custom.CustomOutput`` to the
|
||||
:attr:`mopidy.settings.OUTPUTS` setting, and set the
|
||||
:attr:`mopidy.settings.CUSTOM_OUTPUT` setting to a partial GStreamer pipeline
|
||||
description describing the GStreamer sink you want to use.
|
||||
``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::
|
||||
|
||||
OUTPUTS = (u'mopidy.outputs.custom.CustomOutput',)
|
||||
CUSTOM_OUTPUT = u'oss4sink'
|
||||
OUTPUT = u'oss4sink'
|
||||
|
||||
@ -46,9 +46,6 @@ dependencies installed.
|
||||
|
||||
sudo apt-get install python-dbus python-indicate
|
||||
|
||||
- Some custom mixers (but not the default one) require additional
|
||||
dependencies. See the docs for each mixer.
|
||||
|
||||
|
||||
Install latest stable release
|
||||
=============================
|
||||
@ -176,7 +173,7 @@ If you want to contribute to Mopidy, you should install Mopidy using Git.
|
||||
|
||||
For an introduction to ``git``, please visit `git-scm.com
|
||||
<http://git-scm.com/>`_. Also, please read our :doc:`developer documentation
|
||||
</development/index>`.
|
||||
</development>`.
|
||||
|
||||
|
||||
From AUR on ArchLinux
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
********************************************
|
||||
:mod:`mopidy.gstreamer` -- GStreamer adapter
|
||||
********************************************
|
||||
|
||||
.. automodule:: mopidy.gstreamer
|
||||
:synopsis: GStreamer adapter
|
||||
:members:
|
||||
@ -1,7 +0,0 @@
|
||||
*************************************************
|
||||
:mod:`mopidy.mixers.alsa` -- ALSA mixer for Linux
|
||||
*************************************************
|
||||
|
||||
.. automodule:: mopidy.mixers.alsa
|
||||
:synopsis: ALSA mixer for Linux
|
||||
:members:
|
||||
@ -1,7 +0,0 @@
|
||||
*****************************************************************
|
||||
:mod:`mopidy.mixers.denon` -- Hardware mixer for Denon amplifiers
|
||||
*****************************************************************
|
||||
|
||||
.. automodule:: mopidy.mixers.denon
|
||||
:synopsis: Hardware mixer for Denon amplifiers
|
||||
:members:
|
||||
@ -1,7 +0,0 @@
|
||||
*****************************************************
|
||||
:mod:`mopidy.mixers.dummy` -- Dummy mixer for testing
|
||||
*****************************************************
|
||||
|
||||
.. automodule:: mopidy.mixers.dummy
|
||||
:synopsis: Dummy mixer for testing
|
||||
:members:
|
||||
@ -1,7 +0,0 @@
|
||||
***************************************************************************
|
||||
:mod:`mopidy.mixers.gstreamer_software` -- Software mixer for all platforms
|
||||
***************************************************************************
|
||||
|
||||
.. automodule:: mopidy.mixers.gstreamer_software
|
||||
:synopsis: Software mixer for all platforms
|
||||
:members:
|
||||
@ -1,7 +0,0 @@
|
||||
*************************************************************
|
||||
:mod:`mopidy.mixers.nad` -- Hardware mixer for NAD amplifiers
|
||||
*************************************************************
|
||||
|
||||
.. automodule:: mopidy.mixers.nad
|
||||
:synopsis: Hardware mixer for NAD amplifiers
|
||||
:members:
|
||||
@ -1,7 +0,0 @@
|
||||
**********************************************
|
||||
:mod:`mopidy.mixers.osa` -- Osa mixer for OS X
|
||||
**********************************************
|
||||
|
||||
.. automodule:: mopidy.mixers.osa
|
||||
:synopsis: Osa mixer for OS X
|
||||
:members:
|
||||
@ -1,11 +0,0 @@
|
||||
************************************************
|
||||
:mod:`mopidy.outputs` -- GStreamer audio outputs
|
||||
************************************************
|
||||
|
||||
The following GStreamer audio outputs implements the :ref:`output-api`.
|
||||
|
||||
.. autoclass:: mopidy.outputs.custom.CustomOutput
|
||||
|
||||
.. autoclass:: mopidy.outputs.local.LocalOutput
|
||||
|
||||
.. autoclass:: mopidy.outputs.shoutcast.ShoutcastOutput
|
||||
@ -56,7 +56,7 @@ You may also want to change some of the ``LOCAL_*`` settings. See
|
||||
|
||||
Currently, Mopidy supports using Spotify *or* local storage as a music
|
||||
source. We're working on using both sources simultaneously, and will
|
||||
hopefully have support for this in the 0.6 release.
|
||||
have support for this in a future release.
|
||||
|
||||
|
||||
.. _generating_a_tag_cache:
|
||||
@ -157,18 +157,18 @@ server simultaneously. To use the SHOUTcast output, do the following:
|
||||
#. Install, configure and start the Icecast server. It can be found in the
|
||||
``icecast2`` package in Debian/Ubuntu.
|
||||
|
||||
#. Add ``mopidy.outputs.shoutcast.ShoutcastOutput`` output to the
|
||||
:attr:`mopidy.settings.OUTPUTS` setting.
|
||||
#. Set :attr:`mopidy.settings.OUTPUT` to ``lame ! shout2send``. An Ogg Vorbis
|
||||
encoder could be used instead of the lame MP3 encoder.
|
||||
|
||||
#. Check the default values for the following settings, and alter them to match
|
||||
your Icecast setup if needed:
|
||||
#. You might also need to change the ``shout2send`` default settings, run
|
||||
``gst-inspect-0.10 shout2send`` to see the available settings. Most likely
|
||||
you want to change ``ip``, ``username``, ``password``, and ``mount``. For
|
||||
example, to set the username and password, use:
|
||||
``lame ! shout2send username="foobar" password="s3cret"``.
|
||||
|
||||
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_HOSTNAME`
|
||||
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PORT`
|
||||
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_USERNAME`
|
||||
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PASSWORD`
|
||||
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_MOUNT`
|
||||
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_ENCODER`
|
||||
Other advanced setups are also possible for outputs. Basically, anything you
|
||||
can use with the ``gst-launch-0.10`` command can be plugged into
|
||||
:attr:`mopidy.settings.OUTPUT`.
|
||||
|
||||
|
||||
Available settings
|
||||
|
||||
@ -8,7 +8,7 @@ from subprocess import PIPE, Popen
|
||||
|
||||
import glib
|
||||
|
||||
__version__ = '0.7.3'
|
||||
__version__ = '0.8.0'
|
||||
|
||||
DATA_PATH = os.path.join(str(glib.get_user_data_dir()), 'mopidy')
|
||||
CACHE_PATH = os.path.join(str(glib.get_user_cache_dir()), 'mopidy')
|
||||
|
||||
@ -1,10 +1,152 @@
|
||||
import logging
|
||||
import optparse
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
|
||||
import gobject
|
||||
gobject.threads_init()
|
||||
|
||||
|
||||
# Extract any non-GStreamer arguments, and leave the GStreamer arguments for
|
||||
# processing by GStreamer. This needs to be done before GStreamer is imported,
|
||||
# so that GStreamer doesn't hijack e.g. ``--help``.
|
||||
# NOTE This naive fix does not support values like ``bar`` in
|
||||
# ``--gst-foo bar``. Use equals to pass values, like ``--gst-foo=bar``.
|
||||
|
||||
def is_gst_arg(argument):
|
||||
return argument.startswith('--gst') or argument == '--help-gst'
|
||||
|
||||
gstreamer_args = [arg for arg in sys.argv[1:] if is_gst_arg(arg)]
|
||||
mopidy_args = [arg for arg in sys.argv[1:] if not is_gst_arg(arg)]
|
||||
sys.argv[1:] = gstreamer_args
|
||||
|
||||
|
||||
# Add ../ to the path so we can run Mopidy from a Git checkout without
|
||||
# installing it on the system.
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0,
|
||||
os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
|
||||
|
||||
|
||||
from mopidy import (get_version, settings, OptionalDependencyError,
|
||||
SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE)
|
||||
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
|
||||
|
||||
|
||||
logger = logging.getLogger('mopidy.main')
|
||||
|
||||
|
||||
def main():
|
||||
signal.signal(signal.SIGTERM, exit_handler)
|
||||
loop = gobject.MainLoop()
|
||||
options = parse_options()
|
||||
try:
|
||||
setup_logging(options.verbosity_level, options.save_debug_log)
|
||||
check_old_folders()
|
||||
setup_settings(options.interactive)
|
||||
setup_audio()
|
||||
setup_backend()
|
||||
setup_frontends()
|
||||
loop.run()
|
||||
except SettingsError as e:
|
||||
logger.error(e.message)
|
||||
except KeyboardInterrupt:
|
||||
logger.info(u'Interrupted. Exiting...')
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
finally:
|
||||
loop.quit()
|
||||
stop_frontends()
|
||||
stop_backend()
|
||||
stop_audio()
|
||||
stop_remaining_actors()
|
||||
|
||||
|
||||
def parse_options():
|
||||
parser = optparse.OptionParser(version=u'Mopidy %s' % get_version())
|
||||
parser.add_option('--help-gst',
|
||||
action='store_true', dest='help_gst',
|
||||
help='show GStreamer help options')
|
||||
parser.add_option('-i', '--interactive',
|
||||
action='store_true', dest='interactive',
|
||||
help='ask interactively for required settings which are missing')
|
||||
parser.add_option('-q', '--quiet',
|
||||
action='store_const', const=0, dest='verbosity_level',
|
||||
help='less output (warning level)')
|
||||
parser.add_option('-v', '--verbose',
|
||||
action='count', default=1, dest='verbosity_level',
|
||||
help='more output (debug level)')
|
||||
parser.add_option('--save-debug-log',
|
||||
action='store_true', dest='save_debug_log',
|
||||
help='save debug log to "./mopidy.log"')
|
||||
parser.add_option('--list-settings',
|
||||
action='callback', callback=list_settings_optparse_callback,
|
||||
help='list current settings')
|
||||
parser.add_option('--list-deps',
|
||||
action='callback', callback=list_deps_optparse_callback,
|
||||
help='list dependencies and their versions')
|
||||
return parser.parse_args(args=mopidy_args)[0]
|
||||
|
||||
|
||||
def check_old_folders():
|
||||
old_settings_folder = os.path.expanduser(u'~/.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)
|
||||
|
||||
|
||||
def setup_settings(interactive):
|
||||
get_or_create_folder(SETTINGS_PATH)
|
||||
get_or_create_folder(DATA_PATH)
|
||||
get_or_create_file(SETTINGS_FILE)
|
||||
try:
|
||||
settings.validate(interactive)
|
||||
except SettingsError, e:
|
||||
logger.error(e.message)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def setup_audio():
|
||||
Audio.start()
|
||||
|
||||
|
||||
def stop_audio():
|
||||
stop_actors_by_class(Audio)
|
||||
|
||||
def setup_backend():
|
||||
get_class(settings.BACKENDS[0]).start()
|
||||
|
||||
|
||||
def stop_backend():
|
||||
stop_actors_by_class(get_class(settings.BACKENDS[0]))
|
||||
|
||||
|
||||
def setup_frontends():
|
||||
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)
|
||||
|
||||
|
||||
def stop_frontends():
|
||||
for frontend_class_name in settings.FRONTENDS:
|
||||
try:
|
||||
stop_actors_by_class(get_class(frontend_class_name))
|
||||
except OptionalDependencyError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
from mopidy.core import main
|
||||
main()
|
||||
|
||||
392
mopidy/audio/__init__.py
Normal file
392
mopidy/audio/__init__.py
Normal file
@ -0,0 +1,392 @@
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst
|
||||
import gobject
|
||||
|
||||
import logging
|
||||
|
||||
from pykka.actor import ThreadingActor
|
||||
from pykka.registry import ActorRegistry
|
||||
|
||||
from mopidy import settings, utils
|
||||
from mopidy.backends.base import Backend
|
||||
from mopidy.utils import process
|
||||
|
||||
# Trigger install of gst mixer plugins
|
||||
from mopidy.audio import mixers
|
||||
|
||||
logger = logging.getLogger('mopidy.audio')
|
||||
|
||||
|
||||
class Audio(ThreadingActor):
|
||||
"""
|
||||
Audio output through `GStreamer <http://gstreamer.freedesktop.org/>`_.
|
||||
|
||||
**Settings:**
|
||||
|
||||
- :attr:`mopidy.settings.OUTPUT`
|
||||
- :attr:`mopidy.settings.MIXER`
|
||||
- :attr:`mopidy.settings.MIXER_TRACK`
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super(Audio, self).__init__()
|
||||
|
||||
self._default_caps = gst.Caps("""
|
||||
audio/x-raw-int,
|
||||
endianness=(int)1234,
|
||||
channels=(int)2,
|
||||
width=(int)16,
|
||||
depth=(int)16,
|
||||
signed=(boolean)true,
|
||||
rate=(int)44100""")
|
||||
|
||||
self._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)
|
||||
|
||||
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)
|
||||
43
mopidy/audio/mixers/__init__.py
Normal file
43
mopidy/audio/mixers/__init__.py
Normal file
@ -0,0 +1,43 @@
|
||||
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
|
||||
72
mopidy/audio/mixers/auto.py
Normal file
72
mopidy/audio/mixers/auto.py
Normal file
@ -0,0 +1,72 @@
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gobject
|
||||
import gst
|
||||
|
||||
import logging
|
||||
|
||||
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')
|
||||
|
||||
def __init__(self):
|
||||
gst.Bin.__init__(self)
|
||||
mixer = self._find_mixer()
|
||||
if mixer:
|
||||
self.add(mixer)
|
||||
logger.debug('AutoAudioMixer chose: %s', mixer.get_name())
|
||||
else:
|
||||
logger.debug('AutoAudioMixer did not find any usable mixers')
|
||||
|
||||
def _find_mixer(self):
|
||||
registry = gst.registry_get_default()
|
||||
|
||||
factories = registry.get_feature_list(gst.TYPE_ELEMENT_FACTORY)
|
||||
factories.sort(key=lambda f: (-f.get_rank(), f.get_name()))
|
||||
|
||||
for factory in factories:
|
||||
# Avoid sink/srcs that implement mixing.
|
||||
if factory.get_klass() != 'Generic/Audio':
|
||||
continue
|
||||
# Avoid anything that doesn't implement mixing.
|
||||
elif not factory.has_interface('GstMixer'):
|
||||
continue
|
||||
|
||||
if self._test_mixer(factory):
|
||||
return factory.create()
|
||||
|
||||
return None
|
||||
|
||||
def _test_mixer(self, factory):
|
||||
element = factory.create()
|
||||
if not element:
|
||||
return False
|
||||
|
||||
try:
|
||||
result = element.set_state(gst.STATE_READY)
|
||||
if result != gst.STATE_CHANGE_SUCCESS:
|
||||
return False
|
||||
|
||||
# Trust that the default device is sane and just check tracks.
|
||||
return self._test_tracks(element)
|
||||
finally:
|
||||
element.set_state(gst.STATE_NULL)
|
||||
|
||||
def _test_tracks(self, element):
|
||||
# Only allow elements that have a least one output track.
|
||||
flags = gst.interfaces.MIXER_TRACK_OUTPUT
|
||||
|
||||
for track in element.list_tracks():
|
||||
if track.flags & flags:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
gobject.type_register(AutoAudioMixer)
|
||||
gst.element_register(AutoAudioMixer, 'autoaudiomixer', gst.RANK_MARGINAL)
|
||||
48
mopidy/audio/mixers/fake.py
Normal file
48
mopidy/audio/mixers/fake.py
Normal file
@ -0,0 +1,48 @@
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gobject
|
||||
import gst
|
||||
|
||||
from mopidy.audio.mixers import create_track
|
||||
|
||||
|
||||
class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer):
|
||||
__gstdetails__ = ('FakeMixer',
|
||||
'Mixer',
|
||||
'Fake mixer for use in tests.',
|
||||
'Thomas Adamcik')
|
||||
|
||||
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)
|
||||
|
||||
def list_tracks(self):
|
||||
track = create_track(
|
||||
self.track_label,
|
||||
self.track_initial_volume,
|
||||
self.track_min_volume,
|
||||
self.track_max_volume,
|
||||
self.track_num_channels,
|
||||
self.track_flags)
|
||||
return [track]
|
||||
|
||||
def get_volume(self, track):
|
||||
return track.volumes
|
||||
|
||||
def set_volume(self, track, volumes):
|
||||
track.volumes = volumes
|
||||
|
||||
def set_record(self, track, record):
|
||||
pass
|
||||
|
||||
|
||||
gobject.type_register(FakeMixer)
|
||||
gst.element_register(FakeMixer, 'fakemixer', gst.RANK_MARGINAL)
|
||||
236
mopidy/audio/mixers/nad.py
Normal file
236
mopidy/audio/mixers/nad.py
Normal file
@ -0,0 +1,236 @@
|
||||
import logging
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gobject
|
||||
import gst
|
||||
|
||||
try:
|
||||
import serial
|
||||
except ImportError:
|
||||
serial = None
|
||||
|
||||
from pykka.actor import ThreadingActor
|
||||
|
||||
from mopidy.audio.mixers import create_track
|
||||
|
||||
|
||||
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')
|
||||
|
||||
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
|
||||
|
||||
def list_tracks(self):
|
||||
track = 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))
|
||||
return [track]
|
||||
|
||||
def get_volume(self, track):
|
||||
return [self._volume_cache]
|
||||
|
||||
def set_volume(self, track, volumes):
|
||||
if len(volumes):
|
||||
volume = volumes[0]
|
||||
self._volume_cache = volume
|
||||
self._nad_talker.set_volume(volume)
|
||||
|
||||
def set_mute(self, track, mute):
|
||||
self._nad_talker.mute(mute)
|
||||
|
||||
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')
|
||||
return gst.STATE_CHANGE_FAILURE
|
||||
self._start_nad_talker()
|
||||
return gst.STATE_CHANGE_SUCCESS
|
||||
|
||||
def _start_nad_talker(self):
|
||||
self._nad_talker = NadTalker.start(
|
||||
port=self.port,
|
||||
source=self.source or None,
|
||||
speakers_a=self.speakers_a or None,
|
||||
speakers_b=self.speakers_b or None
|
||||
).proxy()
|
||||
|
||||
|
||||
gobject.type_register(NadMixer)
|
||||
gst.element_register(NadMixer, 'nadmixer', gst.RANK_MARGINAL)
|
||||
|
||||
|
||||
class NadTalker(ThreadingActor):
|
||||
"""
|
||||
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
|
||||
calibrating the NAD amplifier's volume.
|
||||
"""
|
||||
|
||||
# Serial link settings
|
||||
BAUDRATE = 115200
|
||||
BYTESIZE = 8
|
||||
PARITY = 'N'
|
||||
STOPBITS = 1
|
||||
|
||||
# Timeout in seconds used for read/write operations.
|
||||
# If you set the timeout too low, the reads will never get complete
|
||||
# confirmations and calibration will decrease volume forever. If you set
|
||||
# the timeout too high, stuff takes more time. 0.2s seems like a good value
|
||||
# for NAD C 355BEE.
|
||||
TIMEOUT = 0.2
|
||||
|
||||
# Number of volume levels the amplifier supports. 40 for NAD C 355BEE.
|
||||
VOLUME_LEVELS = 40
|
||||
|
||||
def __init__(self, port, source, speakers_a, speakers_b):
|
||||
super(NadTalker, self).__init__()
|
||||
|
||||
self.port = port
|
||||
self.source = source
|
||||
self.speakers_a = speakers_a
|
||||
self.speakers_b = speakers_b
|
||||
|
||||
# Volume in range 0..VOLUME_LEVELS. :class:`None` before calibration.
|
||||
self._nad_volume = None
|
||||
|
||||
self._device = None
|
||||
|
||||
def on_start(self):
|
||||
self._open_connection()
|
||||
self._set_device_to_known_state()
|
||||
|
||||
def _open_connection(self):
|
||||
logger.info(u'NAD amplifier: Connecting through "%s"',
|
||||
self.port)
|
||||
self._device = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.BAUDRATE,
|
||||
bytesize=self.BYTESIZE,
|
||||
parity=self.PARITY,
|
||||
stopbits=self.STOPBITS,
|
||||
timeout=self.TIMEOUT)
|
||||
self._get_device_model()
|
||||
|
||||
def _set_device_to_known_state(self):
|
||||
self._power_device_on()
|
||||
self._select_speakers()
|
||||
self._select_input_source()
|
||||
self.mute(False)
|
||||
self._calibrate_volume()
|
||||
|
||||
def _get_device_model(self):
|
||||
model = self._ask_device('Main.Model')
|
||||
logger.info(u'NAD amplifier: Connected to model "%s"', model)
|
||||
return model
|
||||
|
||||
def _power_device_on(self):
|
||||
self._check_and_set('Main.Power', 'On')
|
||||
|
||||
def _select_speakers(self):
|
||||
if self.speakers_a is not None:
|
||||
self._check_and_set('Main.SpeakerA', self.speakers_a.title())
|
||||
if self.speakers_b is not None:
|
||||
self._check_and_set('Main.SpeakerB', self.speakers_b.title())
|
||||
|
||||
def _select_input_source(self):
|
||||
if self.source is not None:
|
||||
self._check_and_set('Main.Source', self.source.title())
|
||||
|
||||
def mute(self, mute):
|
||||
if mute:
|
||||
self._check_and_set('Main.Mute', 'On')
|
||||
else:
|
||||
self._check_and_set('Main.Mute', 'Off')
|
||||
|
||||
def _calibrate_volume(self):
|
||||
# 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')
|
||||
|
||||
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)
|
||||
target_nad_volume = int(round(volume * self.VOLUME_LEVELS / 100.0))
|
||||
if self._nad_volume is None:
|
||||
return # Calibration needed
|
||||
while target_nad_volume > self._nad_volume:
|
||||
if self._increase_volume():
|
||||
self._nad_volume += 1
|
||||
while target_nad_volume < self._nad_volume:
|
||||
if self._decrease_volume():
|
||||
self._nad_volume -= 1
|
||||
|
||||
def _increase_volume(self):
|
||||
# Increase volume. Returns :class:`True` if confirmed by device.
|
||||
self._write('Main.Volume+')
|
||||
return self._readline() == 'Main.Volume+'
|
||||
|
||||
def _decrease_volume(self):
|
||||
# Decrease volume. Returns :class:`True` if confirmed by device.
|
||||
self._write('Main.Volume-')
|
||||
return self._readline() == 'Main.Volume-'
|
||||
|
||||
def _check_and_set(self, key, value):
|
||||
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)',
|
||||
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"',
|
||||
key, value)
|
||||
|
||||
def _ask_device(self, key):
|
||||
self._write('%s?' % key)
|
||||
return self._readline().replace('%s=' % key, '')
|
||||
|
||||
def _command_device(self, key, value):
|
||||
if type(value) == unicode:
|
||||
value = value.encode('utf-8')
|
||||
self._write('%s=%s' % (key, value))
|
||||
self._readline()
|
||||
|
||||
def _write(self, data):
|
||||
# Write data to device. Prepends and appends a newline to the data, as
|
||||
# recommended by the NAD documentation.
|
||||
if not self._device.isOpen():
|
||||
self._device.open()
|
||||
self._device.write('\n%s\n' % data)
|
||||
logger.debug('Write: %s', data)
|
||||
|
||||
def _readline(self):
|
||||
# Read line from device. The result is stripped for leading and
|
||||
# trailing whitespace.
|
||||
if not self._device.isOpen():
|
||||
self._device.open()
|
||||
result = self._device.readline().strip()
|
||||
if result:
|
||||
logger.debug('Read: %s', result)
|
||||
return result
|
||||
@ -1,12 +1,7 @@
|
||||
import logging
|
||||
from .library import BaseLibraryProvider
|
||||
from .playback import BasePlaybackProvider
|
||||
from .stored_playlists import BaseStoredPlaylistsProvider
|
||||
|
||||
from .current_playlist import CurrentPlaylistController
|
||||
from .library import LibraryController, BaseLibraryProvider
|
||||
from .playback import PlaybackController, BasePlaybackProvider
|
||||
from .stored_playlists import (StoredPlaylistsController,
|
||||
BaseStoredPlaylistsProvider)
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.base')
|
||||
|
||||
class Backend(object):
|
||||
#: The current playlist controller. An instance of
|
||||
|
||||
@ -1,79 +1,3 @@
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.base')
|
||||
|
||||
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`
|
||||
"""
|
||||
|
||||
pykka_traversable = True
|
||||
|
||||
def __init__(self, backend, provider):
|
||||
self.backend = backend
|
||||
self.provider = provider
|
||||
|
||||
def find_exact(self, **query):
|
||||
"""
|
||||
Search the library for tracks where ``field`` is ``values``.
|
||||
|
||||
Examples::
|
||||
|
||||
# Returns results matching 'a'
|
||||
find_exact(any=['a'])
|
||||
# Returns results matching artist 'xyz'
|
||||
find_exact(artist=['xyz'])
|
||||
# Returns results matching 'a' and 'b' and artist 'xyz'
|
||||
find_exact(any=['a', 'b'], artist=['xyz'])
|
||||
|
||||
:param query: one or more queries to search for
|
||||
:type query: dict
|
||||
:rtype: :class:`mopidy.models.Playlist`
|
||||
"""
|
||||
return self.provider.find_exact(**query)
|
||||
|
||||
def lookup(self, uri):
|
||||
"""
|
||||
Lookup track with given URI. Returns :class:`None` if not found.
|
||||
|
||||
:param uri: track URI
|
||||
:type uri: string
|
||||
:rtype: :class:`mopidy.models.Track` or :class:`None`
|
||||
"""
|
||||
return self.provider.lookup(uri)
|
||||
|
||||
def refresh(self, uri=None):
|
||||
"""
|
||||
Refresh library. Limit to URI and below if an URI is given.
|
||||
|
||||
:param uri: directory or track URI
|
||||
:type uri: string
|
||||
"""
|
||||
return self.provider.refresh(uri)
|
||||
|
||||
def search(self, **query):
|
||||
"""
|
||||
Search the library for tracks where ``field`` contains ``values``.
|
||||
|
||||
Examples::
|
||||
|
||||
# Returns results matching 'a'
|
||||
search(any=['a'])
|
||||
# Returns results matching artist 'xyz'
|
||||
search(artist=['xyz'])
|
||||
# Returns results matching 'a' and 'b' and artist 'xyz'
|
||||
search(any=['a', 'b'], artist=['xyz'])
|
||||
|
||||
:param query: one or more queries to search for
|
||||
:type query: dict
|
||||
:rtype: :class:`mopidy.models.Playlist`
|
||||
"""
|
||||
return self.provider.search(**query)
|
||||
|
||||
|
||||
class BaseLibraryProvider(object):
|
||||
"""
|
||||
:param backend: backend the controller is a part of
|
||||
|
||||
@ -1,545 +1,3 @@
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
|
||||
from mopidy.listeners import BackendListener
|
||||
|
||||
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 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
|
||||
|
||||
#: Constant representing the paused state.
|
||||
PAUSED = u'paused'
|
||||
|
||||
#: Constant representing the playing state.
|
||||
PLAYING = u'playing'
|
||||
|
||||
#: Constant representing the stopped state.
|
||||
STOPPED = u'stopped'
|
||||
|
||||
#: :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)
|
||||
|
||||
#: 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 = self.STOPPED
|
||||
self._shuffled = []
|
||||
self._first_shuffle = True
|
||||
self.play_time_accumulated = 0
|
||||
self.play_time_started = None
|
||||
|
||||
def _get_cpid(self, cp_track):
|
||||
if cp_track is None:
|
||||
return None
|
||||
return cp_track.cpid
|
||||
|
||||
def _get_track(self, cp_track):
|
||||
if cp_track is None:
|
||||
return None
|
||||
return cp_track.track
|
||||
|
||||
@property
|
||||
def current_cpid(self):
|
||||
"""
|
||||
The CPID (current playlist ID) of the currently playing or selected
|
||||
track.
|
||||
|
||||
Read-only. Extracted from :attr:`current_cp_track` for convenience.
|
||||
"""
|
||||
return self._get_cpid(self.current_cp_track)
|
||||
|
||||
@property
|
||||
def current_track(self):
|
||||
"""
|
||||
The currently playing or selected :class:`mopidy.models.Track`.
|
||||
|
||||
Read-only. Extracted from :attr:`current_cp_track` for convenience.
|
||||
"""
|
||||
return self._get_track(self.current_cp_track)
|
||||
|
||||
@property
|
||||
def current_playlist_position(self):
|
||||
"""
|
||||
The position of the current track in the current playlist.
|
||||
|
||||
Read-only.
|
||||
"""
|
||||
if self.current_cp_track is None:
|
||||
return None
|
||||
try:
|
||||
return self.backend.current_playlist.cp_tracks.index(
|
||||
self.current_cp_track)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def track_at_eot(self):
|
||||
"""
|
||||
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_eot` for convenience.
|
||||
"""
|
||||
return self._get_track(self.cp_track_at_eot)
|
||||
|
||||
@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`.
|
||||
"""
|
||||
# pylint: disable = R0911
|
||||
# Too many return statements
|
||||
|
||||
cp_tracks = self.backend.current_playlist.cp_tracks
|
||||
|
||||
if not cp_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
|
||||
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.repeat and self.single:
|
||||
return cp_tracks[self.current_playlist_position]
|
||||
|
||||
if self.repeat and not self.single:
|
||||
return cp_tracks[
|
||||
(self.current_playlist_position + 1) % len(cp_tracks)]
|
||||
|
||||
try:
|
||||
return cp_tracks[self.current_playlist_position + 1]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def track_at_next(self):
|
||||
"""
|
||||
The track that will be played if calling :meth:`next()`.
|
||||
|
||||
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)
|
||||
|
||||
@property
|
||||
def cp_track_at_next(self):
|
||||
"""
|
||||
The track that will be played if calling :meth:`next()`.
|
||||
|
||||
Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`).
|
||||
|
||||
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:
|
||||
return None
|
||||
|
||||
if self.random and not self._shuffled:
|
||||
if self.repeat or self._first_shuffle:
|
||||
logger.debug('Shuffling tracks')
|
||||
self._shuffled = cp_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.repeat:
|
||||
return cp_tracks[
|
||||
(self.current_playlist_position + 1) % len(cp_tracks)]
|
||||
|
||||
try:
|
||||
return cp_tracks[self.current_playlist_position + 1]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def track_at_previous(self):
|
||||
"""
|
||||
The track that will be played if calling :meth:`previous()`.
|
||||
|
||||
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)
|
||||
|
||||
@property
|
||||
def cp_track_at_previous(self):
|
||||
"""
|
||||
The track that will be played if calling :meth:`previous()`.
|
||||
|
||||
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.
|
||||
"""
|
||||
if self.repeat or self.consume or self.random:
|
||||
return self.current_cp_track
|
||||
|
||||
if self.current_playlist_position in (None, 0):
|
||||
return None
|
||||
|
||||
return self.backend.current_playlist.cp_tracks[
|
||||
self.current_playlist_position - 1]
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""
|
||||
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" ]
|
||||
"""
|
||||
return self._state
|
||||
|
||||
@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)
|
||||
|
||||
self._trigger_playback_state_changed()
|
||||
|
||||
# FIXME play_time stuff assumes backend does not have a better way of
|
||||
# handeling this stuff :/
|
||||
if (old_state in (self.PLAYING, self.STOPPED)
|
||||
and new_state == self.PLAYING):
|
||||
self._play_time_start()
|
||||
elif old_state == self.PLAYING and new_state == self.PAUSED:
|
||||
self._play_time_pause()
|
||||
elif old_state == self.PAUSED and new_state == self.PLAYING:
|
||||
self._play_time_resume()
|
||||
|
||||
@property
|
||||
def time_position(self):
|
||||
"""Time position in milliseconds."""
|
||||
if self.state == self.PLAYING:
|
||||
time_since_started = (self._current_wall_time -
|
||||
self.play_time_started)
|
||||
return self.play_time_accumulated + time_since_started
|
||||
elif self.state == self.PAUSED:
|
||||
return self.play_time_accumulated
|
||||
elif self.state == self.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)
|
||||
|
||||
def change_track(self, cp_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 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
|
||||
if old_state == self.PLAYING:
|
||||
self.play(on_error_step=on_error_step)
|
||||
elif old_state == self.PAUSED:
|
||||
self.pause()
|
||||
|
||||
def on_end_of_track(self):
|
||||
"""
|
||||
Tell the playback controller that end of track is reached.
|
||||
|
||||
Typically called by :class:`mopidy.process.CoreProcess` after a message
|
||||
from a library thread is received.
|
||||
"""
|
||||
if self.state == self.STOPPED:
|
||||
return
|
||||
|
||||
original_cp_track = self.current_cp_track
|
||||
|
||||
if self.cp_track_at_eot:
|
||||
self._trigger_track_playback_ended()
|
||||
self.play(self.cp_track_at_eot)
|
||||
else:
|
||||
self.stop(clear_current_track=True)
|
||||
|
||||
if self.consume:
|
||||
self.backend.current_playlist.remove(cpid=original_cp_track.cpid)
|
||||
|
||||
def on_current_playlist_change(self):
|
||||
"""
|
||||
Tell the playback controller that the current playlist has changed.
|
||||
|
||||
Used by :class:`mopidy.backends.base.CurrentPlaylistController`.
|
||||
"""
|
||||
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):
|
||||
self.stop(clear_current_track=True)
|
||||
|
||||
def next(self):
|
||||
"""
|
||||
Change to the next track.
|
||||
|
||||
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:
|
||||
self._trigger_track_playback_ended()
|
||||
self.change_track(self.cp_track_at_next)
|
||||
else:
|
||||
self.stop(clear_current_track=True)
|
||||
|
||||
def pause(self):
|
||||
"""Pause playback."""
|
||||
if self.provider.pause():
|
||||
self.state = self.PAUSED
|
||||
self._trigger_track_playback_paused()
|
||||
|
||||
def play(self, cp_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 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 self.state == self.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
|
||||
|
||||
if cp_track is not None:
|
||||
self.current_cp_track = cp_track
|
||||
self.state = self.PLAYING
|
||||
if not self.provider.play(cp_track.track):
|
||||
# Track is not playable
|
||||
if self.random and self._shuffled:
|
||||
self._shuffled.remove(cp_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)
|
||||
|
||||
self._trigger_track_playback_started()
|
||||
|
||||
def previous(self):
|
||||
"""
|
||||
Change to the previous track.
|
||||
|
||||
The current playback state will be kept. If it was playing, playing
|
||||
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)
|
||||
|
||||
def resume(self):
|
||||
"""If paused, resume playing the current track."""
|
||||
if self.state == self.PAUSED and self.provider.resume():
|
||||
self.state = self.PLAYING
|
||||
self._trigger_track_playback_resumed()
|
||||
|
||||
def seek(self, time_position):
|
||||
"""
|
||||
Seeks to time position given in milliseconds.
|
||||
|
||||
:param time_position: time position in milliseconds
|
||||
:type time_position: int
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
if not self.backend.current_playlist.tracks:
|
||||
return False
|
||||
|
||||
if self.state == self.STOPPED:
|
||||
self.play()
|
||||
elif self.state == self.PAUSED:
|
||||
self.resume()
|
||||
|
||||
if time_position < 0:
|
||||
time_position = 0
|
||||
elif time_position > self.current_track.length:
|
||||
self.next()
|
||||
return True
|
||||
|
||||
self.play_time_started = self._current_wall_time
|
||||
self.play_time_accumulated = time_position
|
||||
|
||||
success = self.provider.seek(time_position)
|
||||
if success:
|
||||
self._trigger_seeked()
|
||||
return success
|
||||
|
||||
def stop(self, clear_current_track=False):
|
||||
"""
|
||||
Stop playing.
|
||||
|
||||
:param clear_current_track: whether to clear the current track _after_
|
||||
stopping
|
||||
:type clear_current_track: boolean
|
||||
"""
|
||||
if self.state != self.STOPPED:
|
||||
if self.provider.stop():
|
||||
self._trigger_track_playback_ended()
|
||||
self.state = self.STOPPED
|
||||
if clear_current_track:
|
||||
self.current_cp_track = None
|
||||
|
||||
def _trigger_track_playback_paused(self):
|
||||
logger.debug(u'Triggering track playback paused event')
|
||||
if self.current_track is None:
|
||||
return
|
||||
BackendListener.send('track_playback_paused',
|
||||
track=self.current_track,
|
||||
time_position=self.time_position)
|
||||
|
||||
def _trigger_track_playback_resumed(self):
|
||||
logger.debug(u'Triggering track playback resumed event')
|
||||
if self.current_track is None:
|
||||
return
|
||||
BackendListener.send('track_playback_resumed',
|
||||
track=self.current_track,
|
||||
time_position=self.time_position)
|
||||
|
||||
def _trigger_track_playback_started(self):
|
||||
logger.debug(u'Triggering track playback started event')
|
||||
if self.current_track is None:
|
||||
return
|
||||
BackendListener.send('track_playback_started',
|
||||
track=self.current_track)
|
||||
|
||||
def _trigger_track_playback_ended(self):
|
||||
logger.debug(u'Triggering track playback ended event')
|
||||
if self.current_track is None:
|
||||
return
|
||||
BackendListener.send('track_playback_ended',
|
||||
track=self.current_track,
|
||||
time_position=self.time_position)
|
||||
|
||||
def _trigger_playback_state_changed(self):
|
||||
logger.debug(u'Triggering playback state change event')
|
||||
BackendListener.send('playback_state_changed')
|
||||
|
||||
def _trigger_options_changed(self):
|
||||
logger.debug(u'Triggering options changed event')
|
||||
BackendListener.send('options_changed')
|
||||
|
||||
def _trigger_seeked(self):
|
||||
logger.debug(u'Triggering seeked event')
|
||||
BackendListener.send('seeked')
|
||||
|
||||
|
||||
class BasePlaybackProvider(object):
|
||||
"""
|
||||
:param backend: the backend
|
||||
@ -555,52 +13,75 @@ class BasePlaybackProvider(object):
|
||||
"""
|
||||
Pause playback.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
*MAY be reimplemented by subclass.*
|
||||
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
return self.backend.audio.pause_playback().get()
|
||||
|
||||
def play(self, track):
|
||||
"""
|
||||
Play given track.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
*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`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
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.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
*MAY be reimplemented by subclass.*
|
||||
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
return self.backend.audio.start_playback().get()
|
||||
|
||||
def seek(self, time_position):
|
||||
"""
|
||||
Seek to a given time position.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
*MAY be reimplemented by subclass.*
|
||||
|
||||
:param time_position: time position in milliseconds
|
||||
:type time_position: int
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
return self.backend.audio.set_position(time_position).get()
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
Stop playback.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
*MAY be reimplemented by subclass.*
|
||||
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
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,120 +1,4 @@
|
||||
from copy import copy
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.base')
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class BaseStoredPlaylistsProvider(object):
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
from pykka.actor import ThreadingActor
|
||||
|
||||
from mopidy.backends.base import (Backend, CurrentPlaylistController,
|
||||
PlaybackController, BasePlaybackProvider, LibraryController,
|
||||
BaseLibraryProvider, StoredPlaylistsController,
|
||||
BaseStoredPlaylistsProvider)
|
||||
from mopidy import core
|
||||
from mopidy.backends import base
|
||||
from mopidy.models import Playlist
|
||||
|
||||
|
||||
class DummyBackend(ThreadingActor, Backend):
|
||||
class DummyBackend(ThreadingActor, base.Backend):
|
||||
"""
|
||||
A backend which implements the backend API in the simplest way possible.
|
||||
Used in tests of the frontends.
|
||||
@ -18,24 +16,24 @@ class DummyBackend(ThreadingActor, Backend):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(DummyBackend, self).__init__(*args, **kwargs)
|
||||
|
||||
self.current_playlist = CurrentPlaylistController(backend=self)
|
||||
self.current_playlist = core.CurrentPlaylistController(backend=self)
|
||||
|
||||
library_provider = DummyLibraryProvider(backend=self)
|
||||
self.library = LibraryController(backend=self,
|
||||
self.library = core.LibraryController(backend=self,
|
||||
provider=library_provider)
|
||||
|
||||
playback_provider = DummyPlaybackProvider(backend=self)
|
||||
self.playback = PlaybackController(backend=self,
|
||||
self.playback = core.PlaybackController(backend=self,
|
||||
provider=playback_provider)
|
||||
|
||||
stored_playlists_provider = DummyStoredPlaylistsProvider(backend=self)
|
||||
self.stored_playlists = StoredPlaylistsController(backend=self,
|
||||
self.stored_playlists = core.StoredPlaylistsController(backend=self,
|
||||
provider=stored_playlists_provider)
|
||||
|
||||
self.uri_schemes = [u'dummy']
|
||||
|
||||
|
||||
class DummyLibraryProvider(BaseLibraryProvider):
|
||||
class DummyLibraryProvider(base.BaseLibraryProvider):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(DummyLibraryProvider, self).__init__(*args, **kwargs)
|
||||
self.dummy_library = []
|
||||
@ -55,7 +53,11 @@ class DummyLibraryProvider(BaseLibraryProvider):
|
||||
return Playlist()
|
||||
|
||||
|
||||
class DummyPlaybackProvider(BasePlaybackProvider):
|
||||
class DummyPlaybackProvider(base.BasePlaybackProvider):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(DummyPlaybackProvider, self).__init__(*args, **kwargs)
|
||||
self._volume = None
|
||||
|
||||
def pause(self):
|
||||
return True
|
||||
|
||||
@ -72,8 +74,14 @@ class DummyPlaybackProvider(BasePlaybackProvider):
|
||||
def stop(self):
|
||||
return True
|
||||
|
||||
def get_volume(self):
|
||||
return self._volume
|
||||
|
||||
class DummyStoredPlaylistsProvider(BaseStoredPlaylistsProvider):
|
||||
def set_volume(self, volume):
|
||||
self._volume = volume
|
||||
|
||||
|
||||
class DummyStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider):
|
||||
def create(self, name):
|
||||
playlist = Playlist(name=name)
|
||||
self._playlists.append(playlist)
|
||||
|
||||
@ -7,32 +7,19 @@ import shutil
|
||||
from pykka.actor import ThreadingActor
|
||||
from pykka.registry import ActorRegistry
|
||||
|
||||
from mopidy import settings, DATA_PATH
|
||||
from mopidy.backends.base import (Backend, CurrentPlaylistController,
|
||||
LibraryController, BaseLibraryProvider, PlaybackController,
|
||||
BasePlaybackProvider, StoredPlaylistsController,
|
||||
BaseStoredPlaylistsProvider)
|
||||
from mopidy import audio, core, settings
|
||||
from mopidy.backends import base
|
||||
from mopidy.models import Playlist, Track, Album
|
||||
from mopidy.gstreamer import GStreamer
|
||||
|
||||
from .translator import parse_m3u, parse_mpd_tag_cache
|
||||
|
||||
logger = logging.getLogger(u'mopidy.backends.local')
|
||||
|
||||
DEFAULT_PLAYLIST_PATH = os.path.join(DATA_PATH, 'playlists')
|
||||
DEFAULT_TAG_CACHE_FILE = os.path.join(DATA_PATH, 'tag_cache')
|
||||
DEFAULT_MUSIC_PATH = str(glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC))
|
||||
|
||||
if not DEFAULT_MUSIC_PATH or DEFAULT_MUSIC_PATH == os.path.expanduser(u'~'):
|
||||
DEFAULT_MUSIC_PATH = os.path.expanduser(u'~/music')
|
||||
|
||||
|
||||
class LocalBackend(ThreadingActor, Backend):
|
||||
class LocalBackend(ThreadingActor, base.Backend):
|
||||
"""
|
||||
A backend for playing music from a local music archive.
|
||||
|
||||
**Issues:** https://github.com/mopidy/mopidy/issues?labels=backend-local
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- None
|
||||
@ -47,32 +34,32 @@ class LocalBackend(ThreadingActor, Backend):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(LocalBackend, self).__init__(*args, **kwargs)
|
||||
|
||||
self.current_playlist = CurrentPlaylistController(backend=self)
|
||||
self.current_playlist = core.CurrentPlaylistController(backend=self)
|
||||
|
||||
library_provider = LocalLibraryProvider(backend=self)
|
||||
self.library = LibraryController(backend=self,
|
||||
self.library = core.LibraryController(backend=self,
|
||||
provider=library_provider)
|
||||
|
||||
playback_provider = LocalPlaybackProvider(backend=self)
|
||||
playback_provider = base.BasePlaybackProvider(backend=self)
|
||||
self.playback = LocalPlaybackController(backend=self,
|
||||
provider=playback_provider)
|
||||
|
||||
stored_playlists_provider = LocalStoredPlaylistsProvider(backend=self)
|
||||
self.stored_playlists = StoredPlaylistsController(backend=self,
|
||||
self.stored_playlists = core.StoredPlaylistsController(backend=self,
|
||||
provider=stored_playlists_provider)
|
||||
|
||||
self.uri_schemes = [u'file']
|
||||
|
||||
self.gstreamer = None
|
||||
self.audio = None
|
||||
|
||||
def on_start(self):
|
||||
gstreamer_refs = ActorRegistry.get_by_class(GStreamer)
|
||||
assert len(gstreamer_refs) == 1, \
|
||||
'Expected exactly one running GStreamer.'
|
||||
self.gstreamer = gstreamer_refs[0].proxy()
|
||||
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(PlaybackController):
|
||||
class LocalPlaybackController(core.PlaybackController):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(LocalPlaybackController, self).__init__(*args, **kwargs)
|
||||
|
||||
@ -81,32 +68,13 @@ class LocalPlaybackController(PlaybackController):
|
||||
|
||||
@property
|
||||
def time_position(self):
|
||||
return self.backend.gstreamer.get_position().get()
|
||||
return self.backend.audio.get_position().get()
|
||||
|
||||
|
||||
class LocalPlaybackProvider(BasePlaybackProvider):
|
||||
def pause(self):
|
||||
return self.backend.gstreamer.pause_playback().get()
|
||||
|
||||
def play(self, track):
|
||||
self.backend.gstreamer.prepare_change()
|
||||
self.backend.gstreamer.set_uri(track.uri).get()
|
||||
return self.backend.gstreamer.start_playback().get()
|
||||
|
||||
def resume(self):
|
||||
return self.backend.gstreamer.start_playback().get()
|
||||
|
||||
def seek(self, time_position):
|
||||
return self.backend.gstreamer.set_position(time_position).get()
|
||||
|
||||
def stop(self):
|
||||
return self.backend.gstreamer.stop_playback().get()
|
||||
|
||||
|
||||
class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider):
|
||||
class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs)
|
||||
self._folder = settings.LOCAL_PLAYLIST_PATH or DEFAULT_PLAYLIST_PATH
|
||||
self._folder = settings.LOCAL_PLAYLIST_PATH
|
||||
self.refresh()
|
||||
|
||||
def lookup(self, uri):
|
||||
@ -118,9 +86,9 @@ class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider):
|
||||
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')]
|
||||
name = os.path.basename(m3u)[:-len('.m3u')]
|
||||
tracks = []
|
||||
for uri in parse_m3u(m3u):
|
||||
for uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH):
|
||||
try:
|
||||
tracks.append(self.backend.library.lookup(uri))
|
||||
except LookupError, e:
|
||||
@ -176,19 +144,18 @@ class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider):
|
||||
self._playlists.append(playlist)
|
||||
|
||||
|
||||
class LocalLibraryProvider(BaseLibraryProvider):
|
||||
class LocalLibraryProvider(base.BaseLibraryProvider):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(LocalLibraryProvider, self).__init__(*args, **kwargs)
|
||||
self._uri_mapping = {}
|
||||
self.refresh()
|
||||
|
||||
def refresh(self, uri=None):
|
||||
tag_cache = settings.LOCAL_TAG_CACHE_FILE or DEFAULT_TAG_CACHE_FILE
|
||||
music_folder = settings.LOCAL_MUSIC_PATH or DEFAULT_MUSIC_PATH
|
||||
tracks = parse_mpd_tag_cache(settings.LOCAL_TAG_CACHE_FILE,
|
||||
settings.LOCAL_MUSIC_PATH)
|
||||
|
||||
tracks = parse_mpd_tag_cache(tag_cache, music_folder)
|
||||
|
||||
logger.info('Loading tracks in %s from %s', music_folder, tag_cache)
|
||||
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
|
||||
@ -197,7 +164,8 @@ class LocalLibraryProvider(BaseLibraryProvider):
|
||||
try:
|
||||
return self._uri_mapping[uri]
|
||||
except KeyError:
|
||||
raise LookupError('%s not found.' % uri)
|
||||
logger.debug(u'Failed to lookup "%s"', uri)
|
||||
return None
|
||||
|
||||
def find_exact(self, **query):
|
||||
self._validate_query(query)
|
||||
|
||||
@ -7,7 +7,7 @@ from mopidy.models import Track, Artist, Album
|
||||
from mopidy.utils import locale_decode
|
||||
from mopidy.utils.path import path_to_uri
|
||||
|
||||
def parse_m3u(file_path):
|
||||
def parse_m3u(file_path, music_folder):
|
||||
"""
|
||||
Convert M3U file list of uris
|
||||
|
||||
@ -29,8 +29,6 @@ def parse_m3u(file_path):
|
||||
"""
|
||||
|
||||
uris = []
|
||||
folder = os.path.dirname(file_path)
|
||||
|
||||
try:
|
||||
with open(file_path) as m3u:
|
||||
contents = m3u.readlines()
|
||||
@ -48,7 +46,7 @@ def parse_m3u(file_path):
|
||||
if line.startswith('file://'):
|
||||
uris.append(line)
|
||||
else:
|
||||
path = path_to_uri(folder, line)
|
||||
path = path_to_uri(music_folder, line)
|
||||
uris.append(path)
|
||||
|
||||
return uris
|
||||
|
||||
@ -3,16 +3,14 @@ import logging
|
||||
from pykka.actor import ThreadingActor
|
||||
from pykka.registry import ActorRegistry
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.backends.base import (Backend, CurrentPlaylistController,
|
||||
LibraryController, PlaybackController, StoredPlaylistsController)
|
||||
from mopidy.gstreamer import GStreamer
|
||||
from mopidy import audio, core, settings
|
||||
from mopidy.backends import base
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.spotify')
|
||||
|
||||
BITRATES = {96: 2, 160: 0, 320: 1}
|
||||
|
||||
class SpotifyBackend(ThreadingActor, Backend):
|
||||
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
|
||||
@ -51,24 +49,24 @@ class SpotifyBackend(ThreadingActor, Backend):
|
||||
|
||||
super(SpotifyBackend, self).__init__(*args, **kwargs)
|
||||
|
||||
self.current_playlist = CurrentPlaylistController(backend=self)
|
||||
self.current_playlist = core.CurrentPlaylistController(backend=self)
|
||||
|
||||
library_provider = SpotifyLibraryProvider(backend=self)
|
||||
self.library = LibraryController(backend=self,
|
||||
self.library = core.LibraryController(backend=self,
|
||||
provider=library_provider)
|
||||
|
||||
playback_provider = SpotifyPlaybackProvider(backend=self)
|
||||
self.playback = PlaybackController(backend=self,
|
||||
self.playback = core.PlaybackController(backend=self,
|
||||
provider=playback_provider)
|
||||
|
||||
stored_playlists_provider = SpotifyStoredPlaylistsProvider(
|
||||
backend=self)
|
||||
self.stored_playlists = StoredPlaylistsController(backend=self,
|
||||
self.stored_playlists = core.StoredPlaylistsController(backend=self,
|
||||
provider=stored_playlists_provider)
|
||||
|
||||
self.uri_schemes = [u'spotify']
|
||||
|
||||
self.gstreamer = None
|
||||
self.audio = None
|
||||
self.spotify = None
|
||||
|
||||
# Fail early if settings are not present
|
||||
@ -76,10 +74,10 @@ class SpotifyBackend(ThreadingActor, Backend):
|
||||
self.password = settings.SPOTIFY_PASSWORD
|
||||
|
||||
def on_start(self):
|
||||
gstreamer_refs = ActorRegistry.get_by_class(GStreamer)
|
||||
assert len(gstreamer_refs) == 1, \
|
||||
'Expected exactly one running GStreamer.'
|
||||
self.gstreamer = gstreamer_refs[0].proxy()
|
||||
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()
|
||||
|
||||
@ -5,21 +5,55 @@ from spotify import Link, SpotifyError
|
||||
|
||||
from mopidy.backends.base import BaseLibraryProvider
|
||||
from mopidy.backends.spotify.translator import SpotifyTranslator
|
||||
from mopidy.models import Playlist
|
||||
from mopidy.models import Track, Playlist
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.spotify.library')
|
||||
|
||||
|
||||
class SpotifyTrack(Track):
|
||||
"""Proxy object for unloaded Spotify tracks."""
|
||||
def __init__(self, uri):
|
||||
self._spotify_track = Link.from_string(uri).as_track()
|
||||
self._unloaded_track = Track(uri=uri, name=u'[loading...]')
|
||||
self._track = None
|
||||
|
||||
@property
|
||||
def _proxy(self):
|
||||
if self._track:
|
||||
return self._track
|
||||
elif self._spotify_track.is_loaded():
|
||||
self._track = SpotifyTranslator.to_mopidy_track(self._spotify_track)
|
||||
return self._track
|
||||
else:
|
||||
return self._unloaded_track
|
||||
|
||||
def __getattribute__(self, name):
|
||||
if name.startswith('_'):
|
||||
return super(SpotifyTrack, self).__getattribute__(name)
|
||||
return self._proxy.__getattribute__(name)
|
||||
|
||||
def __repr__(self):
|
||||
return self._proxy.__repr__()
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self._proxy.uri)
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, Track):
|
||||
return False
|
||||
return self._proxy.uri == other.uri
|
||||
|
||||
def copy(self, **values):
|
||||
return self._proxy.copy(**values)
|
||||
|
||||
|
||||
class SpotifyLibraryProvider(BaseLibraryProvider):
|
||||
def find_exact(self, **query):
|
||||
return self.search(**query)
|
||||
|
||||
def lookup(self, uri):
|
||||
try:
|
||||
spotify_track = Link.from_string(uri).as_track()
|
||||
# TODO Block until metadata_updated callback is called. Before that
|
||||
# the track will be unloaded, unless it's already in the stored
|
||||
# playlists.
|
||||
return SpotifyTranslator.to_mopidy_track(spotify_track)
|
||||
return SpotifyTrack(uri)
|
||||
except SpotifyError as e:
|
||||
logger.debug(u'Failed to lookup "%s": %s', uri, e)
|
||||
return None
|
||||
|
||||
@ -3,15 +3,13 @@ import logging
|
||||
from spotify import Link, SpotifyError
|
||||
|
||||
from mopidy.backends.base import BasePlaybackProvider
|
||||
from mopidy.core import PlaybackState
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.spotify.playback')
|
||||
|
||||
class SpotifyPlaybackProvider(BasePlaybackProvider):
|
||||
def pause(self):
|
||||
return self.backend.gstreamer.pause_playback()
|
||||
|
||||
def play(self, track):
|
||||
if self.backend.playback.state == self.backend.playback.PLAYING:
|
||||
if self.backend.playback.state == PlaybackState.PLAYING:
|
||||
self.backend.spotify.session.play(0)
|
||||
if track.uri is None:
|
||||
return False
|
||||
@ -19,10 +17,10 @@ class SpotifyPlaybackProvider(BasePlaybackProvider):
|
||||
self.backend.spotify.session.load(
|
||||
Link.from_string(track.uri).as_track())
|
||||
self.backend.spotify.session.play(1)
|
||||
self.backend.gstreamer.prepare_change()
|
||||
self.backend.gstreamer.set_uri('appsrc://')
|
||||
self.backend.gstreamer.start_playback()
|
||||
self.backend.gstreamer.set_metadata(track)
|
||||
self.backend.audio.prepare_change()
|
||||
self.backend.audio.set_uri('appsrc://')
|
||||
self.backend.audio.start_playback()
|
||||
self.backend.audio.set_metadata(track)
|
||||
return True
|
||||
except SpotifyError as e:
|
||||
logger.info('Playback of %s failed: %s', track.uri, e)
|
||||
@ -32,12 +30,11 @@ class SpotifyPlaybackProvider(BasePlaybackProvider):
|
||||
return self.seek(self.backend.playback.time_position)
|
||||
|
||||
def seek(self, time_position):
|
||||
self.backend.gstreamer.prepare_change()
|
||||
self.backend.audio.prepare_change()
|
||||
self.backend.spotify.session.seek(time_position)
|
||||
self.backend.gstreamer.start_playback()
|
||||
self.backend.audio.start_playback()
|
||||
return True
|
||||
|
||||
def stop(self):
|
||||
result = self.backend.gstreamer.stop_playback()
|
||||
self.backend.spotify.session.play(0)
|
||||
return result
|
||||
return super(SpotifyPlaybackProvider, self).stop()
|
||||
|
||||
@ -6,14 +6,13 @@ from spotify.manager import SpotifySessionManager as PyspotifySessionManager
|
||||
|
||||
from pykka.registry import ActorRegistry
|
||||
|
||||
from mopidy import get_version, settings, CACHE_PATH
|
||||
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.gstreamer import GStreamer
|
||||
from mopidy.utils.process import BaseThread
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.spotify.session_manager')
|
||||
@ -23,8 +22,7 @@ logger = logging.getLogger('mopidy.backends.spotify.session_manager')
|
||||
|
||||
|
||||
class SpotifySessionManager(BaseThread, PyspotifySessionManager):
|
||||
cache_location = (settings.SPOTIFY_CACHE_PATH
|
||||
or os.path.join(CACHE_PATH, 'spotify'))
|
||||
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()
|
||||
@ -34,7 +32,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
|
||||
BaseThread.__init__(self)
|
||||
self.name = 'SpotifyThread'
|
||||
|
||||
self.gstreamer = None
|
||||
self.audio = None
|
||||
self.backend = None
|
||||
|
||||
self.connected = threading.Event()
|
||||
@ -50,10 +48,10 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
|
||||
self.connect()
|
||||
|
||||
def setup(self):
|
||||
gstreamer_refs = ActorRegistry.get_by_class(GStreamer)
|
||||
assert len(gstreamer_refs) == 1, \
|
||||
'Expected exactly one running gstreamer.'
|
||||
self.gstreamer = gstreamer_refs[0].proxy()
|
||||
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.'
|
||||
@ -117,7 +115,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
|
||||
'sample_rate': sample_rate,
|
||||
'channels': channels,
|
||||
}
|
||||
self.gstreamer.emit_data(capabilites, bytes(frames))
|
||||
self.audio.emit_data(capabilites, bytes(frames))
|
||||
return num_frames
|
||||
|
||||
def play_token_lost(self, session):
|
||||
@ -143,7 +141,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
|
||||
def end_of_track(self, session):
|
||||
"""Callback used by pyspotify"""
|
||||
logger.debug(u'End of data stream reached')
|
||||
self.gstreamer.emit_end_of_stream()
|
||||
self.audio.emit_end_of_stream()
|
||||
|
||||
def refresh_stored_playlists(self):
|
||||
"""Refresh the stored playlists in the backend with fresh meta data
|
||||
@ -155,7 +153,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
|
||||
self.session.playlist_container())
|
||||
playlists = filter(None, playlists)
|
||||
self.backend.stored_playlists.playlists = playlists
|
||||
logger.debug(u'Refreshed %d stored playlist(s)', len(playlists))
|
||||
logger.info(u'Loaded %d Spotify playlist(s)', len(playlists))
|
||||
|
||||
def search(self, query, queue):
|
||||
"""Search method used by Mopidy backend"""
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import datetime as dt
|
||||
import logging
|
||||
|
||||
from spotify import Link, SpotifyError
|
||||
@ -31,9 +30,8 @@ class SpotifyTranslator(object):
|
||||
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()
|
||||
and dt.MINYEAR <= int(spotify_album.year()) <= dt.MAXYEAR):
|
||||
date = dt.date(spotify_album.year(), 1, 1)
|
||||
if spotify_album is not None and spotify_album.is_loaded():
|
||||
date = spotify_album.year()
|
||||
else:
|
||||
date = None
|
||||
return Track(
|
||||
|
||||
132
mopidy/core.py
132
mopidy/core.py
@ -1,132 +0,0 @@
|
||||
import logging
|
||||
import optparse
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
|
||||
import gobject
|
||||
gobject.threads_init()
|
||||
|
||||
# Extract any non-GStreamer arguments, and leave the GStreamer arguments for
|
||||
# processing by GStreamer. This needs to be done before GStreamer is imported,
|
||||
# so that GStreamer doesn't hijack e.g. ``--help``.
|
||||
# NOTE This naive fix does not support values like ``bar`` in
|
||||
# ``--gst-foo bar``. Use equals to pass values, like ``--gst-foo=bar``.
|
||||
def is_gst_arg(argument):
|
||||
return argument.startswith('--gst') or argument == '--help-gst'
|
||||
gstreamer_args = [arg for arg in sys.argv[1:] if is_gst_arg(arg)]
|
||||
mopidy_args = [arg for arg in sys.argv[1:] if not is_gst_arg(arg)]
|
||||
sys.argv[1:] = gstreamer_args
|
||||
|
||||
from mopidy import (get_version, settings, OptionalDependencyError,
|
||||
SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE)
|
||||
from mopidy.gstreamer import GStreamer
|
||||
from mopidy.utils import get_class
|
||||
from mopidy.utils.log import setup_logging
|
||||
from mopidy.utils.path import get_or_create_folder, get_or_create_file
|
||||
from mopidy.utils.process import (exit_handler, stop_remaining_actors,
|
||||
stop_actors_by_class)
|
||||
from mopidy.utils.settings import list_settings_optparse_callback
|
||||
|
||||
logger = logging.getLogger('mopidy.core')
|
||||
|
||||
def main():
|
||||
signal.signal(signal.SIGTERM, exit_handler)
|
||||
loop = gobject.MainLoop()
|
||||
try:
|
||||
options = parse_options()
|
||||
setup_logging(options.verbosity_level, options.save_debug_log)
|
||||
check_old_folders()
|
||||
setup_settings(options.interactive)
|
||||
setup_gstreamer()
|
||||
setup_mixer()
|
||||
setup_backend()
|
||||
setup_frontends()
|
||||
loop.run()
|
||||
except SettingsError as e:
|
||||
logger.error(e.message)
|
||||
except KeyboardInterrupt:
|
||||
logger.info(u'Interrupted. Exiting...')
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
finally:
|
||||
loop.quit()
|
||||
stop_frontends()
|
||||
stop_backend()
|
||||
stop_mixer()
|
||||
stop_gstreamer()
|
||||
stop_remaining_actors()
|
||||
|
||||
def parse_options():
|
||||
parser = optparse.OptionParser(version=u'Mopidy %s' % get_version())
|
||||
parser.add_option('--help-gst',
|
||||
action='store_true', dest='help_gst',
|
||||
help='show GStreamer help options')
|
||||
parser.add_option('-i', '--interactive',
|
||||
action='store_true', dest='interactive',
|
||||
help='ask interactively for required settings which are missing')
|
||||
parser.add_option('-q', '--quiet',
|
||||
action='store_const', const=0, dest='verbosity_level',
|
||||
help='less output (warning level)')
|
||||
parser.add_option('-v', '--verbose',
|
||||
action='count', default=1, dest='verbosity_level',
|
||||
help='more output (debug level)')
|
||||
parser.add_option('--save-debug-log',
|
||||
action='store_true', dest='save_debug_log',
|
||||
help='save debug log to "./mopidy.log"')
|
||||
parser.add_option('--list-settings',
|
||||
action='callback', callback=list_settings_optparse_callback,
|
||||
help='list current settings')
|
||||
return parser.parse_args(args=mopidy_args)[0]
|
||||
|
||||
def check_old_folders():
|
||||
old_settings_folder = os.path.expanduser(u'~/.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)
|
||||
|
||||
def setup_settings(interactive):
|
||||
get_or_create_folder(SETTINGS_PATH)
|
||||
get_or_create_folder(DATA_PATH)
|
||||
get_or_create_file(SETTINGS_FILE)
|
||||
try:
|
||||
settings.validate(interactive)
|
||||
except SettingsError, e:
|
||||
logger.error(e.message)
|
||||
sys.exit(1)
|
||||
|
||||
def setup_gstreamer():
|
||||
GStreamer.start()
|
||||
|
||||
def stop_gstreamer():
|
||||
stop_actors_by_class(GStreamer)
|
||||
|
||||
def setup_mixer():
|
||||
get_class(settings.MIXER).start()
|
||||
|
||||
def stop_mixer():
|
||||
stop_actors_by_class(get_class(settings.MIXER))
|
||||
|
||||
def setup_backend():
|
||||
get_class(settings.BACKENDS[0]).start()
|
||||
|
||||
def stop_backend():
|
||||
stop_actors_by_class(get_class(settings.BACKENDS[0]))
|
||||
|
||||
def setup_frontends():
|
||||
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)
|
||||
|
||||
def stop_frontends():
|
||||
for frontend_class_name in settings.FRONTENDS:
|
||||
try:
|
||||
stop_actors_by_class(get_class(frontend_class_name))
|
||||
except OptionalDependencyError:
|
||||
pass
|
||||
4
mopidy/core/__init__.py
Normal file
4
mopidy/core/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from .current_playlist import CurrentPlaylistController
|
||||
from .library import LibraryController
|
||||
from .playback import PlaybackController, PlaybackState
|
||||
from .stored_playlists import StoredPlaylistsController
|
||||
@ -5,7 +5,9 @@ import random
|
||||
from mopidy.listeners import BackendListener
|
||||
from mopidy.models import CpTrack
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.base')
|
||||
|
||||
logger = logging.getLogger('mopidy.core')
|
||||
|
||||
|
||||
class CurrentPlaylistController(object):
|
||||
"""
|
||||
70
mopidy/core/library.py
Normal file
70
mopidy/core/library.py
Normal file
@ -0,0 +1,70 @@
|
||||
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`
|
||||
"""
|
||||
|
||||
pykka_traversable = True
|
||||
|
||||
def __init__(self, backend, provider):
|
||||
self.backend = backend
|
||||
self.provider = provider
|
||||
|
||||
def find_exact(self, **query):
|
||||
"""
|
||||
Search the library for tracks where ``field`` is ``values``.
|
||||
|
||||
Examples::
|
||||
|
||||
# Returns results matching 'a'
|
||||
find_exact(any=['a'])
|
||||
# Returns results matching artist 'xyz'
|
||||
find_exact(artist=['xyz'])
|
||||
# Returns results matching 'a' and 'b' and artist 'xyz'
|
||||
find_exact(any=['a', 'b'], artist=['xyz'])
|
||||
|
||||
:param query: one or more queries to search for
|
||||
:type query: dict
|
||||
:rtype: :class:`mopidy.models.Playlist`
|
||||
"""
|
||||
return self.provider.find_exact(**query)
|
||||
|
||||
def lookup(self, uri):
|
||||
"""
|
||||
Lookup track with given URI. Returns :class:`None` if not found.
|
||||
|
||||
:param uri: track URI
|
||||
:type uri: string
|
||||
:rtype: :class:`mopidy.models.Track` or :class:`None`
|
||||
"""
|
||||
return self.provider.lookup(uri)
|
||||
|
||||
def refresh(self, uri=None):
|
||||
"""
|
||||
Refresh library. Limit to URI and below if an URI is given.
|
||||
|
||||
:param uri: directory or track URI
|
||||
:type uri: string
|
||||
"""
|
||||
return self.provider.refresh(uri)
|
||||
|
||||
def search(self, **query):
|
||||
"""
|
||||
Search the library for tracks where ``field`` contains ``values``.
|
||||
|
||||
Examples::
|
||||
|
||||
# Returns results matching 'a'
|
||||
search(any=['a'])
|
||||
# Returns results matching artist 'xyz'
|
||||
search(artist=['xyz'])
|
||||
# Returns results matching 'a' and 'b' and artist 'xyz'
|
||||
search(any=['a', 'b'], artist=['xyz'])
|
||||
|
||||
:param query: one or more queries to search for
|
||||
:type query: dict
|
||||
:rtype: :class:`mopidy.models.Playlist`
|
||||
"""
|
||||
return self.provider.search(**query)
|
||||
557
mopidy/core/playback.py
Normal file
557
mopidy/core/playback.py
Normal file
@ -0,0 +1,557 @@
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
|
||||
from mopidy.listeners import BackendListener
|
||||
|
||||
|
||||
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'
|
||||
|
||||
|
||||
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)
|
||||
|
||||
#: 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
|
||||
|
||||
def _get_cpid(self, cp_track):
|
||||
if cp_track is None:
|
||||
return None
|
||||
return cp_track.cpid
|
||||
|
||||
def _get_track(self, cp_track):
|
||||
if cp_track is None:
|
||||
return None
|
||||
return cp_track.track
|
||||
|
||||
@property
|
||||
def current_cpid(self):
|
||||
"""
|
||||
The CPID (current playlist ID) of the currently playing or selected
|
||||
track.
|
||||
|
||||
Read-only. Extracted from :attr:`current_cp_track` for convenience.
|
||||
"""
|
||||
return self._get_cpid(self.current_cp_track)
|
||||
|
||||
@property
|
||||
def current_track(self):
|
||||
"""
|
||||
The currently playing or selected :class:`mopidy.models.Track`.
|
||||
|
||||
Read-only. Extracted from :attr:`current_cp_track` for convenience.
|
||||
"""
|
||||
return self._get_track(self.current_cp_track)
|
||||
|
||||
@property
|
||||
def current_playlist_position(self):
|
||||
"""
|
||||
The position of the current track in the current playlist.
|
||||
|
||||
Read-only.
|
||||
"""
|
||||
if self.current_cp_track is None:
|
||||
return None
|
||||
try:
|
||||
return self.backend.current_playlist.cp_tracks.index(
|
||||
self.current_cp_track)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def track_at_eot(self):
|
||||
"""
|
||||
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_eot` for convenience.
|
||||
"""
|
||||
return self._get_track(self.cp_track_at_eot)
|
||||
|
||||
@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`.
|
||||
"""
|
||||
# pylint: disable = R0911
|
||||
# Too many return statements
|
||||
|
||||
cp_tracks = self.backend.current_playlist.cp_tracks
|
||||
|
||||
if not cp_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
|
||||
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.repeat and self.single:
|
||||
return cp_tracks[self.current_playlist_position]
|
||||
|
||||
if self.repeat and not self.single:
|
||||
return cp_tracks[
|
||||
(self.current_playlist_position + 1) % len(cp_tracks)]
|
||||
|
||||
try:
|
||||
return cp_tracks[self.current_playlist_position + 1]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def track_at_next(self):
|
||||
"""
|
||||
The track that will be played if calling :meth:`next()`.
|
||||
|
||||
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)
|
||||
|
||||
@property
|
||||
def cp_track_at_next(self):
|
||||
"""
|
||||
The track that will be played if calling :meth:`next()`.
|
||||
|
||||
Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`).
|
||||
|
||||
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:
|
||||
return None
|
||||
|
||||
if self.random and not self._shuffled:
|
||||
if self.repeat or self._first_shuffle:
|
||||
logger.debug('Shuffling tracks')
|
||||
self._shuffled = cp_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.repeat:
|
||||
return cp_tracks[
|
||||
(self.current_playlist_position + 1) % len(cp_tracks)]
|
||||
|
||||
try:
|
||||
return cp_tracks[self.current_playlist_position + 1]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def track_at_previous(self):
|
||||
"""
|
||||
The track that will be played if calling :meth:`previous()`.
|
||||
|
||||
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)
|
||||
|
||||
@property
|
||||
def cp_track_at_previous(self):
|
||||
"""
|
||||
The track that will be played if calling :meth:`previous()`.
|
||||
|
||||
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.
|
||||
"""
|
||||
if self.repeat or self.consume or self.random:
|
||||
return self.current_cp_track
|
||||
|
||||
if self.current_playlist_position in (None, 0):
|
||||
return None
|
||||
|
||||
return self.backend.current_playlist.cp_tracks[
|
||||
self.current_playlist_position - 1]
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""
|
||||
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" ]
|
||||
"""
|
||||
return self._state
|
||||
|
||||
@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)
|
||||
|
||||
self._trigger_playback_state_changed()
|
||||
|
||||
# FIXME play_time stuff assumes backend does not have a better way of
|
||||
# handeling this stuff :/
|
||||
if (old_state in (PlaybackState.PLAYING, PlaybackState.STOPPED)
|
||||
and new_state == PlaybackState.PLAYING):
|
||||
self._play_time_start()
|
||||
elif (old_state == PlaybackState.PLAYING
|
||||
and new_state == PlaybackState.PAUSED):
|
||||
self._play_time_pause()
|
||||
elif (old_state == PlaybackState.PAUSED
|
||||
and new_state == PlaybackState.PLAYING):
|
||||
self._play_time_resume()
|
||||
|
||||
@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):
|
||||
"""
|
||||
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 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
|
||||
if old_state == PlaybackState.PLAYING:
|
||||
self.play(on_error_step=on_error_step)
|
||||
elif old_state == PlaybackState.PAUSED:
|
||||
self.pause()
|
||||
|
||||
def on_end_of_track(self):
|
||||
"""
|
||||
Tell the playback controller that end of track is reached.
|
||||
"""
|
||||
if self.state == PlaybackState.STOPPED:
|
||||
return
|
||||
|
||||
original_cp_track = self.current_cp_track
|
||||
|
||||
if self.cp_track_at_eot:
|
||||
self._trigger_track_playback_ended()
|
||||
self.play(self.cp_track_at_eot)
|
||||
else:
|
||||
self.stop(clear_current_track=True)
|
||||
|
||||
if self.consume:
|
||||
self.backend.current_playlist.remove(cpid=original_cp_track.cpid)
|
||||
|
||||
def on_current_playlist_change(self):
|
||||
"""
|
||||
Tell the playback controller that the current playlist has changed.
|
||||
|
||||
Used by :class:`mopidy.backends.base.CurrentPlaylistController`.
|
||||
"""
|
||||
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):
|
||||
self.stop(clear_current_track=True)
|
||||
|
||||
def next(self):
|
||||
"""
|
||||
Change to the next track.
|
||||
|
||||
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:
|
||||
self._trigger_track_playback_ended()
|
||||
self.change_track(self.cp_track_at_next)
|
||||
else:
|
||||
self.stop(clear_current_track=True)
|
||||
|
||||
def pause(self):
|
||||
"""Pause playback."""
|
||||
if self.provider.pause():
|
||||
self.state = PlaybackState.PAUSED
|
||||
self._trigger_track_playback_paused()
|
||||
|
||||
def play(self, cp_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 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 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
|
||||
|
||||
if cp_track is not None:
|
||||
self.current_cp_track = cp_track
|
||||
self.state = PlaybackState.PLAYING
|
||||
if not self.provider.play(cp_track.track):
|
||||
# Track is not playable
|
||||
if self.random and self._shuffled:
|
||||
self._shuffled.remove(cp_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)
|
||||
|
||||
self._trigger_track_playback_started()
|
||||
|
||||
def previous(self):
|
||||
"""
|
||||
Change to the previous track.
|
||||
|
||||
The current playback state will be kept. If it was playing, playing
|
||||
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)
|
||||
|
||||
def resume(self):
|
||||
"""If paused, resume playing the current track."""
|
||||
if self.state == PlaybackState.PAUSED and self.provider.resume():
|
||||
self.state = PlaybackState.PLAYING
|
||||
self._trigger_track_playback_resumed()
|
||||
|
||||
def seek(self, time_position):
|
||||
"""
|
||||
Seeks to time position given in milliseconds.
|
||||
|
||||
:param time_position: time position in milliseconds
|
||||
:type time_position: int
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
if not self.backend.current_playlist.tracks:
|
||||
return False
|
||||
|
||||
if self.state == PlaybackState.STOPPED:
|
||||
self.play()
|
||||
elif self.state == PlaybackState.PAUSED:
|
||||
self.resume()
|
||||
|
||||
if time_position < 0:
|
||||
time_position = 0
|
||||
elif time_position > self.current_track.length:
|
||||
self.next()
|
||||
return True
|
||||
|
||||
self.play_time_started = self._current_wall_time
|
||||
self.play_time_accumulated = time_position
|
||||
|
||||
success = self.provider.seek(time_position)
|
||||
if success:
|
||||
self._trigger_seeked()
|
||||
return success
|
||||
|
||||
def stop(self, clear_current_track=False):
|
||||
"""
|
||||
Stop playing.
|
||||
|
||||
:param clear_current_track: whether to clear the current track _after_
|
||||
stopping
|
||||
:type clear_current_track: boolean
|
||||
"""
|
||||
if self.state != PlaybackState.STOPPED:
|
||||
if self.provider.stop():
|
||||
self._trigger_track_playback_ended()
|
||||
self.state = PlaybackState.STOPPED
|
||||
if clear_current_track:
|
||||
self.current_cp_track = None
|
||||
|
||||
def _trigger_track_playback_paused(self):
|
||||
logger.debug(u'Triggering track playback paused event')
|
||||
if self.current_track is None:
|
||||
return
|
||||
BackendListener.send('track_playback_paused',
|
||||
track=self.current_track,
|
||||
time_position=self.time_position)
|
||||
|
||||
def _trigger_track_playback_resumed(self):
|
||||
logger.debug(u'Triggering track playback resumed event')
|
||||
if self.current_track is None:
|
||||
return
|
||||
BackendListener.send('track_playback_resumed',
|
||||
track=self.current_track,
|
||||
time_position=self.time_position)
|
||||
|
||||
def _trigger_track_playback_started(self):
|
||||
logger.debug(u'Triggering track playback started event')
|
||||
if self.current_track is None:
|
||||
return
|
||||
BackendListener.send('track_playback_started',
|
||||
track=self.current_track)
|
||||
|
||||
def _trigger_track_playback_ended(self):
|
||||
logger.debug(u'Triggering track playback ended event')
|
||||
if self.current_track is None:
|
||||
return
|
||||
BackendListener.send('track_playback_ended',
|
||||
track=self.current_track,
|
||||
time_position=self.time_position)
|
||||
|
||||
def _trigger_playback_state_changed(self):
|
||||
logger.debug(u'Triggering playback state change event')
|
||||
BackendListener.send('playback_state_changed')
|
||||
|
||||
def _trigger_options_changed(self):
|
||||
logger.debug(u'Triggering options changed event')
|
||||
BackendListener.send('options_changed')
|
||||
|
||||
def _trigger_seeked(self):
|
||||
logger.debug(u'Triggering seeked event')
|
||||
BackendListener.send('seeked')
|
||||
113
mopidy/core/stored_playlists.py
Normal file
113
mopidy/core/stored_playlists.py
Normal file
@ -0,0 +1,113 @@
|
||||
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)
|
||||
@ -15,7 +15,6 @@ from mopidy.frontends.mpd.protocol import (audio_output, command_list,
|
||||
connection, current_playlist, empty, music_db, playback, reflection,
|
||||
status, stickers, stored_playlists)
|
||||
# pylint: enable = W0611
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
from mopidy.utils import flatten
|
||||
|
||||
logger = logging.getLogger('mopidy.frontends.mpd.dispatcher')
|
||||
@ -235,7 +234,6 @@ class MpdContext(object):
|
||||
self.events = set()
|
||||
self.subscriptions = set()
|
||||
self._backend = None
|
||||
self._mixer = None
|
||||
|
||||
@property
|
||||
def backend(self):
|
||||
@ -248,14 +246,3 @@ class MpdContext(object):
|
||||
'Expected exactly one running backend.'
|
||||
self._backend = backend_refs[0].proxy()
|
||||
return self._backend
|
||||
|
||||
@property
|
||||
def mixer(self):
|
||||
"""
|
||||
The mixer. An instance of :class:`mopidy.mixers.base.BaseMixer`.
|
||||
"""
|
||||
if self._mixer is None:
|
||||
mixer_refs = ActorRegistry.get_by_class(BaseMixer)
|
||||
assert len(mixer_refs) == 1, 'Expected exactly one running mixer.'
|
||||
self._mixer = mixer_refs[0].proxy()
|
||||
return self._mixer
|
||||
|
||||
@ -55,8 +55,7 @@ def addid(context, uri, songpos=None):
|
||||
track = context.backend.library.lookup(uri).get()
|
||||
if track is None:
|
||||
raise MpdNoExistError(u'No such song', command=u'addid')
|
||||
if songpos and songpos > len(
|
||||
context.backend.current_playlist.tracks.get()):
|
||||
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()
|
||||
@ -132,7 +131,7 @@ def move_range(context, start, to, end=None):
|
||||
``TO`` in the playlist.
|
||||
"""
|
||||
if end is None:
|
||||
end = len(context.backend.current_playlist.tracks.get())
|
||||
end = context.backend.current_playlist.length.get()
|
||||
start = int(start)
|
||||
end = int(end)
|
||||
to = int(to)
|
||||
@ -244,7 +243,7 @@ def playlistinfo(context, songpos=None,
|
||||
"""
|
||||
if songpos is not None:
|
||||
songpos = int(songpos)
|
||||
cp_track = context.backend.current_playlist.get(cpid=songpos).get()
|
||||
cp_track = context.backend.current_playlist.cp_tracks.get()[songpos]
|
||||
return track_to_mpd_format(cp_track, position=songpos)
|
||||
else:
|
||||
if start is None:
|
||||
|
||||
@ -193,10 +193,10 @@ def _list_build_query(field, mpd_query):
|
||||
# shlex does not seem to be friends with unicode objects
|
||||
tokens = shlex.split(mpd_query.encode('utf-8'))
|
||||
except ValueError as error:
|
||||
if error.message == 'No closing quotation':
|
||||
if str(error) == 'No closing quotation':
|
||||
raise MpdArgError(u'Invalid unquoted character', command=u'list')
|
||||
else:
|
||||
raise error
|
||||
raise
|
||||
tokens = [t.decode('utf-8') for t in tokens]
|
||||
if len(tokens) == 1:
|
||||
if field == u'album':
|
||||
@ -242,7 +242,7 @@ def _list_date(context, query):
|
||||
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.strftime('%Y-%m-%d')))
|
||||
dates.add((u'Date', track.date))
|
||||
return dates
|
||||
|
||||
@handle_request(r'^listall "(?P<uri>[^"]+)"')
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from mopidy.backends.base import PlaybackController
|
||||
from mopidy.core import PlaybackState
|
||||
from mopidy.frontends.mpd.protocol import handle_request
|
||||
from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError,
|
||||
MpdNotImplemented)
|
||||
@ -104,11 +104,9 @@ def pause(context, state=None):
|
||||
- Calls ``pause`` without any arguments to toogle pause.
|
||||
"""
|
||||
if state is None:
|
||||
if (context.backend.playback.state.get() ==
|
||||
PlaybackController.PLAYING):
|
||||
if (context.backend.playback.state.get() == PlaybackState.PLAYING):
|
||||
context.backend.playback.pause()
|
||||
elif (context.backend.playback.state.get() ==
|
||||
PlaybackController.PAUSED):
|
||||
elif (context.backend.playback.state.get() == PlaybackState.PAUSED):
|
||||
context.backend.playback.resume()
|
||||
elif int(state):
|
||||
context.backend.playback.pause()
|
||||
@ -123,8 +121,8 @@ def play(context):
|
||||
"""
|
||||
return context.backend.playback.play().get()
|
||||
|
||||
@handle_request(r'^playid "(?P<cpid>\d+)"$')
|
||||
@handle_request(r'^playid "(?P<cpid>-1)"$')
|
||||
@handle_request(r'^playid (?P<cpid>-?\d+)$')
|
||||
@handle_request(r'^playid "(?P<cpid>-?\d+)"$')
|
||||
def playid(context, cpid):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
@ -163,11 +161,11 @@ def playpos(context, songpos):
|
||||
|
||||
*Clarifications:*
|
||||
|
||||
- ``playid "-1"`` when playing is ignored.
|
||||
- ``playid "-1"`` when paused resumes playback.
|
||||
- ``playid "-1"`` when stopped with a current track starts playback at the
|
||||
- ``play "-1"`` when playing is ignored.
|
||||
- ``play "-1"`` when paused resumes playback.
|
||||
- ``play "-1"`` when stopped with a current track starts playback at the
|
||||
current track.
|
||||
- ``playid "-1"`` when stopped without a current track, e.g. after playlist
|
||||
- ``play "-1"`` when stopped without a current track, e.g. after playlist
|
||||
replacement, starts playback at the first track.
|
||||
|
||||
*BitMPC:*
|
||||
@ -185,9 +183,9 @@ def playpos(context, songpos):
|
||||
raise MpdArgError(u'Bad song index', command=u'play')
|
||||
|
||||
def _play_minus_one(context):
|
||||
if (context.backend.playback.state.get() == PlaybackController.PLAYING):
|
||||
if (context.backend.playback.state.get() == PlaybackState.PLAYING):
|
||||
return # Nothing to do
|
||||
elif (context.backend.playback.state.get() == PlaybackController.PAUSED):
|
||||
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()
|
||||
@ -353,7 +351,7 @@ def setvol(context, volume):
|
||||
volume = 0
|
||||
if volume > 100:
|
||||
volume = 100
|
||||
context.mixer.volume = volume
|
||||
context.backend.playback.volume = volume
|
||||
|
||||
@handle_request(r'^single (?P<state>[01])$')
|
||||
@handle_request(r'^single "(?P<state>[01])"$')
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import pykka.future
|
||||
|
||||
from mopidy.backends.base import PlaybackController
|
||||
from mopidy.core import PlaybackState
|
||||
from mopidy.frontends.mpd.exceptions import MpdNotImplemented
|
||||
from mopidy.frontends.mpd.protocol import handle_request
|
||||
from mopidy.frontends.mpd.translator import track_to_mpd_format
|
||||
@ -137,7 +137,7 @@ def status(context):
|
||||
|
||||
Reports the current status of the player and the volume level.
|
||||
|
||||
- ``volume``: 0-100
|
||||
- ``volume``: 0-100 or -1
|
||||
- ``repeat``: 0 or 1
|
||||
- ``single``: 0 or 1
|
||||
- ``consume``: 0 or 1
|
||||
@ -168,7 +168,7 @@ def status(context):
|
||||
futures = {
|
||||
'current_playlist.length': context.backend.current_playlist.length,
|
||||
'current_playlist.version': context.backend.current_playlist.version,
|
||||
'mixer.volume': context.mixer.volume,
|
||||
'playback.volume': context.backend.playback.volume,
|
||||
'playback.consume': context.backend.playback.consume,
|
||||
'playback.random': context.backend.playback.random,
|
||||
'playback.repeat': context.backend.playback.repeat,
|
||||
@ -194,8 +194,8 @@ def status(context):
|
||||
if futures['playback.current_cp_track'].get() is not None:
|
||||
result.append(('song', _status_songpos(futures)))
|
||||
result.append(('songid', _status_songid(futures)))
|
||||
if futures['playback.state'].get() in (PlaybackController.PLAYING,
|
||||
PlaybackController.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)))
|
||||
@ -239,11 +239,11 @@ def _status_songpos(futures):
|
||||
|
||||
def _status_state(futures):
|
||||
state = futures['playback.state'].get()
|
||||
if state == PlaybackController.PLAYING:
|
||||
if state == PlaybackState.PLAYING:
|
||||
return u'play'
|
||||
elif state == PlaybackController.STOPPED:
|
||||
elif state == PlaybackState.STOPPED:
|
||||
return u'stop'
|
||||
elif state == PlaybackController.PAUSED:
|
||||
elif state == PlaybackState.PAUSED:
|
||||
return u'pause'
|
||||
|
||||
def _status_time(futures):
|
||||
@ -263,11 +263,11 @@ def _status_time_total(futures):
|
||||
return current_cp_track.track.length
|
||||
|
||||
def _status_volume(futures):
|
||||
volume = futures['mixer.volume'].get()
|
||||
volume = futures['playback.volume'].get()
|
||||
if volume is not None:
|
||||
return volume
|
||||
else:
|
||||
return 0
|
||||
return -1
|
||||
|
||||
def _status_xfade(futures):
|
||||
return 0 # Not supported
|
||||
|
||||
@ -16,8 +16,7 @@ from pykka.registry import ActorRegistry
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.backends.base import Backend
|
||||
from mopidy.backends.base.playback import PlaybackController
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
from mopidy.core import PlaybackState
|
||||
from mopidy.utils.process import exit_process
|
||||
|
||||
# Must be done before dbus.SessionBus() is called
|
||||
@ -37,7 +36,6 @@ class MprisObject(dbus.service.Object):
|
||||
|
||||
def __init__(self):
|
||||
self._backend = None
|
||||
self._mixer = None
|
||||
self.properties = {
|
||||
ROOT_IFACE: self._get_root_iface_properties(),
|
||||
PLAYER_IFACE: self._get_player_iface_properties(),
|
||||
@ -95,14 +93,6 @@ class MprisObject(dbus.service.Object):
|
||||
self._backend = backend_refs[0].proxy()
|
||||
return self._backend
|
||||
|
||||
@property
|
||||
def mixer(self):
|
||||
if self._mixer is None:
|
||||
mixer_refs = ActorRegistry.get_by_class(BaseMixer)
|
||||
assert len(mixer_refs) == 1, 'Expected exactly one running mixer.'
|
||||
self._mixer = mixer_refs[0].proxy()
|
||||
return self._mixer
|
||||
|
||||
def _get_track_id(self, cp_track):
|
||||
return '/com/mopidy/track/%d' % cp_track.cpid
|
||||
|
||||
@ -208,11 +198,11 @@ class MprisObject(dbus.service.Object):
|
||||
logger.debug(u'%s.PlayPause not allowed', PLAYER_IFACE)
|
||||
return
|
||||
state = self.backend.playback.state.get()
|
||||
if state == PlaybackController.PLAYING:
|
||||
if state == PlaybackState.PLAYING:
|
||||
self.backend.playback.pause().get()
|
||||
elif state == PlaybackController.PAUSED:
|
||||
elif state == PlaybackState.PAUSED:
|
||||
self.backend.playback.resume().get()
|
||||
elif state == PlaybackController.STOPPED:
|
||||
elif state == PlaybackState.STOPPED:
|
||||
self.backend.playback.play().get()
|
||||
|
||||
@dbus.service.method(dbus_interface=PLAYER_IFACE)
|
||||
@ -230,7 +220,7 @@ class MprisObject(dbus.service.Object):
|
||||
logger.debug(u'%s.Play not allowed', PLAYER_IFACE)
|
||||
return
|
||||
state = self.backend.playback.state.get()
|
||||
if state == PlaybackController.PAUSED:
|
||||
if state == PlaybackState.PAUSED:
|
||||
self.backend.playback.resume().get()
|
||||
else:
|
||||
self.backend.playback.play().get()
|
||||
@ -297,11 +287,11 @@ class MprisObject(dbus.service.Object):
|
||||
|
||||
def get_PlaybackStatus(self):
|
||||
state = self.backend.playback.state.get()
|
||||
if state == PlaybackController.PLAYING:
|
||||
if state == PlaybackState.PLAYING:
|
||||
return 'Playing'
|
||||
elif state == PlaybackController.PAUSED:
|
||||
elif state == PlaybackState.PAUSED:
|
||||
return 'Paused'
|
||||
elif state == PlaybackController.STOPPED:
|
||||
elif state == PlaybackState.STOPPED:
|
||||
return 'Stopped'
|
||||
|
||||
def get_LoopStatus(self):
|
||||
@ -380,9 +370,10 @@ class MprisObject(dbus.service.Object):
|
||||
return dbus.Dictionary(metadata, signature='sv')
|
||||
|
||||
def get_Volume(self):
|
||||
volume = self.mixer.volume.get()
|
||||
if volume is not None:
|
||||
return volume / 100.0
|
||||
volume = self.backend.playback.volume.get()
|
||||
if volume is None:
|
||||
return 0
|
||||
return volume / 100.0
|
||||
|
||||
def set_Volume(self, value):
|
||||
if not self.get_CanControl():
|
||||
@ -391,11 +382,11 @@ class MprisObject(dbus.service.Object):
|
||||
if value is None:
|
||||
return
|
||||
elif value < 0:
|
||||
self.mixer.volume = 0
|
||||
self.backend.playback.volume = 0
|
||||
elif value > 1:
|
||||
self.mixer.volume = 100
|
||||
self.backend.playback.volume = 100
|
||||
elif 0 <= value <= 1:
|
||||
self.mixer.volume = int(value * 100)
|
||||
self.backend.playback.volume = int(value * 100)
|
||||
|
||||
def get_Position(self):
|
||||
return self.backend.playback.time_position.get() * 1000
|
||||
|
||||
@ -1,396 +0,0 @@
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst
|
||||
|
||||
import logging
|
||||
|
||||
from pykka.actor import ThreadingActor
|
||||
from pykka.registry import ActorRegistry
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.utils import get_class
|
||||
from mopidy.backends.base import Backend
|
||||
|
||||
logger = logging.getLogger('mopidy.gstreamer')
|
||||
|
||||
|
||||
class GStreamer(ThreadingActor):
|
||||
"""
|
||||
Audio output through `GStreamer <http://gstreamer.freedesktop.org/>`_.
|
||||
|
||||
**Settings:**
|
||||
|
||||
- :attr:`mopidy.settings.OUTPUTS`
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super(GStreamer, self).__init__()
|
||||
self._default_caps = gst.Caps("""
|
||||
audio/x-raw-int,
|
||||
endianness=(int)1234,
|
||||
channels=(int)2,
|
||||
width=(int)16,
|
||||
depth=(int)16,
|
||||
signed=(boolean)true,
|
||||
rate=(int)44100""")
|
||||
self._pipeline = None
|
||||
self._source = None
|
||||
self._tee = None
|
||||
self._uridecodebin = None
|
||||
self._volume = None
|
||||
self._outputs = []
|
||||
self._handlers = {}
|
||||
|
||||
def on_start(self):
|
||||
self._setup_pipeline()
|
||||
self._setup_outputs()
|
||||
self._setup_message_processor()
|
||||
|
||||
def _setup_pipeline(self):
|
||||
description = ' ! '.join([
|
||||
'uridecodebin name=uri',
|
||||
'audioconvert name=convert',
|
||||
'volume name=volume',
|
||||
'tee name=tee'])
|
||||
|
||||
logger.debug(u'Setting up base GStreamer pipeline: %s', description)
|
||||
|
||||
self._pipeline = gst.parse_launch(description)
|
||||
self._tee = self._pipeline.get_by_name('tee')
|
||||
self._volume = self._pipeline.get_by_name('volume')
|
||||
self._uridecodebin = self._pipeline.get_by_name('uri')
|
||||
|
||||
self._uridecodebin.connect('notify::source', self._on_new_source)
|
||||
self._uridecodebin.connect('pad-added', self._on_new_pad,
|
||||
self._pipeline.get_by_name('convert').get_pad('sink'))
|
||||
|
||||
def _setup_outputs(self):
|
||||
for output in settings.OUTPUTS:
|
||||
get_class(output)(self).connect()
|
||||
|
||||
def _setup_message_processor(self):
|
||||
bus = self._pipeline.get_bus()
|
||||
bus.add_signal_watch()
|
||||
bus.connect('message', self._on_message)
|
||||
|
||||
def _on_new_source(self, element, pad):
|
||||
self._source = element.get_property('source')
|
||||
try:
|
||||
self._source.set_property('caps', self._default_caps)
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
def _on_new_pad(self, source, pad, target_pad):
|
||||
if not pad.is_linked():
|
||||
if target_pad.is_linked():
|
||||
target_pad.get_peer().unlink(target_pad)
|
||||
pad.link(target_pad)
|
||||
|
||||
def _on_message(self, bus, message):
|
||||
if message.src in self._handlers:
|
||||
if self._handlers[message.src](message):
|
||||
return # Message was handeled by output
|
||||
|
||||
if message.type == gst.MESSAGE_EOS:
|
||||
logger.debug(u'GStreamer signalled end-of-stream. '
|
||||
'Telling backend ...')
|
||||
self._get_backend().playback.on_end_of_track()
|
||||
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 _get_backend(self):
|
||||
backend_refs = ActorRegistry.get_by_class(Backend)
|
||||
assert len(backend_refs) == 1, 'Expected exactly one running backend.'
|
||||
return backend_refs[0].proxy()
|
||||
|
||||
def set_uri(self, uri):
|
||||
"""
|
||||
Set URI of audio to be played.
|
||||
|
||||
You *MUST* call :meth:`prepare_change` before calling this method.
|
||||
|
||||
:param uri: the URI to play
|
||||
:type uri: string
|
||||
"""
|
||||
self._uridecodebin.set_property('uri', uri)
|
||||
|
||||
def emit_data(self, capabilities, data):
|
||||
"""
|
||||
Call this to deliver raw audio data to be played.
|
||||
|
||||
:param capabilities: a GStreamer capabilities string
|
||||
:type capabilities: string
|
||||
:param data: raw audio data to be played
|
||||
"""
|
||||
caps = gst.caps_from_string(capabilities)
|
||||
buffer_ = gst.Buffer(buffer(data))
|
||||
buffer_.set_caps(caps)
|
||||
self._source.set_property('caps', caps)
|
||||
self._source.emit('push-buffer', buffer_)
|
||||
|
||||
def emit_end_of_stream(self):
|
||||
"""
|
||||
Put an end-of-stream token on the pipeline. This is typically used in
|
||||
combination with :meth:`emit_data`.
|
||||
|
||||
We will get a GStreamer message when the stream playback reaches the
|
||||
token, and can then do any end-of-stream related tasks.
|
||||
"""
|
||||
self._source.emit('end-of-stream')
|
||||
|
||||
def get_position(self):
|
||||
"""
|
||||
Get position in milliseconds.
|
||||
|
||||
:rtype: int
|
||||
"""
|
||||
if self._pipeline.get_state()[1] == gst.STATE_NULL:
|
||||
return 0
|
||||
try:
|
||||
position = self._pipeline.query_position(gst.FORMAT_TIME)[0]
|
||||
return position // gst.MSECOND
|
||||
except gst.QueryError, e:
|
||||
logger.error('time_position failed: %s', e)
|
||||
return 0
|
||||
|
||||
def set_position(self, position):
|
||||
"""
|
||||
Set position in milliseconds.
|
||||
|
||||
:param position: the position in milliseconds
|
||||
:type volume: int
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
self._pipeline.get_state() # block until state changes are done
|
||||
handeled = self._pipeline.seek_simple(gst.Format(gst.FORMAT_TIME),
|
||||
gst.SEEK_FLAG_FLUSH, position * gst.MSECOND)
|
||||
self._pipeline.get_state() # block until seek is done
|
||||
return handeled
|
||||
|
||||
def start_playback(self):
|
||||
"""
|
||||
Notify GStreamer that it should start playback.
|
||||
|
||||
:rtype: :class:`True` if successfull, else :class:`False`
|
||||
"""
|
||||
return self._set_state(gst.STATE_PLAYING)
|
||||
|
||||
def pause_playback(self):
|
||||
"""
|
||||
Notify GStreamer that it should pause playback.
|
||||
|
||||
:rtype: :class:`True` if successfull, else :class:`False`
|
||||
"""
|
||||
return self._set_state(gst.STATE_PAUSED)
|
||||
|
||||
def prepare_change(self):
|
||||
"""
|
||||
Notify GStreamer that we are about to change state of playback.
|
||||
|
||||
This function *MUST* be called before changing URIs or doing
|
||||
changes like updating data that is being pushed. The reason for this
|
||||
is that GStreamer will reset all its state when it changes to
|
||||
:attr:`gst.STATE_READY`.
|
||||
"""
|
||||
return self._set_state(gst.STATE_READY)
|
||||
|
||||
def stop_playback(self):
|
||||
"""
|
||||
Notify GStreamer that is should stop playback.
|
||||
|
||||
:rtype: :class:`True` if successfull, else :class:`False`
|
||||
"""
|
||||
return self._set_state(gst.STATE_NULL)
|
||||
|
||||
def _set_state(self, state):
|
||||
"""
|
||||
Internal method for setting the raw GStreamer state.
|
||||
|
||||
.. digraph:: gst_state_transitions
|
||||
|
||||
graph [rankdir="LR"];
|
||||
node [fontsize=10];
|
||||
|
||||
"NULL" -> "READY"
|
||||
"PAUSED" -> "PLAYING"
|
||||
"PAUSED" -> "READY"
|
||||
"PLAYING" -> "PAUSED"
|
||||
"READY" -> "NULL"
|
||||
"READY" -> "PAUSED"
|
||||
|
||||
:param state: State to set pipeline to. One of: `gst.STATE_NULL`,
|
||||
`gst.STATE_READY`, `gst.STATE_PAUSED` and `gst.STATE_PLAYING`.
|
||||
:type state: :class:`gst.State`
|
||||
:rtype: :class:`True` if successfull, else :class:`False`
|
||||
"""
|
||||
result = self._pipeline.set_state(state)
|
||||
if result == gst.STATE_CHANGE_FAILURE:
|
||||
logger.warning('Setting GStreamer state to %s: failed',
|
||||
state.value_name)
|
||||
return False
|
||||
elif result == gst.STATE_CHANGE_ASYNC:
|
||||
logger.debug('Setting GStreamer state to %s: async',
|
||||
state.value_name)
|
||||
return True
|
||||
else:
|
||||
logger.debug('Setting GStreamer state to %s: OK',
|
||||
state.value_name)
|
||||
return True
|
||||
|
||||
def get_volume(self):
|
||||
"""
|
||||
Get volume level of the GStreamer software mixer.
|
||||
|
||||
:rtype: int in range [0..100]
|
||||
"""
|
||||
return int(self._volume.get_property('volume') * 100)
|
||||
|
||||
def set_volume(self, volume):
|
||||
"""
|
||||
Set volume level of the GStreamer software mixer.
|
||||
|
||||
:param volume: the volume in the range [0..100]
|
||||
:type volume: int
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
self._volume.set_property('volume', volume / 100.0)
|
||||
return True
|
||||
|
||||
def set_metadata(self, track):
|
||||
"""
|
||||
Set track metadata for currently playing song.
|
||||
|
||||
Only needs to be called by sources such as `appsrc` which do not
|
||||
already inject tags in pipeline, e.g. when using :meth:`emit_data` to
|
||||
deliver raw audio data to GStreamer.
|
||||
|
||||
:param track: the current track
|
||||
:type track: :class:`mopidy.modes.Track`
|
||||
"""
|
||||
taglist = gst.TagList()
|
||||
artists = [a for a in (track.artists or []) if a.name]
|
||||
|
||||
# Default to blank data to trick shoutcast into clearing any previous
|
||||
# values it might have.
|
||||
taglist[gst.TAG_ARTIST] = u' '
|
||||
taglist[gst.TAG_TITLE] = u' '
|
||||
taglist[gst.TAG_ALBUM] = u' '
|
||||
|
||||
if artists:
|
||||
taglist[gst.TAG_ARTIST] = u', '.join([a.name for a in artists])
|
||||
|
||||
if track.name:
|
||||
taglist[gst.TAG_TITLE] = track.name
|
||||
|
||||
if track.album and track.album.name:
|
||||
taglist[gst.TAG_ALBUM] = track.album.name
|
||||
|
||||
event = gst.event_new_tag(taglist)
|
||||
self._pipeline.send_event(event)
|
||||
|
||||
def connect_output(self, output):
|
||||
"""
|
||||
Connect output to pipeline.
|
||||
|
||||
:param output: output to connect to the pipeline
|
||||
:type output: :class:`gst.Bin`
|
||||
"""
|
||||
self._pipeline.add(output)
|
||||
output.sync_state_with_parent() # Required to add to running pipe
|
||||
gst.element_link_many(self._tee, output)
|
||||
self._outputs.append(output)
|
||||
logger.debug('GStreamer added %s', output.get_name())
|
||||
|
||||
def list_outputs(self):
|
||||
"""
|
||||
Get list with the name of all active outputs.
|
||||
|
||||
:rtype: list of strings
|
||||
"""
|
||||
return [output.get_name() for output in self._outputs]
|
||||
|
||||
def remove_output(self, output):
|
||||
"""
|
||||
Remove output from our pipeline.
|
||||
|
||||
:param output: output to remove from the pipeline
|
||||
:type output: :class:`gst.Bin`
|
||||
"""
|
||||
if output not in self._outputs:
|
||||
raise LookupError('Ouput %s not present in pipeline'
|
||||
% output.get_name)
|
||||
teesrc = output.get_pad('sink').get_peer()
|
||||
handler = teesrc.add_event_probe(self._handle_event_probe)
|
||||
|
||||
struct = gst.Structure('mopidy-unlink-tee')
|
||||
struct.set_value('handler', handler)
|
||||
|
||||
event = gst.event_new_custom(gst.EVENT_CUSTOM_DOWNSTREAM, struct)
|
||||
self._tee.send_event(event)
|
||||
|
||||
def _handle_event_probe(self, teesrc, event):
|
||||
if (event.type == gst.EVENT_CUSTOM_DOWNSTREAM
|
||||
and event.has_name('mopidy-unlink-tee')):
|
||||
data = self._get_structure_data(event.get_structure())
|
||||
|
||||
output = teesrc.get_peer().get_parent()
|
||||
|
||||
teesrc.unlink(teesrc.get_peer())
|
||||
teesrc.remove_event_probe(data['handler'])
|
||||
|
||||
output.set_state(gst.STATE_NULL)
|
||||
self._pipeline.remove(output)
|
||||
|
||||
logger.warning('Removed %s', output.get_name())
|
||||
return False
|
||||
return True
|
||||
|
||||
def _get_structure_data(self, struct):
|
||||
# Ugly hack to get around missing get_value in pygst bindings :/
|
||||
data = {}
|
||||
def get_data(key, value):
|
||||
data[key] = value
|
||||
struct.foreach(get_data)
|
||||
return data
|
||||
|
||||
def connect_message_handler(self, element, handler):
|
||||
"""
|
||||
Attach custom message handler for given element.
|
||||
|
||||
Hook to allow outputs (or other code) to register custom message
|
||||
handlers for all messages coming from the element in question.
|
||||
|
||||
In the case of outputs, :meth:`mopidy.outputs.BaseOutput.on_connect`
|
||||
should be used to attach such handlers and care should be taken to
|
||||
remove them in :meth:`mopidy.outputs.BaseOutput.on_remove` using
|
||||
:meth:`remove_message_handler`.
|
||||
|
||||
The handler callback will only be given the message in question, and
|
||||
is free to ignore the message. However, if the handler wants to prevent
|
||||
the default handling of the message it should return :class:`True`
|
||||
indicating that the message has been handled.
|
||||
|
||||
Note that there can only be one handler per element.
|
||||
|
||||
:param element: element to watch messages from
|
||||
:type element: :class:`gst.Element`
|
||||
:param handler: callable that takes :class:`gst.Message` and returns
|
||||
:class:`True` if the message has been handeled
|
||||
:type handler: callable
|
||||
"""
|
||||
self._handlers[element] = handler
|
||||
|
||||
def remove_message_handler(self, element):
|
||||
"""
|
||||
Remove custom message handler.
|
||||
|
||||
:param element: element to remove message handling from.
|
||||
:type element: :class:`gst.Element`
|
||||
"""
|
||||
self._handlers.pop(element, None)
|
||||
@ -1,60 +0,0 @@
|
||||
import alsaaudio
|
||||
import logging
|
||||
|
||||
from pykka.actor import ThreadingActor
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
|
||||
logger = logging.getLogger('mopidy.mixers.alsa')
|
||||
|
||||
class AlsaMixer(ThreadingActor, BaseMixer):
|
||||
"""
|
||||
Mixer which uses the Advanced Linux Sound Architecture (ALSA) to control
|
||||
volume.
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- pyalsaaudio >= 0.2 (python-alsaaudio on Debian/Ubuntu)
|
||||
|
||||
**Settings:**
|
||||
|
||||
- :attr:`mopidy.settings.MIXER_ALSA_CONTROL`
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super(AlsaMixer, self).__init__()
|
||||
self._mixer = None
|
||||
|
||||
def on_start(self):
|
||||
self._mixer = alsaaudio.Mixer(self._get_mixer_control())
|
||||
assert self._mixer is not None
|
||||
|
||||
def _get_mixer_control(self):
|
||||
"""Returns the first mixer control candidate that is known to ALSA"""
|
||||
candidates = self._get_mixer_control_candidates()
|
||||
for control in candidates:
|
||||
if control in alsaaudio.mixers():
|
||||
logger.info(u'Mixer control in use: %s', control)
|
||||
return control
|
||||
else:
|
||||
logger.debug(u'Mixer control not found, skipping: %s', control)
|
||||
logger.warning(u'No working mixer controls found. Tried: %s',
|
||||
candidates)
|
||||
|
||||
def _get_mixer_control_candidates(self):
|
||||
"""
|
||||
A mixer named 'Master' does not always exist, so we fall back to using
|
||||
'PCM'. If this does not work for you, you may set
|
||||
:attr:`mopidy.settings.MIXER_ALSA_CONTROL`.
|
||||
"""
|
||||
if settings.MIXER_ALSA_CONTROL:
|
||||
return [settings.MIXER_ALSA_CONTROL]
|
||||
return [u'Master', u'PCM']
|
||||
|
||||
def get_volume(self):
|
||||
# FIXME does not seem to see external volume changes.
|
||||
return self._mixer.getvolume()[0]
|
||||
|
||||
def set_volume(self, volume):
|
||||
self._mixer.setvolume(volume)
|
||||
@ -1,68 +0,0 @@
|
||||
import logging
|
||||
|
||||
from mopidy import listeners, settings
|
||||
|
||||
logger = logging.getLogger('mopidy.mixers')
|
||||
|
||||
class BaseMixer(object):
|
||||
"""
|
||||
**Settings:**
|
||||
|
||||
- :attr:`mopidy.settings.MIXER_MAX_VOLUME`
|
||||
"""
|
||||
|
||||
amplification_factor = settings.MIXER_MAX_VOLUME / 100.0
|
||||
|
||||
@property
|
||||
def volume(self):
|
||||
"""
|
||||
The audio volume
|
||||
|
||||
Integer in range [0, 100]. :class:`None` if unknown. Values below 0 is
|
||||
equal to 0. Values above 100 is equal to 100.
|
||||
"""
|
||||
if not hasattr(self, '_user_volume'):
|
||||
self._user_volume = 0
|
||||
volume = self.get_volume()
|
||||
if volume is None or not self.amplification_factor < 1:
|
||||
return volume
|
||||
else:
|
||||
user_volume = int(volume / self.amplification_factor)
|
||||
if (user_volume - 1) <= self._user_volume <= (user_volume + 1):
|
||||
return self._user_volume
|
||||
else:
|
||||
return user_volume
|
||||
|
||||
@volume.setter
|
||||
def volume(self, volume):
|
||||
if not hasattr(self, '_user_volume'):
|
||||
self._user_volume = 0
|
||||
volume = int(volume)
|
||||
if volume < 0:
|
||||
volume = 0
|
||||
elif volume > 100:
|
||||
volume = 100
|
||||
self._user_volume = volume
|
||||
real_volume = int(volume * self.amplification_factor)
|
||||
self.set_volume(real_volume)
|
||||
self._trigger_volume_changed()
|
||||
|
||||
def get_volume(self):
|
||||
"""
|
||||
Return volume as integer in range [0, 100]. :class:`None` if unknown.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def set_volume(self, volume):
|
||||
"""
|
||||
Set volume as integer in range [0, 100].
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def _trigger_volume_changed(self):
|
||||
logger.debug(u'Triggering volume changed event')
|
||||
listeners.BackendListener.send('volume_changed')
|
||||
@ -1,58 +0,0 @@
|
||||
import logging
|
||||
|
||||
from pykka.actor import ThreadingActor
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
|
||||
logger = logging.getLogger(u'mopidy.mixers.denon')
|
||||
|
||||
class DenonMixer(ThreadingActor, BaseMixer):
|
||||
"""
|
||||
Mixer for controlling Denon amplifiers and receivers using the RS-232
|
||||
protocol.
|
||||
|
||||
The external mixer is the authoritative source for the current volume.
|
||||
This allows the user to use his remote control the volume without Mopidy
|
||||
cancelling the volume setting.
|
||||
|
||||
**Dependencies**
|
||||
|
||||
- pyserial (python-serial on Debian/Ubuntu)
|
||||
|
||||
**Settings**
|
||||
|
||||
- :attr:`mopidy.settings.MIXER_EXT_PORT` -- Example: ``/dev/ttyUSB0``
|
||||
"""
|
||||
|
||||
def __init__(self, device=None):
|
||||
super(DenonMixer, self).__init__()
|
||||
self._device = device
|
||||
self._levels = ['99'] + ["%(#)02d" % {'#': v} for v in range(0, 99)]
|
||||
self._volume = 0
|
||||
|
||||
def on_start(self):
|
||||
if self._device is None:
|
||||
from serial import Serial
|
||||
self._device = Serial(port=settings.MIXER_EXT_PORT, timeout=0.2)
|
||||
|
||||
def get_volume(self):
|
||||
self._ensure_open_device()
|
||||
self._device.write('MV?\r')
|
||||
vol = str(self._device.readline()[2:4])
|
||||
logger.debug(u'_get_volume() = %s' % vol)
|
||||
return self._levels.index(vol)
|
||||
|
||||
def set_volume(self, volume):
|
||||
# Clamp according to Denon-spec
|
||||
if volume > 99:
|
||||
volume = 99
|
||||
self._ensure_open_device()
|
||||
self._device.write('MV%s\r'% self._levels[volume])
|
||||
vol = self._device.readline()[2:4]
|
||||
self._volume = self._levels.index(vol)
|
||||
|
||||
def _ensure_open_device(self):
|
||||
if not self._device.isOpen():
|
||||
logger.debug(u'(re)connecting to Denon device')
|
||||
self._device.open()
|
||||
@ -1,16 +0,0 @@
|
||||
from pykka.actor import ThreadingActor
|
||||
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
|
||||
class DummyMixer(ThreadingActor, BaseMixer):
|
||||
"""Mixer which just stores and reports the chosen volume."""
|
||||
|
||||
def __init__(self):
|
||||
super(DummyMixer, self).__init__()
|
||||
self._volume = None
|
||||
|
||||
def get_volume(self):
|
||||
return self._volume
|
||||
|
||||
def set_volume(self, volume):
|
||||
self._volume = volume
|
||||
@ -1,23 +0,0 @@
|
||||
from pykka.actor import ThreadingActor
|
||||
from pykka.registry import ActorRegistry
|
||||
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
from mopidy.gstreamer import GStreamer
|
||||
|
||||
class GStreamerSoftwareMixer(ThreadingActor, BaseMixer):
|
||||
"""Mixer which uses GStreamer to control volume in software."""
|
||||
|
||||
def __init__(self):
|
||||
super(GStreamerSoftwareMixer, self).__init__()
|
||||
self.output = None
|
||||
|
||||
def on_start(self):
|
||||
output_refs = ActorRegistry.get_by_class(GStreamer)
|
||||
assert len(output_refs) == 1, 'Expected exactly one running output.'
|
||||
self.output = output_refs[0].proxy()
|
||||
|
||||
def get_volume(self):
|
||||
return self.output.get_volume().get()
|
||||
|
||||
def set_volume(self, volume):
|
||||
self.output.set_volume(volume).get()
|
||||
@ -1,198 +0,0 @@
|
||||
import logging
|
||||
import serial
|
||||
|
||||
from pykka.actor import ThreadingActor
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
|
||||
logger = logging.getLogger('mopidy.mixers.nad')
|
||||
|
||||
class NadMixer(ThreadingActor, BaseMixer):
|
||||
"""
|
||||
Mixer for controlling NAD amplifiers and receivers using the NAD RS-232
|
||||
protocol.
|
||||
|
||||
The NAD mixer was created using a NAD C 355BEE amplifier, but should also
|
||||
work with other NAD amplifiers supporting the same RS-232 protocol (v2.x).
|
||||
The C 355BEE does not give you access to the current volume. It only
|
||||
supports increasing or decreasing the volume one step at the time. Other
|
||||
NAD amplifiers may support more advanced volume adjustment than what is
|
||||
currently used by this mixer.
|
||||
|
||||
Sadly, this means that if you use the remote control to change the volume
|
||||
on the amplifier, Mopidy will no longer report the correct volume.
|
||||
|
||||
**Dependencies**
|
||||
|
||||
- pyserial (python-serial on Debian/Ubuntu)
|
||||
|
||||
**Settings**
|
||||
|
||||
- :attr:`mopidy.settings.MIXER_EXT_PORT` -- Example: ``/dev/ttyUSB0``
|
||||
- :attr:`mopidy.settings.MIXER_EXT_SOURCE` -- Example: ``Aux``
|
||||
- :attr:`mopidy.settings.MIXER_EXT_SPEAKERS_A` -- Example: ``On``
|
||||
- :attr:`mopidy.settings.MIXER_EXT_SPEAKERS_B` -- Example: ``Off``
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super(NadMixer, self).__init__()
|
||||
self._volume_cache = None
|
||||
self._nad_talker = NadTalker.start().proxy()
|
||||
|
||||
def get_volume(self):
|
||||
return self._volume_cache
|
||||
|
||||
def set_volume(self, volume):
|
||||
self._volume_cache = volume
|
||||
self._nad_talker.set_volume(volume)
|
||||
|
||||
|
||||
class NadTalker(ThreadingActor):
|
||||
"""
|
||||
Independent process which does the communication with the NAD device.
|
||||
|
||||
Since the communication is done in an independent process, Mopidy won't
|
||||
block other requests while doing rather time consuming work like
|
||||
calibrating the NAD device's volume.
|
||||
"""
|
||||
|
||||
# Timeout in seconds used for read/write operations.
|
||||
# If you set the timeout too low, the reads will never get complete
|
||||
# confirmations and calibration will decrease volume forever. If you set
|
||||
# the timeout too high, stuff takes more time. 0.2s seems like a good value
|
||||
# for NAD C 355BEE.
|
||||
TIMEOUT = 0.2
|
||||
|
||||
# Number of volume levels the device supports. 40 for NAD C 355BEE.
|
||||
VOLUME_LEVELS = 40
|
||||
|
||||
# Volume in range 0..VOLUME_LEVELS. :class:`None` before calibration.
|
||||
_nad_volume = None
|
||||
|
||||
def __init__(self):
|
||||
super(NadTalker, self).__init__()
|
||||
self._device = None
|
||||
|
||||
def on_start(self):
|
||||
self._open_connection()
|
||||
self._set_device_to_known_state()
|
||||
|
||||
def _open_connection(self):
|
||||
# Opens serial connection to the device.
|
||||
# Communication settings: 115200 bps 8N1
|
||||
logger.info(u'Connecting to serial device "%s"',
|
||||
settings.MIXER_EXT_PORT)
|
||||
self._device = serial.Serial(port=settings.MIXER_EXT_PORT,
|
||||
baudrate=115200, timeout=self.TIMEOUT)
|
||||
self._get_device_model()
|
||||
|
||||
def _set_device_to_known_state(self):
|
||||
self._power_device_on()
|
||||
self._select_speakers()
|
||||
self._select_input_source()
|
||||
self._unmute()
|
||||
self._calibrate_volume()
|
||||
|
||||
def _get_device_model(self):
|
||||
model = self._ask_device('Main.Model')
|
||||
logger.info(u'Connected to device of model "%s"', model)
|
||||
return model
|
||||
|
||||
def _power_device_on(self):
|
||||
while self._ask_device('Main.Power') != 'On':
|
||||
logger.info(u'Powering device on')
|
||||
self._command_device('Main.Power', 'On')
|
||||
|
||||
def _select_speakers(self):
|
||||
if settings.MIXER_EXT_SPEAKERS_A is not None:
|
||||
while (self._ask_device('Main.SpeakerA')
|
||||
!= settings.MIXER_EXT_SPEAKERS_A):
|
||||
logger.info(u'Setting speakers A "%s"',
|
||||
settings.MIXER_EXT_SPEAKERS_A)
|
||||
self._command_device('Main.SpeakerA',
|
||||
settings.MIXER_EXT_SPEAKERS_A)
|
||||
if settings.MIXER_EXT_SPEAKERS_B is not None:
|
||||
while (self._ask_device('Main.SpeakerB') !=
|
||||
settings.MIXER_EXT_SPEAKERS_B):
|
||||
logger.info(u'Setting speakers B "%s"',
|
||||
settings.MIXER_EXT_SPEAKERS_B)
|
||||
self._command_device('Main.SpeakerB',
|
||||
settings.MIXER_EXT_SPEAKERS_B)
|
||||
|
||||
def _select_input_source(self):
|
||||
if settings.MIXER_EXT_SOURCE is not None:
|
||||
while self._ask_device('Main.Source') != settings.MIXER_EXT_SOURCE:
|
||||
logger.info(u'Selecting input source "%s"',
|
||||
settings.MIXER_EXT_SOURCE)
|
||||
self._command_device('Main.Source', settings.MIXER_EXT_SOURCE)
|
||||
|
||||
def _unmute(self):
|
||||
while self._ask_device('Main.Mute') != 'Off':
|
||||
logger.info(u'Unmuting device')
|
||||
self._command_device('Main.Mute', 'Off')
|
||||
|
||||
def _ask_device(self, key):
|
||||
self._write('%s?' % key)
|
||||
return self._readline().replace('%s=' % key, '')
|
||||
|
||||
def _command_device(self, key, value):
|
||||
if type(value) == unicode:
|
||||
value = value.encode('utf-8')
|
||||
self._write('%s=%s' % (key, value))
|
||||
self._readline()
|
||||
|
||||
def _calibrate_volume(self):
|
||||
# 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'Calibrating NAD amplifier')
|
||||
steps_left = self.VOLUME_LEVELS - 1
|
||||
while steps_left:
|
||||
if self._decrease_volume():
|
||||
steps_left -= 1
|
||||
self._nad_volume = 0
|
||||
logger.info(u'Done calibrating NAD amplifier')
|
||||
|
||||
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)
|
||||
target_nad_volume = int(round(volume * self.VOLUME_LEVELS / 100.0))
|
||||
if self._nad_volume is None:
|
||||
return # Calibration needed
|
||||
while target_nad_volume > self._nad_volume:
|
||||
if self._increase_volume():
|
||||
self._nad_volume += 1
|
||||
while target_nad_volume < self._nad_volume:
|
||||
if self._decrease_volume():
|
||||
self._nad_volume -= 1
|
||||
|
||||
def _increase_volume(self):
|
||||
# Increase volume. Returns :class:`True` if confirmed by device.
|
||||
self._write('Main.Volume+')
|
||||
return self._readline() == 'Main.Volume+'
|
||||
|
||||
def _decrease_volume(self):
|
||||
# Decrease volume. Returns :class:`True` if confirmed by device.
|
||||
self._write('Main.Volume-')
|
||||
return self._readline() == 'Main.Volume-'
|
||||
|
||||
def _write(self, data):
|
||||
# Write data to device. Prepends and appends a newline to the data, as
|
||||
# recommended by the NAD documentation.
|
||||
if not self._device.isOpen():
|
||||
self._device.open()
|
||||
self._device.write('\n%s\n' % data)
|
||||
logger.debug('Write: %s', data)
|
||||
|
||||
def _readline(self):
|
||||
# Read line from device. The result is stripped for leading and
|
||||
# trailing whitespace.
|
||||
if not self._device.isOpen():
|
||||
self._device.open()
|
||||
result = self._device.readline().strip()
|
||||
if result:
|
||||
logger.debug('Read: %s', result)
|
||||
return result
|
||||
@ -1,46 +0,0 @@
|
||||
from subprocess import Popen, PIPE
|
||||
import time
|
||||
|
||||
from pykka.actor import ThreadingActor
|
||||
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
|
||||
class OsaMixer(ThreadingActor, BaseMixer):
|
||||
"""
|
||||
Mixer which uses ``osascript`` on OS X to control volume.
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- None
|
||||
|
||||
**Settings:**
|
||||
|
||||
- None
|
||||
"""
|
||||
|
||||
CACHE_TTL = 30
|
||||
|
||||
_cache = None
|
||||
_last_update = None
|
||||
|
||||
def _valid_cache(self):
|
||||
return (self._cache is not None
|
||||
and self._last_update is not None
|
||||
and (int(time.time() - self._last_update) < self.CACHE_TTL))
|
||||
|
||||
def get_volume(self):
|
||||
if not self._valid_cache():
|
||||
try:
|
||||
self._cache = int(Popen(
|
||||
['osascript', '-e',
|
||||
'output volume of (get volume settings)'],
|
||||
stdout=PIPE).communicate()[0])
|
||||
except ValueError:
|
||||
self._cache = None
|
||||
self._last_update = int(time.time())
|
||||
return self._cache
|
||||
|
||||
def set_volume(self, volume):
|
||||
Popen(['osascript', '-e', 'set volume output volume %d' % volume])
|
||||
self._cache = volume
|
||||
self._last_update = int(time.time())
|
||||
@ -1,5 +1,6 @@
|
||||
from collections import namedtuple
|
||||
|
||||
|
||||
class ImmutableObject(object):
|
||||
"""
|
||||
Superclass for immutable objects whose fields can only be modified via the
|
||||
@ -74,6 +75,19 @@ class ImmutableObject(object):
|
||||
% key)
|
||||
return self.__class__(**data)
|
||||
|
||||
def serialize(self):
|
||||
data = {}
|
||||
for key in self.__dict__.keys():
|
||||
public_key = key.lstrip('_')
|
||||
value = self.__dict__[key]
|
||||
if isinstance(value, (set, frozenset, list, tuple)):
|
||||
value = [o.serialize() for o in value]
|
||||
elif isinstance(value, ImmutableObject):
|
||||
value = value.serialize()
|
||||
if value:
|
||||
data[public_key] = value
|
||||
return data
|
||||
|
||||
|
||||
class Artist(ImmutableObject):
|
||||
"""
|
||||
@ -144,8 +158,8 @@ class Track(ImmutableObject):
|
||||
:type album: :class:`Album`
|
||||
:param track_no: track number in album
|
||||
:type track_no: integer
|
||||
:param date: track release date
|
||||
:type date: :class:`datetime.date`
|
||||
:param date: track release date (YYYY or YYYY-MM-DD)
|
||||
:type date: string
|
||||
:param length: track length in milliseconds
|
||||
:type length: integer
|
||||
:param bitrate: bitrate in kbit/s
|
||||
@ -216,6 +230,8 @@ class Playlist(ImmutableObject):
|
||||
self.__dict__['tracks'] = tuple(kwargs.pop('tracks', []))
|
||||
super(Playlist, self).__init__(*args, **kwargs)
|
||||
|
||||
# TODO: def insert(self, pos, track): ... ?
|
||||
|
||||
@property
|
||||
def length(self):
|
||||
"""The number of tracks in the playlist. Read-only."""
|
||||
|
||||
@ -1,105 +0,0 @@
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('mopidy.outputs')
|
||||
|
||||
class BaseOutput(object):
|
||||
"""Base class for pluggable audio outputs."""
|
||||
|
||||
MESSAGE_EOS = gst.MESSAGE_EOS
|
||||
MESSAGE_ERROR = gst.MESSAGE_ERROR
|
||||
MESSAGE_WARNING = gst.MESSAGE_WARNING
|
||||
|
||||
def __init__(self, gstreamer):
|
||||
self.gstreamer = gstreamer
|
||||
self.bin = self._build_bin()
|
||||
self.bin.set_name(self.get_name())
|
||||
|
||||
self.modify_bin()
|
||||
|
||||
def _build_bin(self):
|
||||
description = 'queue ! %s' % self.describe_bin()
|
||||
logger.debug('Creating new output: %s', description)
|
||||
return gst.parse_bin_from_description(description, True)
|
||||
|
||||
def connect(self):
|
||||
"""Attach output to GStreamer pipeline."""
|
||||
self.gstreamer.connect_output(self.bin)
|
||||
self.on_connect()
|
||||
|
||||
def on_connect(self):
|
||||
"""
|
||||
Called after output has been connected to GStreamer pipeline.
|
||||
|
||||
*MAY be implemented by subclass.*
|
||||
"""
|
||||
pass
|
||||
|
||||
def remove(self):
|
||||
"""Remove output from GStreamer pipeline."""
|
||||
self.gstreamer.remove_output(self.bin)
|
||||
self.on_remove()
|
||||
|
||||
def on_remove(self):
|
||||
"""
|
||||
Called after output has been removed from GStreamer pipeline.
|
||||
|
||||
*MAY be implemented by subclass.*
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_name(self):
|
||||
"""
|
||||
Get name of the output. Defaults to the output's class name.
|
||||
|
||||
*MAY be implemented by subclass.*
|
||||
|
||||
:rtype: string
|
||||
"""
|
||||
return self.__class__.__name__
|
||||
|
||||
def modify_bin(self):
|
||||
"""
|
||||
Modifies ``self.bin`` before it is installed if needed.
|
||||
|
||||
Overriding this method allows for outputs to modify the constructed bin
|
||||
before it is installed. This can for instance be a good place to call
|
||||
`set_properties` on elements that need to be configured.
|
||||
|
||||
*MAY be implemented by subclass.*
|
||||
"""
|
||||
pass
|
||||
|
||||
def describe_bin(self):
|
||||
"""
|
||||
Return string describing the output bin in :command:`gst-launch`
|
||||
format.
|
||||
|
||||
For simple cases this can just be a sink such as ``autoaudiosink``,
|
||||
or it can be a chain like ``element1 ! element2 ! sink``. See the
|
||||
manpage of :command:`gst-launch` for details on the format.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
|
||||
:rtype: string
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def set_properties(self, element, properties):
|
||||
"""
|
||||
Helper method for setting of properties on elements.
|
||||
|
||||
Will call :meth:`gst.Element.set_property` on ``element`` for each key
|
||||
in ``properties`` that has a value that is not :class:`None`.
|
||||
|
||||
:param element: element to set properties on
|
||||
:type element: :class:`gst.Element`
|
||||
:param properties: properties to set on element
|
||||
:type properties: dict
|
||||
"""
|
||||
for key, value in properties.items():
|
||||
if value is not None:
|
||||
element.set_property(key, value)
|
||||
@ -1,34 +0,0 @@
|
||||
from mopidy import settings
|
||||
from mopidy.outputs import BaseOutput
|
||||
|
||||
class CustomOutput(BaseOutput):
|
||||
"""
|
||||
Custom output for using alternate setups.
|
||||
|
||||
This output is intended to handle two main cases:
|
||||
|
||||
1. Simple things like switching which sink to use. Say :class:`LocalOutput`
|
||||
doesn't work for you and you want to switch to ALSA, simple. Set
|
||||
:attr:`mopidy.settings.CUSTOM_OUTPUT` to ``alsasink`` and you are good
|
||||
to go. Some possible sinks include:
|
||||
|
||||
- alsasink
|
||||
- osssink
|
||||
- pulsesink
|
||||
- ...and many more
|
||||
|
||||
2. Advanced setups that require complete control of the output bin. For
|
||||
these cases setup :attr:`mopidy.settings.CUSTOM_OUTPUT` with a
|
||||
:command:`gst-launch` compatible string describing the target setup.
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- None
|
||||
|
||||
**Settings:**
|
||||
|
||||
- :attr:`mopidy.settings.CUSTOM_OUTPUT`
|
||||
"""
|
||||
|
||||
def describe_bin(self):
|
||||
return settings.CUSTOM_OUTPUT
|
||||
@ -1,20 +0,0 @@
|
||||
from mopidy.outputs import BaseOutput
|
||||
|
||||
class LocalOutput(BaseOutput):
|
||||
"""
|
||||
Basic output to local audio sink.
|
||||
|
||||
This output will normally tell GStreamer to choose whatever it thinks is
|
||||
best for your system. In other words this is usually a sane choice.
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- None
|
||||
|
||||
**Settings:**
|
||||
|
||||
- None
|
||||
"""
|
||||
|
||||
def describe_bin(self):
|
||||
return 'autoaudiosink'
|
||||
@ -1,58 +0,0 @@
|
||||
import logging
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.outputs import BaseOutput
|
||||
|
||||
logger = logging.getLogger('mopidy.outputs.shoutcast')
|
||||
|
||||
class ShoutcastOutput(BaseOutput):
|
||||
"""
|
||||
Shoutcast streaming output.
|
||||
|
||||
This output allows for streaming to an icecast server or anything else that
|
||||
supports Shoutcast. The output supports setting for: server address, port,
|
||||
mount point, user, password and encoder to use. Please see
|
||||
:class:`mopidy.settings` for details about settings.
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- A SHOUTcast/Icecast server
|
||||
|
||||
**Settings:**
|
||||
|
||||
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_HOSTNAME`
|
||||
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PORT`
|
||||
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_USERNAME`
|
||||
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PASSWORD`
|
||||
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_MOUNT`
|
||||
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_ENCODER`
|
||||
"""
|
||||
|
||||
def describe_bin(self):
|
||||
return 'audioconvert ! %s ! shout2send name=shoutcast' \
|
||||
% settings.SHOUTCAST_OUTPUT_ENCODER
|
||||
|
||||
def modify_bin(self):
|
||||
self.set_properties(self.bin.get_by_name('shoutcast'), {
|
||||
u'ip': settings.SHOUTCAST_OUTPUT_HOSTNAME,
|
||||
u'port': settings.SHOUTCAST_OUTPUT_PORT,
|
||||
u'mount': settings.SHOUTCAST_OUTPUT_MOUNT,
|
||||
u'username': settings.SHOUTCAST_OUTPUT_USERNAME,
|
||||
u'password': settings.SHOUTCAST_OUTPUT_PASSWORD,
|
||||
})
|
||||
|
||||
def on_connect(self):
|
||||
self.gstreamer.connect_message_handler(
|
||||
self.bin.get_by_name('shoutcast'), self.message_handler)
|
||||
|
||||
def on_remove(self):
|
||||
self.gstreamer.remove_message_handler(
|
||||
self.bin.get_by_name('shoutcast'))
|
||||
|
||||
def message_handler(self, message):
|
||||
if message.type != self.MESSAGE_ERROR:
|
||||
return False
|
||||
error, debug = message.parse_error()
|
||||
logger.warning('%s (%s)', error, debug)
|
||||
self.remove()
|
||||
return True
|
||||
@ -52,7 +52,7 @@ def translator(data):
|
||||
|
||||
class Scanner(object):
|
||||
def __init__(self, folder, data_callback, error_callback=None):
|
||||
self.uris = [path_to_uri(f) for f in find_files(folder)]
|
||||
self.files = find_files(folder)
|
||||
self.data_callback = data_callback
|
||||
self.error_callback = error_callback
|
||||
self.loop = gobject.MainLoop()
|
||||
@ -114,18 +114,19 @@ class Scanner(object):
|
||||
return None
|
||||
|
||||
def next_uri(self):
|
||||
if not self.uris:
|
||||
return self.stop()
|
||||
|
||||
try:
|
||||
uri = path_to_uri(self.files.next())
|
||||
except StopIteration:
|
||||
self.stop()
|
||||
return False
|
||||
self.pipe.set_state(gst.STATE_NULL)
|
||||
self.uribin.set_property('uri', self.uris.pop())
|
||||
self.uribin.set_property('uri', uri)
|
||||
self.pipe.set_state(gst.STATE_PAUSED)
|
||||
return True
|
||||
|
||||
def start(self):
|
||||
if not self.uris:
|
||||
return
|
||||
self.next_uri()
|
||||
self.loop.run()
|
||||
if self.next_uri():
|
||||
self.loop.run()
|
||||
|
||||
def stop(self):
|
||||
self.pipe.set_state(gst.STATE_NULL)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
"""
|
||||
Available settings and their default values.
|
||||
All available settings and their default values.
|
||||
|
||||
.. warning::
|
||||
|
||||
@ -14,6 +14,10 @@ Available settings and their default values.
|
||||
#:
|
||||
#: BACKENDS = (u'mopidy.backends.spotify.SpotifyBackend',)
|
||||
#:
|
||||
#: Other typical values::
|
||||
#:
|
||||
#: BACKENDS = (u'mopidy.backends.local.LocalBackend',)
|
||||
#:
|
||||
#: .. note::
|
||||
#: Currently only the first backend in the list is used.
|
||||
BACKENDS = (
|
||||
@ -26,14 +30,6 @@ BACKENDS = (
|
||||
#: details on the format.
|
||||
CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(message)s'
|
||||
|
||||
#: Which GStreamer bin description to use in
|
||||
#: :class:`mopidy.outputs.custom.CustomOutput`.
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: CUSTOM_OUTPUT = u'fakesink'
|
||||
CUSTOM_OUTPUT = u'fakesink'
|
||||
|
||||
#: The log format used for debug logging.
|
||||
#:
|
||||
#: See http://docs.python.org/library/logging.html#formatter-objects for
|
||||
@ -89,9 +85,8 @@ LASTFM_PASSWORD = u''
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: # Defaults to asking glib where music is stored, fallback is ~/music
|
||||
#: LOCAL_MUSIC_PATH = None
|
||||
LOCAL_MUSIC_PATH = None
|
||||
#: LOCAL_MUSIC_PATH = u'$XDG_MUSIC_DIR'
|
||||
LOCAL_MUSIC_PATH = u'$XDG_MUSIC_DIR'
|
||||
|
||||
#: Path to playlist folder with m3u files for local music.
|
||||
#:
|
||||
@ -99,8 +94,8 @@ LOCAL_MUSIC_PATH = None
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: LOCAL_PLAYLIST_PATH = None # Implies $XDG_DATA_DIR/mopidy/playlists
|
||||
LOCAL_PLAYLIST_PATH = None
|
||||
#: LOCAL_PLAYLIST_PATH = u'$XDG_DATA_DIR/mopidy/playlists'
|
||||
LOCAL_PLAYLIST_PATH = u'$XDG_DATA_DIR/mopidy/playlists'
|
||||
|
||||
#: Path to tag cache for local music.
|
||||
#:
|
||||
@ -108,57 +103,38 @@ LOCAL_PLAYLIST_PATH = None
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: LOCAL_TAG_CACHE_FILE = None # Implies $XDG_DATA_DIR/mopidy/tag_cache
|
||||
LOCAL_TAG_CACHE_FILE = None
|
||||
#: LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache'
|
||||
LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache'
|
||||
|
||||
#: Sound mixer to use. See :mod:`mopidy.mixers` for all available mixers.
|
||||
#: Sound mixer to use.
|
||||
#:
|
||||
#: Expects a GStreamer mixer to use, typical values are:
|
||||
#: ``alsamixer``, ``pulsemixer``, ``ossmixer``, and ``oss4mixer``.
|
||||
#:
|
||||
#: Setting this to :class:`None` turns off volume control. ``software``
|
||||
#: can be used to force software mixing in the application.
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: MIXER = u'mopidy.mixers.gstreamer_software.GStreamerSoftwareMixer'
|
||||
MIXER = u'mopidy.mixers.gstreamer_software.GStreamerSoftwareMixer'
|
||||
#: MIXER = u'autoaudiomixer'
|
||||
MIXER = u'autoaudiomixer'
|
||||
|
||||
#: ALSA mixer only. What mixer control to use. If set to :class:`False`, first
|
||||
#: ``Master`` and then ``PCM`` will be tried.
|
||||
#: Sound mixer track to use.
|
||||
#:
|
||||
#: Example: ``Master Front``. Default: :class:`False`
|
||||
MIXER_ALSA_CONTROL = False
|
||||
|
||||
#: External mixers only. Which port the mixer is connected to.
|
||||
#:
|
||||
#: This must point to the device port like ``/dev/ttyUSB0``.
|
||||
#:
|
||||
#: Default: :class:`None`
|
||||
MIXER_EXT_PORT = None
|
||||
|
||||
#: External mixers only. What input source the external mixer should use.
|
||||
#:
|
||||
#: Example: ``Aux``. Default: :class:`None`
|
||||
MIXER_EXT_SOURCE = None
|
||||
|
||||
#: External mixers only. What state Speakers A should be in.
|
||||
#:
|
||||
#: Default: :class:`None`.
|
||||
MIXER_EXT_SPEAKERS_A = None
|
||||
|
||||
#: External mixers only. What state Speakers B should be in.
|
||||
#:
|
||||
#: Default: :class:`None`.
|
||||
MIXER_EXT_SPEAKERS_B = None
|
||||
|
||||
#: The maximum volume. Integer in the range 0 to 100.
|
||||
#:
|
||||
#: If this settings is set to 80, the mixer will set the actual volume to 80
|
||||
#: when asked to set it to 100.
|
||||
#: Name of the mixer track to use. If this is not set we will try to find the
|
||||
#: master output track. As an example, using ``alsamixer`` you would
|
||||
#: typically set this to ``Master`` or ``PCM``.
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: MIXER_MAX_VOLUME = 100
|
||||
MIXER_MAX_VOLUME = 100
|
||||
#: MIXER_TRACK = None
|
||||
MIXER_TRACK = None
|
||||
|
||||
#: Which address Mopidy's MPD server should bind to.
|
||||
#:
|
||||
#:Examples:
|
||||
#: Used by :mod:`mopidy.frontends.mpd`.
|
||||
#:
|
||||
#: Examples:
|
||||
#:
|
||||
#: ``127.0.0.1``
|
||||
#: Listens only on the IPv4 loopback interface. Default.
|
||||
@ -172,89 +148,40 @@ MPD_SERVER_HOSTNAME = u'127.0.0.1'
|
||||
|
||||
#: Which TCP port Mopidy's MPD server should listen to.
|
||||
#:
|
||||
#: Used by :mod:`mopidy.frontends.mpd`.
|
||||
#:
|
||||
#: Default: 6600
|
||||
MPD_SERVER_PORT = 6600
|
||||
|
||||
#: The password required for connecting to the MPD server.
|
||||
#:
|
||||
#: Used by :mod:`mopidy.frontends.mpd`.
|
||||
#:
|
||||
#: Default: :class:`None`, which means no password required.
|
||||
MPD_SERVER_PASSWORD = None
|
||||
|
||||
#: The maximum number of concurrent connections the MPD server will accept.
|
||||
#:
|
||||
#: Used by :mod:`mopidy.frontends.mpd`.
|
||||
#:
|
||||
#: Default: 20
|
||||
MPD_SERVER_MAX_CONNECTIONS = 20
|
||||
|
||||
#: List of outputs to use. See :mod:`mopidy.outputs` for all available
|
||||
#: backends
|
||||
#: Output to use. See :mod:`mopidy.outputs` for all available backends
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: OUTPUTS = (
|
||||
#: u'mopidy.outputs.local.LocalOutput',
|
||||
#: )
|
||||
OUTPUTS = (
|
||||
u'mopidy.outputs.local.LocalOutput',
|
||||
)
|
||||
|
||||
#: Hostname of the SHOUTcast server which Mopidy should stream audio to.
|
||||
#:
|
||||
#: Used by :mod:`mopidy.outputs.shoutcast`.
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: SHOUTCAST_OUTPUT_HOSTNAME = u'127.0.0.1'
|
||||
SHOUTCAST_OUTPUT_HOSTNAME = u'127.0.0.1'
|
||||
|
||||
#: Port of the SHOUTcast server.
|
||||
#:
|
||||
#: Used by :mod:`mopidy.outputs.shoutcast`.
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: SHOUTCAST_OUTPUT_PORT = 8000
|
||||
SHOUTCAST_OUTPUT_PORT = 8000
|
||||
|
||||
#: User to authenticate as against SHOUTcast server.
|
||||
#:
|
||||
#: Used by :mod:`mopidy.outputs.shoutcast`.
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: SHOUTCAST_OUTPUT_USERNAME = u'source'
|
||||
SHOUTCAST_OUTPUT_USERNAME = u'source'
|
||||
|
||||
#: Password to authenticate with against SHOUTcast server.
|
||||
#:
|
||||
#: Used by :mod:`mopidy.outputs.shoutcast`.
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: SHOUTCAST_OUTPUT_PASSWORD = u'hackme'
|
||||
SHOUTCAST_OUTPUT_PASSWORD = u'hackme'
|
||||
|
||||
#: Mountpoint to use for the stream on the SHOUTcast server.
|
||||
#:
|
||||
#: Used by :mod:`mopidy.outputs.shoutcast`.
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: SHOUTCAST_OUTPUT_MOUNT = u'/stream'
|
||||
SHOUTCAST_OUTPUT_MOUNT = u'/stream'
|
||||
|
||||
#: Encoder to use to process audio data before streaming to SHOUTcast server.
|
||||
#:
|
||||
#: Used by :mod:`mopidy.outputs.shoutcast`.
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: SHOUTCAST_OUTPUT_ENCODER = u'lame mode=stereo bitrate=320'
|
||||
SHOUTCAST_OUTPUT_ENCODER = u'lame mode=stereo bitrate=320'
|
||||
#: OUTPUT = u'autoaudiosink'
|
||||
OUTPUT = u'autoaudiosink'
|
||||
|
||||
#: Path to the Spotify cache.
|
||||
#:
|
||||
#: Used by :mod:`mopidy.backends.spotify`.
|
||||
SPOTIFY_CACHE_PATH = None
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: SPOTIFY_CACHE_PATH = u'$XDG_CACHE_DIR/mopidy/spotify'
|
||||
SPOTIFY_CACHE_PATH = u'$XDG_CACHE_DIR/mopidy/spotify'
|
||||
|
||||
#: Your Spotify Premium username.
|
||||
#:
|
||||
@ -271,7 +198,7 @@ SPOTIFY_PASSWORD = u''
|
||||
#: Available values are 96, 160, and 320.
|
||||
#:
|
||||
#: Used by :mod:`mopidy.backends.spotify`.
|
||||
#
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: SPOTIFY_BITRATE = 160
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
from __future__ import division
|
||||
|
||||
import locale
|
||||
import logging
|
||||
import os
|
||||
@ -5,6 +7,8 @@ import sys
|
||||
|
||||
logger = logging.getLogger('mopidy.utils')
|
||||
|
||||
|
||||
# TODO: use itertools.chain.from_iterable(the_list)?
|
||||
def flatten(the_list):
|
||||
result = []
|
||||
for element in the_list:
|
||||
@ -14,22 +18,33 @@ def flatten(the_list):
|
||||
result.append(element)
|
||||
return result
|
||||
|
||||
|
||||
def rescale(v, 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 round(scaling * (v - old_min) + new_min)
|
||||
|
||||
|
||||
def import_module(name):
|
||||
__import__(name)
|
||||
return sys.modules[name]
|
||||
|
||||
|
||||
def get_class(name):
|
||||
logger.debug('Loading: %s', name)
|
||||
if '.' not in name:
|
||||
raise ImportError("Couldn't load: %s" % name)
|
||||
module_name = name[:name.rindex('.')]
|
||||
class_name = name[name.rindex('.') + 1:]
|
||||
cls_name = name[name.rindex('.') + 1:]
|
||||
try:
|
||||
module = import_module(module_name)
|
||||
class_object = getattr(module, class_name)
|
||||
cls = getattr(module, cls_name)
|
||||
except (ImportError, AttributeError):
|
||||
raise ImportError("Couldn't load: %s" % name)
|
||||
return class_object
|
||||
return cls
|
||||
|
||||
|
||||
def locale_decode(bytestr):
|
||||
try:
|
||||
|
||||
193
mopidy/utils/deps.py
Normal file
193
mopidy/utils/deps.py
Normal file
@ -0,0 +1,193 @@
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst
|
||||
|
||||
import pykka
|
||||
|
||||
from mopidy.utils.log import indent
|
||||
|
||||
|
||||
def list_deps_optparse_callback(*args):
|
||||
"""
|
||||
Prints a list of all dependencies.
|
||||
|
||||
Called by optparse when Mopidy is run with the :option:`--list-deps`
|
||||
option.
|
||||
"""
|
||||
print format_dependency_list()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def format_dependency_list(adapters=None):
|
||||
if adapters is None:
|
||||
adapters = [
|
||||
platform_info,
|
||||
python_info,
|
||||
gstreamer_info,
|
||||
pykka_info,
|
||||
pyspotify_info,
|
||||
pylast_info,
|
||||
dbus_info,
|
||||
serial_info,
|
||||
]
|
||||
|
||||
lines = []
|
||||
for adapter in adapters:
|
||||
dep_info = adapter()
|
||||
lines.append('%(name)s: %(version)s' % {
|
||||
'name': dep_info['name'],
|
||||
'version': dep_info.get('version', 'not found'),
|
||||
})
|
||||
if 'path' in dep_info:
|
||||
lines.append(' Imported from: %s' % (
|
||||
os.path.dirname(dep_info['path'])))
|
||||
if 'other' in dep_info:
|
||||
lines.append(' Other: %s' % (
|
||||
indent(dep_info['other'])),)
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def platform_info():
|
||||
return {
|
||||
'name': 'Platform',
|
||||
'version': platform.platform(),
|
||||
}
|
||||
|
||||
|
||||
def python_info():
|
||||
return {
|
||||
'name': 'Python',
|
||||
'version': '%s %s' % (platform.python_implementation(),
|
||||
platform.python_version()),
|
||||
'path': platform.__file__,
|
||||
}
|
||||
|
||||
|
||||
def gstreamer_info():
|
||||
other = []
|
||||
other.append('Python wrapper: gst-python %s' % (
|
||||
'.'.join(map(str, gst.get_pygst_version()))))
|
||||
other.append('Relevant elements:')
|
||||
for name, status in _gstreamer_check_elements():
|
||||
other.append(' %s: %s' % (name, 'OK' if status else 'not found'))
|
||||
return {
|
||||
'name': 'GStreamer',
|
||||
'version': '.'.join(map(str, gst.get_gst_version())),
|
||||
'path': gst.__file__,
|
||||
'other': '\n'.join(other),
|
||||
}
|
||||
|
||||
|
||||
def _gstreamer_check_elements():
|
||||
elements_to_check = [
|
||||
# Core playback
|
||||
'uridecodebin',
|
||||
|
||||
# External HTTP streams
|
||||
'souphttpsrc',
|
||||
|
||||
# Spotify
|
||||
'appsrc',
|
||||
|
||||
# Mixers and sinks
|
||||
'alsamixer',
|
||||
'alsasink',
|
||||
'ossmixer',
|
||||
'osssink',
|
||||
'oss4mixer',
|
||||
'oss4sink',
|
||||
'pulsemixer',
|
||||
'pulsesink',
|
||||
|
||||
# MP3 encoding and decoding
|
||||
'mp3parse',
|
||||
'mad',
|
||||
'id3demux',
|
||||
'id3v2mux',
|
||||
'lame',
|
||||
|
||||
# Ogg Vorbis encoding and decoding
|
||||
'vorbisdec',
|
||||
'vorbisenc',
|
||||
'vorbisparse',
|
||||
'oggdemux',
|
||||
'oggmux',
|
||||
'oggparse',
|
||||
|
||||
# Flac decoding
|
||||
'flacdec',
|
||||
'flacparse',
|
||||
|
||||
# Shoutcast output
|
||||
'shout2send',
|
||||
]
|
||||
known_elements = [factory.get_name() for factory in
|
||||
gst.registry_get_default().get_feature_list(gst.TYPE_ELEMENT_FACTORY)]
|
||||
return [(element, element in known_elements) for element in elements_to_check]
|
||||
|
||||
|
||||
def pykka_info():
|
||||
if hasattr(pykka, '__version__'):
|
||||
# Pykka >= 0.14
|
||||
version = pykka.__version__
|
||||
else:
|
||||
# Pykka < 0.14
|
||||
version = pykka.get_version()
|
||||
return {
|
||||
'name': 'Pykka',
|
||||
'version': version,
|
||||
'path': pykka.__file__,
|
||||
}
|
||||
|
||||
|
||||
def pyspotify_info():
|
||||
dep_info = {'name': 'pyspotify'}
|
||||
try:
|
||||
import spotify
|
||||
if hasattr(spotify, '__version__'):
|
||||
dep_info['version'] = spotify.__version__
|
||||
else:
|
||||
dep_info['version'] = '< 1.3'
|
||||
dep_info['path'] = spotify.__file__
|
||||
dep_info['other'] = 'Built for libspotify API version %d' % (
|
||||
spotify.api_version,)
|
||||
except ImportError:
|
||||
pass
|
||||
return dep_info
|
||||
|
||||
|
||||
def pylast_info():
|
||||
dep_info = {'name': 'pylast'}
|
||||
try:
|
||||
import pylast
|
||||
dep_info['version'] = pylast.__version__
|
||||
dep_info['path'] = pylast.__file__
|
||||
except ImportError:
|
||||
pass
|
||||
return dep_info
|
||||
|
||||
|
||||
def dbus_info():
|
||||
dep_info = {'name': 'dbus-python'}
|
||||
try:
|
||||
import dbus
|
||||
dep_info['version'] = dbus.__version__
|
||||
dep_info['path'] = dbus.__file__
|
||||
except ImportError:
|
||||
pass
|
||||
return dep_info
|
||||
|
||||
|
||||
def serial_info():
|
||||
dep_info = {'name': 'pyserial'}
|
||||
try:
|
||||
import serial
|
||||
dep_info['version'] = serial.VERSION
|
||||
dep_info['path'] = serial.__file__
|
||||
except ImportError:
|
||||
pass
|
||||
return dep_info
|
||||
@ -9,8 +9,9 @@ def setup_logging(verbosity_level, save_debug_log):
|
||||
if save_debug_log:
|
||||
setup_debug_logging_to_file()
|
||||
logger = logging.getLogger('mopidy.utils.log')
|
||||
logger.info(u'Starting Mopidy %s on %s %s',
|
||||
get_version(), get_platform(), get_python())
|
||||
logger.info(u'Starting Mopidy %s', get_version())
|
||||
logger.info(u'Platform: %s', get_platform())
|
||||
logger.info(u'Python: %s', get_python())
|
||||
|
||||
def setup_root_logger():
|
||||
root = logging.getLogger('')
|
||||
|
||||
@ -150,7 +150,7 @@ class Connection(object):
|
||||
logger.log(level, reason)
|
||||
|
||||
try:
|
||||
self.actor_ref.stop()
|
||||
self.actor_ref.stop(block=False)
|
||||
except ActorDeadError:
|
||||
pass
|
||||
|
||||
|
||||
@ -1,11 +1,20 @@
|
||||
import glib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import string
|
||||
import sys
|
||||
import urllib
|
||||
|
||||
logger = logging.getLogger('mopidy.utils.path')
|
||||
|
||||
XDG_DIRS = {
|
||||
'XDG_CACHE_DIR': glib.get_user_cache_dir(),
|
||||
'XDG_DATA_DIR': glib.get_user_data_dir(),
|
||||
'XDG_MUSIC_DIR': glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC),
|
||||
}
|
||||
|
||||
|
||||
def get_or_create_folder(folder):
|
||||
folder = os.path.expanduser(folder)
|
||||
if os.path.isfile(folder):
|
||||
@ -16,6 +25,7 @@ def get_or_create_folder(folder):
|
||||
os.makedirs(folder, 0755)
|
||||
return folder
|
||||
|
||||
|
||||
def get_or_create_file(filename):
|
||||
filename = os.path.expanduser(filename)
|
||||
if not os.path.isfile(filename):
|
||||
@ -23,6 +33,7 @@ def get_or_create_file(filename):
|
||||
open(filename, 'w')
|
||||
return filename
|
||||
|
||||
|
||||
def path_to_uri(*paths):
|
||||
path = os.path.join(*paths)
|
||||
path = path.encode('utf-8')
|
||||
@ -30,6 +41,7 @@ def path_to_uri(*paths):
|
||||
return 'file:' + urllib.pathname2url(path)
|
||||
return 'file://' + urllib.pathname2url(path)
|
||||
|
||||
|
||||
def uri_to_path(uri):
|
||||
if sys.platform == 'win32':
|
||||
path = urllib.url2pathname(re.sub('^file:', '', uri))
|
||||
@ -37,6 +49,7 @@ def uri_to_path(uri):
|
||||
path = urllib.url2pathname(re.sub('^file://', '', uri))
|
||||
return path.encode('latin1').decode('utf-8') # Undo double encoding
|
||||
|
||||
|
||||
def split_path(path):
|
||||
parts = []
|
||||
while True:
|
||||
@ -47,21 +60,40 @@ def split_path(path):
|
||||
break
|
||||
return parts
|
||||
|
||||
# pylint: disable = W0612
|
||||
# Unused variable 'dirnames'
|
||||
|
||||
def expand_path(path):
|
||||
path = string.Template(path).safe_substitute(XDG_DIRS)
|
||||
path = os.path.expanduser(path)
|
||||
path = os.path.abspath(path)
|
||||
return path
|
||||
|
||||
|
||||
def find_files(path):
|
||||
if os.path.isfile(path):
|
||||
if not isinstance(path, unicode):
|
||||
path = path.decode('utf-8')
|
||||
yield path
|
||||
if not os.path.basename(path).startswith('.'):
|
||||
yield path
|
||||
else:
|
||||
for dirpath, dirnames, filenames in os.walk(path):
|
||||
# Filter out hidden folders by modifying dirnames in place.
|
||||
for dirname in dirnames:
|
||||
if dirname.startswith('.'):
|
||||
dirnames.remove(dirname)
|
||||
|
||||
for filename in filenames:
|
||||
# Skip hidden files.
|
||||
if filename.startswith('.'):
|
||||
continue
|
||||
|
||||
filename = os.path.join(dirpath, filename)
|
||||
if not isinstance(filename, unicode):
|
||||
filename = filename.decode('utf-8')
|
||||
try:
|
||||
filename = filename.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
filename = filename.decode('latin1')
|
||||
yield filename
|
||||
# pylint: enable = W0612
|
||||
|
||||
|
||||
# FIXME replace with mock usage in tests.
|
||||
class Mtime(object):
|
||||
|
||||
@ -1,17 +1,20 @@
|
||||
# Absolute import needed to import ~/.mopidy/settings.py and not ourselves
|
||||
from __future__ import absolute_import
|
||||
from copy import copy
|
||||
|
||||
import copy
|
||||
import getpass
|
||||
import logging
|
||||
import os
|
||||
from pprint import pformat
|
||||
import pprint
|
||||
import sys
|
||||
|
||||
from mopidy import SettingsError, SETTINGS_PATH, SETTINGS_FILE
|
||||
from mopidy.utils.log import indent
|
||||
from mopidy.utils import log
|
||||
from mopidy.utils import path
|
||||
|
||||
logger = logging.getLogger('mopidy.utils.settings')
|
||||
|
||||
|
||||
class SettingsProxy(object):
|
||||
def __init__(self, default_settings_module):
|
||||
self.default = self._get_settings_dict_from_module(
|
||||
@ -38,7 +41,7 @@ class SettingsProxy(object):
|
||||
|
||||
@property
|
||||
def current(self):
|
||||
current = copy(self.default)
|
||||
current = copy.copy(self.default)
|
||||
current.update(self.local)
|
||||
current.update(self.runtime)
|
||||
return current
|
||||
@ -46,16 +49,18 @@ class SettingsProxy(object):
|
||||
def __getattr__(self, attr):
|
||||
if not self._is_setting(attr):
|
||||
return
|
||||
if attr not in self.current:
|
||||
|
||||
current = self.current # bind locally to avoid copying+updates
|
||||
if attr not in current:
|
||||
raise SettingsError(u'Setting "%s" is not set.' % attr)
|
||||
value = self.current[attr]
|
||||
|
||||
value = current[attr]
|
||||
if isinstance(value, basestring) and len(value) == 0:
|
||||
raise SettingsError(u'Setting "%s" is empty.' % attr)
|
||||
if not value:
|
||||
return value
|
||||
if attr.endswith('_PATH') or attr.endswith('_FILE'):
|
||||
value = os.path.expanduser(value)
|
||||
value = os.path.abspath(value)
|
||||
value = path.expand_path(value)
|
||||
return value
|
||||
|
||||
def __setattr__(self, attr, value):
|
||||
@ -69,7 +74,7 @@ class SettingsProxy(object):
|
||||
self._read_missing_settings_from_stdin(self.current, self.runtime)
|
||||
if self.get_errors():
|
||||
logger.error(u'Settings validation errors: %s',
|
||||
indent(self.get_errors_as_string()))
|
||||
log.indent(self.get_errors_as_string()))
|
||||
raise SettingsError(u'Settings validation failed.')
|
||||
|
||||
def _read_missing_settings_from_stdin(self, current, runtime):
|
||||
@ -101,7 +106,7 @@ def validate_settings(defaults, settings):
|
||||
Checks the settings for both errors like misspellings and against a set of
|
||||
rules for renamed settings, etc.
|
||||
|
||||
Returns of setting names with associated errors.
|
||||
Returns mapping from setting names to associated errors.
|
||||
|
||||
:param defaults: Mopidy's default settings
|
||||
:type defaults: dict
|
||||
@ -112,15 +117,20 @@ def validate_settings(defaults, settings):
|
||||
errors = {}
|
||||
|
||||
changed = {
|
||||
'CUSTOM_OUTPUT': 'OUTPUT',
|
||||
'DUMP_LOG_FILENAME': 'DEBUG_LOG_FILENAME',
|
||||
'DUMP_LOG_FORMAT': 'DEBUG_LOG_FORMAT',
|
||||
'FRONTEND': 'FRONTENDS',
|
||||
'GSTREAMER_AUDIO_SINK': 'CUSTOM_OUTPUT',
|
||||
'GSTREAMER_AUDIO_SINK': 'OUTPUT',
|
||||
'LOCAL_MUSIC_FOLDER': 'LOCAL_MUSIC_PATH',
|
||||
'LOCAL_OUTPUT_OVERRIDE': 'CUSTOM_OUTPUT',
|
||||
'LOCAL_OUTPUT_OVERRIDE': 'OUTPUT',
|
||||
'LOCAL_PLAYLIST_FOLDER': 'LOCAL_PLAYLIST_PATH',
|
||||
'LOCAL_TAG_CACHE': 'LOCAL_TAG_CACHE_FILE',
|
||||
'OUTPUT': None,
|
||||
'MIXER_ALSA_CONTROL': None,
|
||||
'MIXER_EXT_PORT': None,
|
||||
'MIXER_EXT_SPEAKERS_A': None,
|
||||
'MIXER_EXT_SPEAKERS_B': None,
|
||||
'MIXER_MAX_VOLUME': None,
|
||||
'SERVER': None,
|
||||
'SERVER_HOSTNAME': 'MPD_SERVER_HOSTNAME',
|
||||
'SERVER_PORT': 'MPD_SERVER_PORT',
|
||||
@ -136,26 +146,40 @@ def validate_settings(defaults, settings):
|
||||
else:
|
||||
errors[setting] = u'Deprecated setting. Use %s.' % (
|
||||
changed[setting],)
|
||||
continue
|
||||
|
||||
if setting == 'BACKENDS':
|
||||
elif setting == 'BACKENDS':
|
||||
if 'mopidy.backends.despotify.DespotifyBackend' in value:
|
||||
errors[setting] = (u'Deprecated setting value. ' +
|
||||
'"mopidy.backends.despotify.DespotifyBackend" is no ' +
|
||||
'longer available.')
|
||||
continue
|
||||
errors[setting] = (
|
||||
u'Deprecated setting value. '
|
||||
u'"mopidy.backends.despotify.DespotifyBackend" is no '
|
||||
u'longer available.')
|
||||
|
||||
if setting == 'SPOTIFY_BITRATE':
|
||||
elif setting == 'OUTPUTS':
|
||||
errors[setting] = (
|
||||
u'Deprecated setting, please change to OUTPUT. OUTPUT expects '
|
||||
u'a GStreamer bin description string for your desired output.')
|
||||
|
||||
elif setting == 'SPOTIFY_BITRATE':
|
||||
if value not in (96, 160, 320):
|
||||
errors[setting] = (u'Unavailable Spotify bitrate. ' +
|
||||
u'Available bitrates are 96, 160, and 320.')
|
||||
errors[setting] = (
|
||||
u'Unavailable Spotify bitrate. Available bitrates are 96, '
|
||||
u'160, and 320.')
|
||||
|
||||
if setting not in defaults:
|
||||
errors[setting] = u'Unknown setting. Is it misspelled?'
|
||||
continue
|
||||
elif setting.startswith('SHOUTCAST_OUTPUT_'):
|
||||
errors[setting] = (
|
||||
u'Deprecated setting, please set the value via the GStreamer '
|
||||
u'bin in OUTPUT.')
|
||||
|
||||
elif setting not in defaults:
|
||||
errors[setting] = u'Unknown setting.'
|
||||
suggestion = did_you_mean(setting, defaults)
|
||||
|
||||
if suggestion:
|
||||
errors[setting] += u' Did you mean %s?' % suggestion
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def list_settings_optparse_callback(*args):
|
||||
"""
|
||||
Prints a list of all settings.
|
||||
@ -167,22 +191,57 @@ def list_settings_optparse_callback(*args):
|
||||
print format_settings_list(settings)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def format_settings_list(settings):
|
||||
errors = settings.get_errors()
|
||||
lines = []
|
||||
for (key, value) in sorted(settings.current.iteritems()):
|
||||
default_value = settings.default.get(key)
|
||||
masked_value = mask_value_if_secret(key, value)
|
||||
lines.append(u'%s: %s' % (key, indent(pformat(masked_value), places=2)))
|
||||
lines.append(u'%s: %s' % (
|
||||
key, log.indent(pprint.pformat(masked_value), places=2)))
|
||||
if value != default_value and default_value is not None:
|
||||
lines.append(u' Default: %s' %
|
||||
indent(pformat(default_value), places=4))
|
||||
log.indent(pprint.pformat(default_value), places=4))
|
||||
if errors.get(key) is not None:
|
||||
lines.append(u' Error: %s' % errors[key])
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def mask_value_if_secret(key, value):
|
||||
if key.endswith('PASSWORD') and value:
|
||||
return u'********'
|
||||
else:
|
||||
return value
|
||||
|
||||
|
||||
def did_you_mean(setting, defaults):
|
||||
"""Suggest most likely setting based on levenshtein."""
|
||||
if not defaults:
|
||||
return None
|
||||
|
||||
setting = setting.upper()
|
||||
candidates = [(levenshtein(setting, d), d) for d in defaults]
|
||||
candidates.sort()
|
||||
|
||||
if candidates[0][0] <= 3:
|
||||
return candidates[0][1]
|
||||
return None
|
||||
|
||||
|
||||
def levenshtein(a, b, max=3):
|
||||
"""Calculates the Levenshtein distance between a and b."""
|
||||
n, m = len(a), len(b)
|
||||
if n > m:
|
||||
return levenshtein(b, a)
|
||||
|
||||
current = xrange(n+1)
|
||||
for i in xrange(1, m+1):
|
||||
previous, current = current, [i] + [0] * n
|
||||
for j in xrange(1, n+1):
|
||||
add, delete = previous[j] + 1, current[j-1] + 1
|
||||
change = previous[j-1]
|
||||
if a[j-1] != b[i-1]:
|
||||
change += 1
|
||||
current[j] = min(add, delete, change)
|
||||
return current[n]
|
||||
|
||||
67
tests/audio_test.py
Normal file
67
tests/audio_test.py
Normal file
@ -0,0 +1,67 @@
|
||||
import sys
|
||||
|
||||
from mopidy import audio, settings
|
||||
from mopidy.utils.path import path_to_uri
|
||||
|
||||
from tests import unittest, path_to_data_dir
|
||||
|
||||
|
||||
@unittest.skipIf(sys.platform == 'win32',
|
||||
'Our Windows build server does not support GStreamer yet')
|
||||
class AudioTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
settings.MIXER = 'fakemixer track_max_volume=65536'
|
||||
settings.OUTPUT = 'fakesink'
|
||||
self.song_uri = path_to_uri(path_to_data_dir('song1.wav'))
|
||||
self.audio = audio.Audio.start().proxy()
|
||||
|
||||
def tearDown(self):
|
||||
self.audio.stop()
|
||||
settings.runtime.clear()
|
||||
|
||||
def prepare_uri(self, uri):
|
||||
self.audio.prepare_change()
|
||||
self.audio.set_uri(uri)
|
||||
|
||||
def test_start_playback_existing_file(self):
|
||||
self.prepare_uri(self.song_uri)
|
||||
self.assertTrue(self.audio.start_playback().get())
|
||||
|
||||
def test_start_playback_non_existing_file(self):
|
||||
self.prepare_uri(self.song_uri + 'bogus')
|
||||
self.assertFalse(self.audio.start_playback().get())
|
||||
|
||||
def test_pause_playback_while_playing(self):
|
||||
self.prepare_uri(self.song_uri)
|
||||
self.audio.start_playback()
|
||||
self.assertTrue(self.audio.pause_playback().get())
|
||||
|
||||
def test_stop_playback_while_playing(self):
|
||||
self.prepare_uri(self.song_uri)
|
||||
self.audio.start_playback()
|
||||
self.assertTrue(self.audio.stop_playback().get())
|
||||
|
||||
@unittest.SkipTest
|
||||
def test_deliver_data(self):
|
||||
pass # TODO
|
||||
|
||||
@unittest.SkipTest
|
||||
def test_end_of_data_stream(self):
|
||||
pass # TODO
|
||||
|
||||
def test_set_volume(self):
|
||||
for value in range(0, 101):
|
||||
self.assertTrue(self.audio.set_volume(value).get())
|
||||
self.assertEqual(value, self.audio.get_volume().get())
|
||||
|
||||
@unittest.SkipTest
|
||||
def test_set_state_encapsulation(self):
|
||||
pass # TODO
|
||||
|
||||
@unittest.SkipTest
|
||||
def test_set_position(self):
|
||||
pass # TODO
|
||||
|
||||
@unittest.SkipTest
|
||||
def test_invalid_output_raises_error(self):
|
||||
pass # TODO
|
||||
@ -1,8 +1,9 @@
|
||||
import mock
|
||||
import random
|
||||
|
||||
from mopidy import audio
|
||||
from mopidy.core import PlaybackState
|
||||
from mopidy.models import CpTrack, Playlist, Track
|
||||
from mopidy.gstreamer import GStreamer
|
||||
|
||||
from tests.backends.base import populate_playlist
|
||||
|
||||
@ -12,7 +13,7 @@ class CurrentPlaylistControllerTest(object):
|
||||
|
||||
def setUp(self):
|
||||
self.backend = self.backend_class()
|
||||
self.backend.gstreamer = mock.Mock(spec=GStreamer)
|
||||
self.backend.audio = mock.Mock(spec=audio.Audio)
|
||||
self.controller = self.backend.current_playlist
|
||||
self.playback = self.backend.playback
|
||||
|
||||
@ -71,9 +72,9 @@ class CurrentPlaylistControllerTest(object):
|
||||
@populate_playlist
|
||||
def test_clear_when_playing(self):
|
||||
self.playback.play()
|
||||
self.assertEqual(self.playback.state, self.playback.PLAYING)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
self.controller.clear()
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
|
||||
def test_get_by_uri_returns_unique_match(self):
|
||||
track = Track(uri='a')
|
||||
@ -134,13 +135,13 @@ class CurrentPlaylistControllerTest(object):
|
||||
self.playback.play()
|
||||
track = self.playback.current_track
|
||||
self.controller.append(self.controller.tracks[1:2])
|
||||
self.assertEqual(self.playback.state, self.playback.PLAYING)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
self.assertEqual(self.playback.current_track, track)
|
||||
|
||||
@populate_playlist
|
||||
def test_append_preserves_stopped_state(self):
|
||||
self.controller.append(self.controller.tracks[1:2])
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
self.assertEqual(self.playback.current_track, None)
|
||||
|
||||
def test_index_returns_index_of_track(self):
|
||||
@ -205,7 +206,7 @@ class CurrentPlaylistControllerTest(object):
|
||||
version = self.controller.version
|
||||
self.controller.remove(uri=track1.uri)
|
||||
self.assert_(version < self.controller.version)
|
||||
self.assert_(track1 not in self.controller.tracks)
|
||||
self.assertNotIn(track1, self.controller.tracks)
|
||||
self.assertEqual(track2, self.controller.tracks[1])
|
||||
|
||||
@populate_playlist
|
||||
|
||||
@ -34,8 +34,8 @@ class LibraryControllerTest(object):
|
||||
self.assertEqual(track, self.tracks[0])
|
||||
|
||||
def test_lookup_unknown_track(self):
|
||||
test = lambda: self.library.lookup('fake uri')
|
||||
self.assertRaises(LookupError, test)
|
||||
track = self.library.lookup('fake uri')
|
||||
self.assertEquals(track, None)
|
||||
|
||||
def test_find_exact_no_hits(self):
|
||||
result = self.library.find_exact(track=['unknown track'])
|
||||
|
||||
@ -2,8 +2,9 @@ import mock
|
||||
import random
|
||||
import time
|
||||
|
||||
from mopidy import audio
|
||||
from mopidy.core import PlaybackState
|
||||
from mopidy.models import Track
|
||||
from mopidy.gstreamer import GStreamer
|
||||
|
||||
from tests import unittest
|
||||
from tests.backends.base import populate_playlist
|
||||
@ -16,7 +17,7 @@ class PlaybackControllerTest(object):
|
||||
|
||||
def setUp(self):
|
||||
self.backend = self.backend_class()
|
||||
self.backend.gstreamer = mock.Mock(spec=GStreamer)
|
||||
self.backend.audio = mock.Mock(spec=audio.Audio)
|
||||
self.playback = self.backend.playback
|
||||
self.current_playlist = self.backend.current_playlist
|
||||
|
||||
@ -26,21 +27,21 @@ class PlaybackControllerTest(object):
|
||||
'First song needs to be at least 2000 miliseconds'
|
||||
|
||||
def test_initial_state_is_stopped(self):
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
|
||||
def test_play_with_empty_playlist(self):
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
self.playback.play()
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
|
||||
def test_play_with_empty_playlist_return_value(self):
|
||||
self.assertEqual(self.playback.play(), None)
|
||||
|
||||
@populate_playlist
|
||||
def test_play_state(self):
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
self.playback.play()
|
||||
self.assertEqual(self.playback.state, self.playback.PLAYING)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
|
||||
@populate_playlist
|
||||
def test_play_return_value(self):
|
||||
@ -48,9 +49,9 @@ class PlaybackControllerTest(object):
|
||||
|
||||
@populate_playlist
|
||||
def test_play_track_state(self):
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
self.playback.play(self.current_playlist.cp_tracks[-1])
|
||||
self.assertEqual(self.playback.state, self.playback.PLAYING)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
|
||||
@populate_playlist
|
||||
def test_play_track_return_value(self):
|
||||
@ -70,7 +71,7 @@ class PlaybackControllerTest(object):
|
||||
track = self.playback.current_track
|
||||
self.playback.pause()
|
||||
self.playback.play()
|
||||
self.assertEqual(self.playback.state, self.playback.PLAYING)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
self.assertEqual(track, self.playback.current_track)
|
||||
|
||||
@populate_playlist
|
||||
@ -81,7 +82,7 @@ class PlaybackControllerTest(object):
|
||||
track = self.playback.current_track
|
||||
self.playback.pause()
|
||||
self.playback.play()
|
||||
self.assertEqual(self.playback.state, self.playback.PLAYING)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
self.assertEqual(track, self.playback.current_track)
|
||||
|
||||
@populate_playlist
|
||||
@ -106,12 +107,12 @@ class PlaybackControllerTest(object):
|
||||
def test_current_track_after_completed_playlist(self):
|
||||
self.playback.play(self.current_playlist.cp_tracks[-1])
|
||||
self.playback.on_end_of_track()
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
self.assertEqual(self.playback.current_track, None)
|
||||
|
||||
self.playback.play(self.current_playlist.cp_tracks[-1])
|
||||
self.playback.next()
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
self.assertEqual(self.playback.current_track, None)
|
||||
|
||||
@populate_playlist
|
||||
@ -141,17 +142,17 @@ class PlaybackControllerTest(object):
|
||||
self.playback.next()
|
||||
self.playback.stop()
|
||||
self.playback.previous()
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
|
||||
@populate_playlist
|
||||
def test_previous_at_start_of_playlist(self):
|
||||
self.playback.previous()
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
self.assertEqual(self.playback.current_track, None)
|
||||
|
||||
def test_previous_for_empty_playlist(self):
|
||||
self.playback.previous()
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
self.assertEqual(self.playback.current_track, None)
|
||||
|
||||
@populate_playlist
|
||||
@ -185,20 +186,20 @@ class PlaybackControllerTest(object):
|
||||
@populate_playlist
|
||||
def test_next_does_not_trigger_playback(self):
|
||||
self.playback.next()
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
|
||||
@populate_playlist
|
||||
def test_next_at_end_of_playlist(self):
|
||||
self.playback.play()
|
||||
|
||||
for i, track in enumerate(self.tracks):
|
||||
self.assertEqual(self.playback.state, self.playback.PLAYING)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
self.assertEqual(self.playback.current_track, track)
|
||||
self.assertEqual(self.playback.current_playlist_position, i)
|
||||
|
||||
self.playback.next()
|
||||
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
|
||||
@populate_playlist
|
||||
def test_next_until_end_of_playlist_and_play_from_start(self):
|
||||
@ -208,15 +209,15 @@ class PlaybackControllerTest(object):
|
||||
self.playback.next()
|
||||
|
||||
self.assertEqual(self.playback.current_track, None)
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
|
||||
self.playback.play()
|
||||
self.assertEqual(self.playback.state, self.playback.PLAYING)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
self.assertEqual(self.playback.current_track, self.tracks[0])
|
||||
|
||||
def test_next_for_empty_playlist(self):
|
||||
self.playback.next()
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
|
||||
@populate_playlist
|
||||
def test_next_skips_to_next_track_on_failure(self):
|
||||
@ -273,7 +274,7 @@ class PlaybackControllerTest(object):
|
||||
self.playback.consume = True
|
||||
self.playback.play()
|
||||
self.playback.next()
|
||||
self.assert_(self.tracks[0] in self.backend.current_playlist.tracks)
|
||||
self.assertIn(self.tracks[0], self.backend.current_playlist.tracks)
|
||||
|
||||
@populate_playlist
|
||||
def test_next_with_single_and_repeat(self):
|
||||
@ -321,20 +322,20 @@ class PlaybackControllerTest(object):
|
||||
@populate_playlist
|
||||
def test_end_of_track_does_not_trigger_playback(self):
|
||||
self.playback.on_end_of_track()
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
|
||||
@populate_playlist
|
||||
def test_end_of_track_at_end_of_playlist(self):
|
||||
self.playback.play()
|
||||
|
||||
for i, track in enumerate(self.tracks):
|
||||
self.assertEqual(self.playback.state, self.playback.PLAYING)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
self.assertEqual(self.playback.current_track, track)
|
||||
self.assertEqual(self.playback.current_playlist_position, i)
|
||||
|
||||
self.playback.on_end_of_track()
|
||||
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
|
||||
@populate_playlist
|
||||
def test_end_of_track_until_end_of_playlist_and_play_from_start(self):
|
||||
@ -344,15 +345,15 @@ class PlaybackControllerTest(object):
|
||||
self.playback.on_end_of_track()
|
||||
|
||||
self.assertEqual(self.playback.current_track, None)
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
|
||||
self.playback.play()
|
||||
self.assertEqual(self.playback.state, self.playback.PLAYING)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
self.assertEqual(self.playback.current_track, self.tracks[0])
|
||||
|
||||
def test_end_of_track_for_empty_playlist(self):
|
||||
self.playback.on_end_of_track()
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
|
||||
@populate_playlist
|
||||
def test_end_of_track_skips_to_next_track_on_failure(self):
|
||||
@ -410,7 +411,7 @@ class PlaybackControllerTest(object):
|
||||
self.playback.consume = True
|
||||
self.playback.play()
|
||||
self.playback.on_end_of_track()
|
||||
self.assert_(self.tracks[0] not in self.backend.current_playlist.tracks)
|
||||
self.assertNotIn(self.tracks[0], self.backend.current_playlist.tracks)
|
||||
|
||||
@populate_playlist
|
||||
def test_end_of_track_with_random(self):
|
||||
@ -534,13 +535,13 @@ class PlaybackControllerTest(object):
|
||||
self.playback.play()
|
||||
current_track = self.playback.current_track
|
||||
self.backend.current_playlist.append([self.tracks[2]])
|
||||
self.assertEqual(self.playback.state, self.playback.PLAYING)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
self.assertEqual(self.playback.current_track, current_track)
|
||||
|
||||
@populate_playlist
|
||||
def test_on_current_playlist_change_when_stopped(self):
|
||||
self.backend.current_playlist.append([self.tracks[2]])
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
self.assertEqual(self.playback.current_track, None)
|
||||
|
||||
@populate_playlist
|
||||
@ -549,26 +550,26 @@ class PlaybackControllerTest(object):
|
||||
self.playback.pause()
|
||||
current_track = self.playback.current_track
|
||||
self.backend.current_playlist.append([self.tracks[2]])
|
||||
self.assertEqual(self.playback.state, self.backend.playback.PAUSED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PAUSED)
|
||||
self.assertEqual(self.playback.current_track, current_track)
|
||||
|
||||
@populate_playlist
|
||||
def test_pause_when_stopped(self):
|
||||
self.playback.pause()
|
||||
self.assertEqual(self.playback.state, self.playback.PAUSED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PAUSED)
|
||||
|
||||
@populate_playlist
|
||||
def test_pause_when_playing(self):
|
||||
self.playback.play()
|
||||
self.playback.pause()
|
||||
self.assertEqual(self.playback.state, self.playback.PAUSED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PAUSED)
|
||||
|
||||
@populate_playlist
|
||||
def test_pause_when_paused(self):
|
||||
self.playback.play()
|
||||
self.playback.pause()
|
||||
self.playback.pause()
|
||||
self.assertEqual(self.playback.state, self.playback.PAUSED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PAUSED)
|
||||
|
||||
@populate_playlist
|
||||
def test_pause_return_value(self):
|
||||
@ -578,20 +579,20 @@ class PlaybackControllerTest(object):
|
||||
@populate_playlist
|
||||
def test_resume_when_stopped(self):
|
||||
self.playback.resume()
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
|
||||
@populate_playlist
|
||||
def test_resume_when_playing(self):
|
||||
self.playback.play()
|
||||
self.playback.resume()
|
||||
self.assertEqual(self.playback.state, self.playback.PLAYING)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
|
||||
@populate_playlist
|
||||
def test_resume_when_paused(self):
|
||||
self.playback.play()
|
||||
self.playback.pause()
|
||||
self.playback.resume()
|
||||
self.assertEqual(self.playback.state, self.playback.PLAYING)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
|
||||
@populate_playlist
|
||||
def test_resume_return_value(self):
|
||||
@ -624,12 +625,12 @@ class PlaybackControllerTest(object):
|
||||
|
||||
def test_seek_on_empty_playlist_updates_position(self):
|
||||
self.playback.seek(0)
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
|
||||
@populate_playlist
|
||||
def test_seek_when_stopped_triggers_play(self):
|
||||
self.playback.seek(0)
|
||||
self.assertEqual(self.playback.state, self.playback.PLAYING)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
|
||||
@populate_playlist
|
||||
def test_seek_when_playing(self):
|
||||
@ -666,7 +667,7 @@ class PlaybackControllerTest(object):
|
||||
self.playback.play()
|
||||
self.playback.pause()
|
||||
self.playback.seek(0)
|
||||
self.assertEqual(self.playback.state, self.playback.PLAYING)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
|
||||
@unittest.SkipTest
|
||||
@populate_playlist
|
||||
@ -686,7 +687,7 @@ class PlaybackControllerTest(object):
|
||||
def test_seek_beyond_end_of_song_for_last_track(self):
|
||||
self.playback.play(self.current_playlist.cp_tracks[-1])
|
||||
self.playback.seek(self.current_playlist.tracks[-1].length * 100)
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
|
||||
@unittest.SkipTest
|
||||
@populate_playlist
|
||||
@ -702,25 +703,25 @@ class PlaybackControllerTest(object):
|
||||
self.playback.seek(-1000)
|
||||
position = self.playback.time_position
|
||||
self.assert_(position >= 0, position)
|
||||
self.assertEqual(self.playback.state, self.playback.PLAYING)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
|
||||
@populate_playlist
|
||||
def test_stop_when_stopped(self):
|
||||
self.playback.stop()
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
|
||||
@populate_playlist
|
||||
def test_stop_when_playing(self):
|
||||
self.playback.play()
|
||||
self.playback.stop()
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
|
||||
@populate_playlist
|
||||
def test_stop_when_paused(self):
|
||||
self.playback.play()
|
||||
self.playback.pause()
|
||||
self.playback.stop()
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
|
||||
def test_stop_return_value(self):
|
||||
self.playback.play()
|
||||
@ -729,7 +730,7 @@ class PlaybackControllerTest(object):
|
||||
def test_time_position_when_stopped(self):
|
||||
future = mock.Mock()
|
||||
future.get = mock.Mock(return_value=0)
|
||||
self.backend.gstreamer.get_position = mock.Mock(return_value=future)
|
||||
self.backend.audio.get_position = mock.Mock(return_value=future)
|
||||
|
||||
self.assertEqual(self.playback.time_position, 0)
|
||||
|
||||
@ -737,7 +738,7 @@ class PlaybackControllerTest(object):
|
||||
def test_time_position_when_stopped_with_playlist(self):
|
||||
future = mock.Mock()
|
||||
future.get = mock.Mock(return_value=0)
|
||||
self.backend.gstreamer.get_position = mock.Mock(return_value=future)
|
||||
self.backend.audio.get_position = mock.Mock(return_value=future)
|
||||
|
||||
self.assertEqual(self.playback.time_position, 0)
|
||||
|
||||
@ -810,7 +811,7 @@ class PlaybackControllerTest(object):
|
||||
def test_end_of_playlist_stops(self):
|
||||
self.playback.play(self.current_playlist.cp_tracks[-1])
|
||||
self.playback.on_end_of_track()
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
|
||||
def test_repeat_off_by_default(self):
|
||||
self.assertEqual(self.playback.repeat, False)
|
||||
@ -835,9 +836,9 @@ class PlaybackControllerTest(object):
|
||||
for _ in self.tracks:
|
||||
self.playback.next()
|
||||
self.assertNotEqual(self.playback.track_at_next, None)
|
||||
self.assertEqual(self.playback.state, self.playback.STOPPED)
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
self.playback.play()
|
||||
self.assertEqual(self.playback.state, self.playback.PLAYING)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
|
||||
@populate_playlist
|
||||
def test_random_until_end_of_playlist_with_repeat(self):
|
||||
@ -854,7 +855,7 @@ class PlaybackControllerTest(object):
|
||||
self.playback.play()
|
||||
played = []
|
||||
for _ in self.tracks:
|
||||
self.assert_(self.playback.current_track not in played)
|
||||
self.assertNotIn(self.playback.current_track, played)
|
||||
played.append(self.playback.current_track)
|
||||
self.playback.next()
|
||||
|
||||
|
||||
@ -30,7 +30,7 @@ class StoredPlaylistsControllerTest(object):
|
||||
def test_create_in_playlists(self):
|
||||
playlist = self.stored.create('test')
|
||||
self.assert_(self.stored.playlists)
|
||||
self.assert_(playlist in self.stored.playlists)
|
||||
self.assertIn(playlist, self.stored.playlists)
|
||||
|
||||
def test_playlists_empty_to_start_with(self):
|
||||
self.assert_(not self.stored.playlists)
|
||||
@ -101,7 +101,7 @@ class StoredPlaylistsControllerTest(object):
|
||||
# FIXME should we handle playlists without names?
|
||||
playlist = Playlist(name='test')
|
||||
self.stored.save(playlist)
|
||||
self.assert_(playlist in self.stored.playlists)
|
||||
self.assertIn(playlist, self.stored.playlists)
|
||||
|
||||
@unittest.SkipTest
|
||||
def test_playlist_with_unknown_track(self):
|
||||
|
||||
@ -2,6 +2,7 @@ import sys
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.backends.local import LocalBackend
|
||||
from mopidy.core import PlaybackState
|
||||
from mopidy.models import Track
|
||||
from mopidy.utils.path import path_to_uri
|
||||
|
||||
@ -34,19 +35,19 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase):
|
||||
self.backend.current_playlist.add(track)
|
||||
|
||||
def test_uri_scheme(self):
|
||||
self.assert_('file' in self.backend.uri_schemes)
|
||||
self.assertIn('file', self.backend.uri_schemes)
|
||||
|
||||
def test_play_mp3(self):
|
||||
self.add_track('blank.mp3')
|
||||
self.playback.play()
|
||||
self.assertEqual(self.playback.state, self.playback.PLAYING)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
|
||||
def test_play_ogg(self):
|
||||
self.add_track('blank.ogg')
|
||||
self.playback.play()
|
||||
self.assertEqual(self.playback.state, self.playback.PLAYING)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
|
||||
def test_play_flac(self):
|
||||
self.add_track('blank.flac')
|
||||
self.playback.play()
|
||||
self.assertEqual(self.playback.state, self.playback.PLAYING)
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
|
||||
@ -9,6 +9,7 @@ from mopidy.models import Track, Artist, Album
|
||||
|
||||
from tests import unittest, path_to_data_dir
|
||||
|
||||
data_dir = path_to_data_dir('')
|
||||
song1_path = path_to_data_dir('song1.mp3')
|
||||
song2_path = path_to_data_dir('song2.mp3')
|
||||
encoded_path = path_to_data_dir(u'æøå.mp3')
|
||||
@ -21,22 +22,32 @@ encoded_uri = path_to_uri(encoded_path)
|
||||
|
||||
class M3UToUriTest(unittest.TestCase):
|
||||
def test_empty_file(self):
|
||||
uris = parse_m3u(path_to_data_dir('empty.m3u'))
|
||||
uris = parse_m3u(path_to_data_dir('empty.m3u'), data_dir)
|
||||
self.assertEqual([], uris)
|
||||
|
||||
def test_basic_file(self):
|
||||
uris = parse_m3u(path_to_data_dir('one.m3u'))
|
||||
uris = parse_m3u(path_to_data_dir('one.m3u'), data_dir)
|
||||
self.assertEqual([song1_uri], uris)
|
||||
|
||||
def test_file_with_comment(self):
|
||||
uris = parse_m3u(path_to_data_dir('comment.m3u'))
|
||||
uris = parse_m3u(path_to_data_dir('comment.m3u'), data_dir)
|
||||
self.assertEqual([song1_uri], uris)
|
||||
|
||||
def test_file_is_relative_to_correct_folder(self):
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
||||
tmp.write('song1.mp3')
|
||||
try:
|
||||
uris = parse_m3u(tmp.name, data_dir)
|
||||
self.assertEqual([song1_uri], uris)
|
||||
finally:
|
||||
if os.path.exists(tmp.name):
|
||||
os.remove(tmp.name)
|
||||
|
||||
def test_file_with_absolute_files(self):
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
||||
tmp.write(song1_path)
|
||||
try:
|
||||
uris = parse_m3u(tmp.name)
|
||||
uris = parse_m3u(tmp.name, data_dir)
|
||||
self.assertEqual([song1_uri], uris)
|
||||
finally:
|
||||
if os.path.exists(tmp.name):
|
||||
@ -48,29 +59,28 @@ class M3UToUriTest(unittest.TestCase):
|
||||
tmp.write('# comment \n')
|
||||
tmp.write(song2_path)
|
||||
try:
|
||||
uris = parse_m3u(tmp.name)
|
||||
uris = parse_m3u(tmp.name, data_dir)
|
||||
self.assertEqual([song1_uri, song2_uri], uris)
|
||||
finally:
|
||||
if os.path.exists(tmp.name):
|
||||
os.remove(tmp.name)
|
||||
|
||||
|
||||
def test_file_with_uri(self):
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
||||
tmp.write(song1_uri)
|
||||
try:
|
||||
uris = parse_m3u(tmp.name)
|
||||
uris = parse_m3u(tmp.name, data_dir)
|
||||
self.assertEqual([song1_uri], uris)
|
||||
finally:
|
||||
if os.path.exists(tmp.name):
|
||||
os.remove(tmp.name)
|
||||
|
||||
def test_encoding_is_latin1(self):
|
||||
uris = parse_m3u(path_to_data_dir('encoding.m3u'))
|
||||
uris = parse_m3u(path_to_data_dir('encoding.m3u'), data_dir)
|
||||
self.assertEqual([encoded_uri], uris)
|
||||
|
||||
def test_open_missing_file(self):
|
||||
uris = parse_m3u(path_to_data_dir('non-existant.m3u'))
|
||||
uris = parse_m3u(path_to_data_dir('non-existant.m3u'), data_dir)
|
||||
self.assertEqual([], uris)
|
||||
|
||||
|
||||
|
||||
BIN
tests/data/.blank.mp3
Normal file
BIN
tests/data/.blank.mp3
Normal file
Binary file not shown.
@ -2,7 +2,6 @@ from mopidy.backends.dummy import DummyBackend
|
||||
from mopidy.frontends.mpd.dispatcher import MpdDispatcher
|
||||
from mopidy.frontends.mpd.exceptions import MpdAckError
|
||||
from mopidy.frontends.mpd.protocol import request_handlers, handle_request
|
||||
from mopidy.mixers.dummy import DummyMixer
|
||||
|
||||
from tests import unittest
|
||||
|
||||
@ -10,12 +9,10 @@ from tests import unittest
|
||||
class MpdDispatcherTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.backend = DummyBackend.start().proxy()
|
||||
self.mixer = DummyMixer.start().proxy()
|
||||
self.dispatcher = MpdDispatcher()
|
||||
|
||||
def tearDown(self):
|
||||
self.backend.stop().get()
|
||||
self.mixer.stop().get()
|
||||
|
||||
def test_register_same_pattern_twice_fails(self):
|
||||
func = lambda: None
|
||||
@ -40,7 +37,7 @@ class MpdDispatcherTest(unittest.TestCase):
|
||||
expected_handler
|
||||
(handler, kwargs) = self.dispatcher._find_handler('known_command an_arg')
|
||||
self.assertEqual(handler, expected_handler)
|
||||
self.assert_('arg1' in kwargs)
|
||||
self.assertIn('arg1', kwargs)
|
||||
self.assertEqual(kwargs['arg1'], 'an_arg')
|
||||
|
||||
def test_handling_unknown_request_yields_error(self):
|
||||
@ -51,5 +48,5 @@ class MpdDispatcherTest(unittest.TestCase):
|
||||
expected = 'magic'
|
||||
request_handlers['known request'] = lambda x: expected
|
||||
result = self.dispatcher.handle_request('known request')
|
||||
self.assert_(u'OK' in result)
|
||||
self.assert_(expected in result)
|
||||
self.assertIn(u'OK', result)
|
||||
self.assertIn(expected, result)
|
||||
|
||||
@ -3,7 +3,6 @@ import mock
|
||||
from mopidy import settings
|
||||
from mopidy.backends import dummy as backend
|
||||
from mopidy.frontends import mpd
|
||||
from mopidy.mixers import dummy as mixer
|
||||
|
||||
from tests import unittest
|
||||
|
||||
@ -23,7 +22,6 @@ class MockConnection(mock.Mock):
|
||||
class BaseTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.backend = backend.DummyBackend.start().proxy()
|
||||
self.mixer = mixer.DummyMixer.start().proxy()
|
||||
|
||||
self.connection = MockConnection()
|
||||
self.session = mpd.MpdSession(self.connection)
|
||||
@ -32,7 +30,6 @@ class BaseTestCase(unittest.TestCase):
|
||||
|
||||
def tearDown(self):
|
||||
self.backend.stop().get()
|
||||
self.mixer.stop().get()
|
||||
settings.runtime.clear()
|
||||
|
||||
def sendRequest(self, request):
|
||||
@ -45,7 +42,7 @@ class BaseTestCase(unittest.TestCase):
|
||||
self.assertEqual([], self.connection.response)
|
||||
|
||||
def assertInResponse(self, value):
|
||||
self.assert_(value in self.connection.response, u'Did not find %s '
|
||||
self.assertIn(value, self.connection.response, u'Did not find %s '
|
||||
'in %s' % (repr(value), repr(self.connection.response)))
|
||||
|
||||
def assertOnceInResponse(self, value):
|
||||
@ -54,7 +51,7 @@ class BaseTestCase(unittest.TestCase):
|
||||
(repr(value), repr(self.connection.response)))
|
||||
|
||||
def assertNotInResponse(self, value):
|
||||
self.assert_(value not in self.connection.response, u'Found %s in %s' %
|
||||
self.assertNotIn(value, self.connection.response, u'Found %s in %s' %
|
||||
(repr(value), repr(self.connection.response)))
|
||||
|
||||
def assertEqualResponse(self, value):
|
||||
|
||||
@ -38,7 +38,6 @@ class AuthenticationTest(protocol.BaseTestCase):
|
||||
|
||||
self.sendRequest(u'close')
|
||||
self.assertFalse(self.dispatcher.authenticated)
|
||||
self.assertInResponse(u'OK')
|
||||
|
||||
def test_commands_is_allowed_without_authentication(self):
|
||||
settings.MPD_SERVER_PASSWORD = u'topsecret'
|
||||
|
||||
@ -21,7 +21,7 @@ class CommandListsTest(protocol.BaseTestCase):
|
||||
self.assertEqual([], self.dispatcher.command_list)
|
||||
self.assertEqual(False, self.dispatcher.command_list_ok)
|
||||
self.sendRequest(u'ping')
|
||||
self.assert_(u'ping' in self.dispatcher.command_list)
|
||||
self.assertIn(u'ping', self.dispatcher.command_list)
|
||||
self.sendRequest(u'command_list_end')
|
||||
self.assertInResponse(u'OK')
|
||||
self.assertEqual(False, self.dispatcher.command_list)
|
||||
@ -42,7 +42,7 @@ class CommandListsTest(protocol.BaseTestCase):
|
||||
self.assertEqual([], self.dispatcher.command_list)
|
||||
self.assertEqual(True, self.dispatcher.command_list_ok)
|
||||
self.sendRequest(u'ping')
|
||||
self.assert_(u'ping' in self.dispatcher.command_list)
|
||||
self.assertIn(u'ping', self.dispatcher.command_list)
|
||||
self.sendRequest(u'command_list_end')
|
||||
self.assertInResponse(u'list_OK')
|
||||
self.assertInResponse(u'OK')
|
||||
|
||||
@ -285,6 +285,8 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase):
|
||||
self.assertInResponse(u'OK')
|
||||
|
||||
def test_playlistinfo_with_songpos(self):
|
||||
# Make the track's CPID not match the playlist position
|
||||
self.backend.current_playlist.cp_id = 17
|
||||
self.backend.current_playlist.append([
|
||||
Track(name='a'), Track(name='b'), Track(name='c'),
|
||||
Track(name='d'), Track(name='e'), Track(name='f'),
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
from mopidy.backends import base as backend
|
||||
from mopidy.core import PlaybackState
|
||||
from mopidy.models import Track
|
||||
|
||||
from tests import unittest
|
||||
from tests.frontends.mpd import protocol
|
||||
|
||||
PAUSED = backend.PlaybackController.PAUSED
|
||||
PLAYING = backend.PlaybackController.PLAYING
|
||||
STOPPED = backend.PlaybackController.STOPPED
|
||||
|
||||
PAUSED = PlaybackState.PAUSED
|
||||
PLAYING = PlaybackState.PLAYING
|
||||
STOPPED = PlaybackState.STOPPED
|
||||
|
||||
|
||||
class PlaybackOptionsHandlerTest(protocol.BaseTestCase):
|
||||
@ -76,37 +77,37 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase):
|
||||
|
||||
def test_setvol_below_min(self):
|
||||
self.sendRequest(u'setvol "-10"')
|
||||
self.assertEqual(0, self.mixer.volume.get())
|
||||
self.assertEqual(0, self.backend.playback.volume.get())
|
||||
self.assertInResponse(u'OK')
|
||||
|
||||
def test_setvol_min(self):
|
||||
self.sendRequest(u'setvol "0"')
|
||||
self.assertEqual(0, self.mixer.volume.get())
|
||||
self.assertEqual(0, self.backend.playback.volume.get())
|
||||
self.assertInResponse(u'OK')
|
||||
|
||||
def test_setvol_middle(self):
|
||||
self.sendRequest(u'setvol "50"')
|
||||
self.assertEqual(50, self.mixer.volume.get())
|
||||
self.assertEqual(50, self.backend.playback.volume.get())
|
||||
self.assertInResponse(u'OK')
|
||||
|
||||
def test_setvol_max(self):
|
||||
self.sendRequest(u'setvol "100"')
|
||||
self.assertEqual(100, self.mixer.volume.get())
|
||||
self.assertEqual(100, self.backend.playback.volume.get())
|
||||
self.assertInResponse(u'OK')
|
||||
|
||||
def test_setvol_above_max(self):
|
||||
self.sendRequest(u'setvol "110"')
|
||||
self.assertEqual(100, self.mixer.volume.get())
|
||||
self.assertEqual(100, self.backend.playback.volume.get())
|
||||
self.assertInResponse(u'OK')
|
||||
|
||||
def test_setvol_plus_is_ignored(self):
|
||||
self.sendRequest(u'setvol "+10"')
|
||||
self.assertEqual(10, self.mixer.volume.get())
|
||||
self.assertEqual(10, self.backend.playback.volume.get())
|
||||
self.assertInResponse(u'OK')
|
||||
|
||||
def test_setvol_without_quotes(self):
|
||||
self.sendRequest(u'setvol 50')
|
||||
self.assertEqual(50, self.mixer.volume.get())
|
||||
self.assertEqual(50, self.backend.playback.volume.get())
|
||||
self.assertInResponse(u'OK')
|
||||
|
||||
def test_single_off(self):
|
||||
@ -286,6 +287,13 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase):
|
||||
self.assertEqual(PLAYING, self.backend.playback.state.get())
|
||||
self.assertInResponse(u'OK')
|
||||
|
||||
def test_playid_without_quotes(self):
|
||||
self.backend.current_playlist.append([Track()])
|
||||
|
||||
self.sendRequest(u'playid 0')
|
||||
self.assertEqual(PLAYING, self.backend.playback.state.get())
|
||||
self.assertInResponse(u'OK')
|
||||
|
||||
def test_playid_minus_one_plays_first_in_playlist_if_no_current_track(self):
|
||||
self.assertEqual(self.backend.playback.current_track.get(), None)
|
||||
self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')])
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user