Release v0.8.0

This commit is contained in:
Stein Magnus Jodal 2012-09-20 01:05:04 +02:00
commit 587616ebf7
116 changed files with 3725 additions and 3379 deletions

4
.mailmap Normal file
View 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
View 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

View File

@ -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

View File

@ -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
View 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:

View File

@ -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`

View File

@ -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:

View File

@ -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
View 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:

View File

@ -5,7 +5,10 @@ API reference
.. toctree::
:glob:
backends/concepts
backends/controllers
backends/providers
*
concepts
models
backends
core
audio
frontends
listeners

View File

@ -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`

View File

@ -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`

View File

@ -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.

View File

@ -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".

View File

@ -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.

View File

@ -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
View 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.

View File

@ -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.

View File

@ -1,9 +0,0 @@
***********
Development
***********
.. toctree::
:maxdepth: 3
roadmap
contributing

View File

@ -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.

View File

@ -54,7 +54,7 @@ Development documentation
.. toctree::
:maxdepth: 3
development/index
development
Indices and tables
==================

View File

@ -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'

View File

@ -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

View File

@ -1,7 +0,0 @@
********************************************
:mod:`mopidy.gstreamer` -- GStreamer adapter
********************************************
.. automodule:: mopidy.gstreamer
:synopsis: GStreamer adapter
:members:

View File

@ -1,7 +0,0 @@
*************************************************
:mod:`mopidy.mixers.alsa` -- ALSA mixer for Linux
*************************************************
.. automodule:: mopidy.mixers.alsa
:synopsis: ALSA mixer for Linux
:members:

View File

@ -1,7 +0,0 @@
*****************************************************************
:mod:`mopidy.mixers.denon` -- Hardware mixer for Denon amplifiers
*****************************************************************
.. automodule:: mopidy.mixers.denon
:synopsis: Hardware mixer for Denon amplifiers
:members:

View File

@ -1,7 +0,0 @@
*****************************************************
:mod:`mopidy.mixers.dummy` -- Dummy mixer for testing
*****************************************************
.. automodule:: mopidy.mixers.dummy
:synopsis: Dummy mixer for testing
:members:

View File

@ -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:

View File

@ -1,7 +0,0 @@
*************************************************************
:mod:`mopidy.mixers.nad` -- Hardware mixer for NAD amplifiers
*************************************************************
.. automodule:: mopidy.mixers.nad
:synopsis: Hardware mixer for NAD amplifiers
:members:

View File

@ -1,7 +0,0 @@
**********************************************
:mod:`mopidy.mixers.osa` -- Osa mixer for OS X
**********************************************
.. automodule:: mopidy.mixers.osa
:synopsis: Osa mixer for OS X
:members:

View File

@ -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

View File

@ -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

View File

@ -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')

View File

@ -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
View 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)

View 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

View 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)

View 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
View 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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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):

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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"""

View File

@ -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(

View File

@ -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
View File

@ -0,0 +1,4 @@
from .current_playlist import CurrentPlaylistController
from .library import LibraryController
from .playback import PlaybackController, PlaybackState
from .stored_playlists import StoredPlaylistsController

View File

@ -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
View 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
View 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')

View 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)

View File

@ -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

View File

@ -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:

View File

@ -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>[^"]+)"')

View File

@ -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])"$')

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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')

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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())

View File

@ -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."""

View File

@ -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)

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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
View 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

View File

@ -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('')

View File

@ -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

View File

@ -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):

View File

@ -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
View 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

View File

@ -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

View File

@ -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'])

View File

@ -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()

View File

@ -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):

View File

@ -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)

View File

@ -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

Binary file not shown.

View File

@ -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)

View File

@ -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):

View File

@ -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'

View File

@ -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')

View File

@ -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'),

View File

@ -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