Release v2.0.0

This commit is contained in:
Stein Magnus Jodal 2016-02-15 22:03:12 +01:00
commit 842076a978
118 changed files with 5065 additions and 3440 deletions

View File

@ -27,3 +27,4 @@ Ronald Zielaznicki <zielaznickizm@g.cofc.edu> <zielaznickiz@g.cofc.edu>
Kyle Heyne <kyleheyne@gmail.com> Kyle Heyne <kyleheyne@gmail.com>
Tom Roth <rawdlite@googlemail.com> Tom Roth <rawdlite@googlemail.com>
Eric Jahn <ejahn@newstore.com> Eric Jahn <ejahn@newstore.com>
Loïck Bonniot <git@lesterpig.com>

View File

@ -1,18 +1,11 @@
sudo: false sudo: required
dist: trusty
language: python language: python
python: python:
- "2.7_with_system_site_packages" - "2.7_with_system_site_packages"
addons:
apt:
sources:
- mopidy-stable
packages:
- graphviz-dev
- mopidy
env: env:
- TOX_ENV=py27 - TOX_ENV=py27
- TOX_ENV=py27-tornado23 - TOX_ENV=py27-tornado23
@ -20,6 +13,11 @@ env:
- TOX_ENV=docs - TOX_ENV=docs
- TOX_ENV=flake8 - TOX_ENV=flake8
before_install:
- "sudo sed -i '/127.0.1.1/d' /etc/hosts" # Workaround tornadoweb/tornado#1573
- "sudo apt-get update -qq"
- "sudo apt-get install -y gir1.2-gst-plugins-base-1.0 gir1.2-gstreamer-1.0 graphviz-dev gstreamer1.0-plugins-good gstreamer1.0-plugins-bad python-gst-1.0"
install: install:
- "pip install tox" - "pip install tox"
@ -27,7 +25,7 @@ script:
- "tox -e $TOX_ENV" - "tox -e $TOX_ENV"
after_success: after_success:
- "if [ $TOX_ENV == 'py27' ]; then pip install coveralls==1.0b1; coveralls; fi" - "if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi"
branches: branches:
except: except:

10
AUTHORS
View File

@ -67,3 +67,13 @@
- Danilo Bargen <mail@dbrgn.ch> - Danilo Bargen <mail@dbrgn.ch>
- Bjørnar Snoksrud <bjornar@snoksrud.no> - Bjørnar Snoksrud <bjornar@snoksrud.no>
- Giorgos Logiotatidis <seadog@sealabs.net> - Giorgos Logiotatidis <seadog@sealabs.net>
- Ben Evans <ben@bensbit.co.uk>
- vrs01 <vrs01@users.noreply.github.com>
- Cadel Watson <cadel@cadelwatson.com>
- Loïck Bonniot <git@lesterpig.com>
- Gustaf Hallberg <ghallberg@gmail.com>
- kozec <kozec@kozec.com>
- Jelle van der Waa <jelle@vdwaa.nl>
- Alex Malone <jalexmalone@gmail.com>
- Daniel Hahler <git@thequod.de>
- Bryan Bennett <bbenne10@gmail.com>

View File

@ -53,8 +53,6 @@ To get started with Mopidy, check out
- `Discussion forum <https://discuss.mopidy.com/>`_ - `Discussion forum <https://discuss.mopidy.com/>`_
- `Source code <https://github.com/mopidy/mopidy>`_ - `Source code <https://github.com/mopidy/mopidy>`_
- `Issue tracker <https://github.com/mopidy/mopidy/issues>`_ - `Issue tracker <https://github.com/mopidy/mopidy/issues>`_
- `Development branch tarball <https://github.com/mopidy/mopidy/archive/develop.tar.gz#egg=mopidy-dev>`_
- IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_ - IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
- Announcement list: `mopidy@googlegroups.com <https://groups.google.com/forum/?fromgroups=#!forum/mopidy>`_ - Announcement list: `mopidy@googlegroups.com <https://groups.google.com/forum/?fromgroups=#!forum/mopidy>`_
- Twitter: `@mopidy <https://twitter.com/mopidy/>`_ - Twitter: `@mopidy <https://twitter.com/mopidy/>`_

View File

@ -161,6 +161,8 @@ Playlists controller
.. class:: mopidy.core.PlaylistsController .. class:: mopidy.core.PlaylistsController
.. automethod:: mopidy.core.PlaylistsController.get_uri_schemes
Fetching Fetching
-------- --------
@ -226,8 +228,8 @@ TracklistController
.. autoattribute:: mopidy.core.TracklistController.repeat .. autoattribute:: mopidy.core.TracklistController.repeat
.. autoattribute:: mopidy.core.TracklistController.single .. autoattribute:: mopidy.core.TracklistController.single
PlaylistsController PlaybackController
------------------- ------------------
.. automethod:: mopidy.core.PlaybackController.get_mute .. automethod:: mopidy.core.PlaybackController.get_mute
.. automethod:: mopidy.core.PlaybackController.get_volume .. automethod:: mopidy.core.PlaybackController.get_volume
@ -244,8 +246,8 @@ LibraryController
.. automethod:: mopidy.core.LibraryController.find_exact .. automethod:: mopidy.core.LibraryController.find_exact
PlaybackController PlaylistsController
------------------ -------------------
.. automethod:: mopidy.core.PlaylistsController.filter .. automethod:: mopidy.core.PlaylistsController.filter
.. automethod:: mopidy.core.PlaylistsController.get_playlists .. automethod:: mopidy.core.PlaylistsController.get_playlists

130
docs/audio.rst Normal file
View File

@ -0,0 +1,130 @@
.. _audio:
*********************
Advanced audio setups
*********************
Mopidy has very few :ref:`audio configs <audio-config>`, but the ones we
have are very powerful because they let you modify the GStreamer audio pipeline
directly. Here we describe some use cases that can be solved with the audio
configs and GStreamer.
.. _custom-sink:
Custom audio sink
=================
If you have successfully installed GStreamer, and then run the
``gst-inspect-1.0`` command, you should see a long listing of installed
plugins, ending in a summary line::
$ gst-inspect-1.0
... long list of installed plugins ...
Total count: 233 plugins, 1339 features
Next, you should be able to produce a audible tone by running::
gst-launch-1.0 audiotestsrc ! audioresample ! autoaudiosink
If you cannot hear any sound when running this command, you won't hear any
sound from Mopidy either, as Mopidy by default uses GStreamer's
``autoaudiosink`` to play audio. Thus, make this work before you file a bug
against Mopidy.
If you for some reason want to use some other GStreamer audio sink than
``autoaudiosink``, you can set the :confval:`audio/output` config value to a
partial GStreamer pipeline description describing the GStreamer sink you want
to use.
Example ``mopidy.conf`` for using OSS4:
.. code-block:: ini
[audio]
output = oss4sink
Again, this is the equivalent of the following ``gst-launch-1.0`` command, so
make this work first::
gst-launch-1.0 audiotestsrc ! audioresample ! oss4sink
.. _streaming:
Streaming through Icecast
=========================
If you want to play the audio on another computer than the one running Mopidy,
you can stream the audio from Mopidy through an Icecast audio streaming server.
Multiple media players can then be connected to the streaming server
simultaneously. To use the Icecast output, do the following:
#. Install, configure and start the Icecast server. It can be found in the
``icecast2`` package in Debian/Ubuntu.
#. Set the :confval:`audio/output` config value to encode the output audio to
MP3 (``lamemp3enc``) or Ogg Vorbis (``audioresample ! audioconvert !
vorbisenc ! oggmux``) and send it to Icecast (``shout2send``).
You might also need to change the ``shout2send`` default settings, run
``gst-inspect-1.0 shout2send`` to see the available settings. Most likely
you want to change ``ip``, ``username``, ``password``, and ``mount``.
Example for MP3 streaming:
.. code-block:: ini
[audio]
output = lamemp3enc ! shout2send mount=mopidy ip=127.0.0.1 port=8000 password=hackme
Example for Ogg Vorbis streaming:
.. code-block:: ini
[audio]
output = audioresample ! audioconvert ! vorbisenc ! oggmux ! shout2send mount=mopidy ip=127.0.0.1 port=8000 password=hackme
Other advanced setups are also possible for outputs. Basically, anything you
can use with the ``gst-launch-1.0`` command can be plugged into
:confval:`audio/output`.
Known issues
------------
- **Changing track:** As of Mopidy 1.2 we support gapless playback, and the
stream does no longer end when changing from one track to another.
- **Previous/next:** The stream ends on previous and next. See :issue:`1306`
for details. This can be worked around using a fallback stream, as described
below.
- **Pause:** Pausing playback stops the stream. This is probably not something
we're going to fix. This can be worked around using a fallback stream, as
described below.
- **Metadata:** Track metadata is mostly missing from the stream. For Spotify,
fixing :issue:`1357` should help. The general issue for other extensions is
:issue:`866`.
Fallback stream
---------------
By using a *fallback stream* playing silence, you can somewhat mitigate the
known issues above.
Example Icecast configuration:
.. code-block:: xml
<mount>
<mount-name>/mopidy</mount-name>
<fallback-mount>/silence.mp3</fallback-mount>
<fallback-override>1</fallback-override>
</mount>
You can easily find MP3 files with just silence by searching the web. The
``silence.mp3`` file needs to be placed in the directory defined by
``<webroot>...</webroot>`` in the Icecast configuration.

View File

@ -4,6 +4,241 @@ Changelog
This changelog is used to track all major changes to Mopidy. This changelog is used to track all major changes to Mopidy.
v2.0.0 (2016-02-15)
===================
Mopidy 2.0 is here!
Since the release of 1.1, we've closed or merged approximately 80 issues and
pull requests through about 350 commits by 14 extraordinary people, including
10 newcomers. That's about the same amount of issues and commits as between 1.0
and 1.1. The number of contributors is a bit lower but we didn't have a real
life sprint during this development cycle. Thanks to :ref:`everyone <authors>`
who has :ref:`contributed <contributing>`!
With the release of Mopidy 1.0 we promised that any extension working with
Mopidy 1.0 should continue working with all Mopidy 1.x releases. Mopidy 2.0 is
quite a friendly major release and will only break a single extension that we
know of: Mopidy-Spotify. To ensure that everything continues working, please
upgrade to Mopidy 2.0 and Mopidy-Spotify 3.0 at the same time.
No deprecated functionality has been removed in Mopidy 2.0.
The major features of Mopidy 2.0 are:
- Gapless playback has been mostly implemented. It works as long as you don't
change tracks in the middle of a track or use previous and next. In a future
release, previous and next will also become gapless. It is now quite easy to
have Mopidy streaming audio over the network using Icecast. See the updated
:ref:`streaming` docs for details of how to set it up and workarounds for the
remaining issues.
- Mopidy has upgraded from GStreamer 0.10 to 1.x. This has been in our backlog
for more than three years. With this upgrade we're ridding ourselves of
years of GStreamer bugs that have been fixed in newer releases, we can get
into Debian testing again, and we've removed the last major roadblock for
running Mopidy on Python 3.
Dependencies
------------
- Mopidy now requires GStreamer >= 1.2.3, as we've finally ported from
GStreamer 0.10. Since we're requiring a new major version of our major
dependency, we're upping the major version of Mopidy too. (Fixes:
:issue:`225`)
Core API
--------
- Start ``tlid`` counting at 1 instead of 0 to keep in sync with MPD's
``songid``.
- :meth:`~mopidy.core.PlaybackController.get_time_position` now returns the
seek target while a seek is in progress. This gives better results than just
failing the position query. (Fixes: :issue:`312` PR: :issue:`1346`)
- Add :meth:`mopidy.core.PlaylistsController.get_uri_schemes`. (PR:
:issue:`1362`)
- The ``track_playback_ended`` event now includes the correct ``tl_track``
reference when changing to the next track in consume mode. (Fixes:
:issue:`1402` PR: :issue:`1403` PR: :issue:`1406`)
Models
------
- **Deprecated:** :attr:`mopidy.models.Album.images` is deprecated. Use
:meth:`mopidy.core.LibraryController.get_images` instead. (Fixes:
:issue:`1325`)
Extension support
-----------------
- Log exception and continue if an extension crashes during setup. Previously,
we let Mopidy crash if an extension's setup crashed. (PR: :issue:`1337`)
Local backend
-------------
- Made :confval:`local/data_dir` really deprecated. This change breaks older
versions of Mopidy-Local-SQLite and Mopidy-Local-Images.
M3U backend
-----------
- Add :confval:`m3u/base_dir` for resolving relative paths in M3U
files. (Fixes: :issue:`1428`, PR: :issue:`1442`)
- Derive track name from file name for non-extended M3U
playlists. (Fixes: :issue:`1364`, PR: :issue:`1369`)
- Major refactoring of the M3U playlist extension. (Fixes:
:issue:`1370` PR: :issue:`1386`)
- Add :confval:`m3u/default_encoding` and :confval:`m3u/default_extension`
config values for improved text encoding support.
- No longer scan playlist directory and parse playlists at startup or refresh.
Similarly to the file extension, this now happens on request.
- Use :class:`mopidy.models.Ref` instances when reading and writing
playlists. Therefore, ``Track.length`` is no longer stored in
extended M3U playlists and ``#EXTINF`` runtime is always set to
-1.
- Improve reliability of playlist updates using the core playlist API by
applying the write-replace pattern for file updates.
Stream backend
--------------
- Make sure both lookup and playback correctly handle playlists and our
blacklist support. (Fixes: :issue:`1445`, PR: :issue:`1447`)
MPD frontend
------------
- Implemented commands for modifying stored playlists:
- ``playlistadd``
- ``playlistclear``
- ``playlistdelete``
- ``playlistmove``
- ``rename``
- ``rm``
- ``save``
(Fixes: :issue:`1014`, PR: :issue:`1187`, :issue:`1308`, :issue:`1322`)
- Start ``songid`` counting at 1 instead of 0 to match the original MPD server.
- Idle events are now emitted on ``seeked`` events. This fix means that
clients relying on ``idle`` events now get notified about seeks.
(Fixes: :issue:`1331`, PR: :issue:`1347`)
- Idle events are now emitted on ``playlists_loaded`` events. This fix means
that clients relying on ``idle`` events now get notified about playlist loads.
(Fixes: :issue:`1331`, PR: :issue:`1347`)
- Event handler for ``playlist_deleted`` has been unbroken. This unreported bug
would cause the MPD frontend to crash preventing any further communication
via the MPD protocol. (PR: :issue:`1347`)
Zeroconf
--------
- Require ``stype`` argument to :class:`mopidy.zeroconf.Zeroconf`.
- Use Avahi's interface selection by default. (Fixes: :issue:`1283`)
- Use Avahi server's hostname instead of ``socket.getfqdn()`` in service
display name.
Cleanups
--------
- Removed warning if :file:`~/.mopidy` exists. We stopped using this location
in 0.6, released in October 2011.
- Removed warning if :file:`~/.config/mopidy/settings.py` exists. We stopped
using this settings file in 0.14, released in April 2013.
- The ``on_event`` handler in our listener helper now catches exceptions. This
means that any errors in event handling won't crash the actor in question.
- Catch errors when loading :confval:`logging/config_file`.
(Fixes: :issue:`1320`)
- **Breaking:** Removed unused internal
:class:`mopidy.internal.process.BaseThread`. This breaks Mopidy-Spotify
1.4.0. Versions < 1.4.0 was already broken by Mopidy 1.1, while versions >=
2.0 doesn't use this class.
Audio
-----
- **Breaking:** The audio scanner now returns ISO-8601 formatted strings
instead of :class:`~datetime.datetime` objects for dates found in tags.
Because of this change, we can now return years without months or days, which
matches the semantics of the date fields in our data models.
- **Breaking:** :meth:`mopidy.audio.Audio.set_appsrc`'s ``caps`` argument has
changed format due to the upgrade from GStreamer 0.10 to GStreamer 1. As
far as we know, this is only used by Mopidy-Spotify. As an example, with
GStreamer 0.10 the Mopidy-Spotify caps was::
audio/x-raw-int, endianness=(int)1234, channels=(int)2, width=(int)16,
depth=(int)16, signed=(boolean)true, rate=(int)44100
With GStreamer 1 this changes to::
audio/x-raw,format=S16LE,rate=44100,channels=2,layout=interleaved
If your Mopidy backend uses ``set_appsrc()``, please refer to GStreamer
documentation for details on the new caps string format.
- **Breaking:** :func:`mopidy.audio.utils.create_buffer`'s ``capabilities``
argument is no longer in use and has been removed. As far as we know, this
was only used by Mopidy-Spotify.
- Duplicate seek events getting to ``appsrc`` based backends is now fixed. This
should prevent seeking in Mopidy-Spotify from glitching. (Fixes:
:issue:`1404`)
- Workaround crash caused by a race that does not seem to affect functionality.
This should be fixed properly together with :issue:`1222`. (Fixes:
:issue:`1430`, PR: :issue:`1438`)
- Add a new config option, :confval:`audio/buffer_time`, for setting the buffer
time of the GStreamer queue. If you experience buffering before track
changes, it may help to increase this. (Workaround for :issue:`1409`)
- ``tags_changed`` events are only emitted for fields that have changed.
Previous behavior was to emit this for all fields received from GStreamer.
(PR: :issue:`1439`)
Gapless
-------
- Add partial support for gapless playback. Gapless now works as long as you
don't change tracks or use next/previous. (PR: :issue:`1288`)
The :ref:`streaming` docs has been updated with the workarounds still needed
to properly stream Mopidy audio through Icecast.
- Core playback has been refactored to better handle gapless, and async state
changes.
- Tests have been updated to always use a core actor so async state changes
don't trip us up.
- Seek events are now triggered when the seek completes. Previously the event
was emitted when the seek was requested, not when it completed. Further
changes have been made to make seek work correctly for gapless related corner
cases. (Fixes: :issue:`1305` PR: :issue:`1346`)
v1.1.2 (2016-01-18) v1.1.2 (2016-01-18)
=================== ===================
@ -2064,7 +2299,7 @@ already have.
- Mopidy.js now works both from browsers and from Node.js environments. This - Mopidy.js now works both from browsers and from Node.js environments. This
means that you now can make Mopidy clients in Node.js. Mopidy.js has been means that you now can make Mopidy clients in Node.js. Mopidy.js has been
published to the `npm registry <https://npmjs.org/package/mopidy>`_ for easy published to the `npm registry <https://www.npmjs.com/package/mopidy>`_ for easy
installation in Node.js projects. installation in Node.js projects.
- Upgrade Mopidy.js' build system Grunt from 0.3 to 0.4. - Upgrade Mopidy.js' build system Grunt from 0.3 to 0.4.
@ -2820,9 +3055,9 @@ Please note that 0.6.0 requires some updated dependencies, as listed under
subsystems: player, playlist, options, and mixer. (Fixes: :issue:`32`) subsystems: player, playlist, options, and mixer. (Fixes: :issue:`32`)
- A new frontend :mod:`mopidy.frontends.mpris` have been added. It exposes - A new frontend :mod:`mopidy.frontends.mpris` have been added. It exposes
Mopidy through the `MPRIS interface <http://www.mpris.org/>`_ over D-Bus. In Mopidy through the `MPRIS interface <http://specifications.freedesktop.org/mpris-spec/latest/>`_ over D-Bus. In
practice, this makes it possible to control Mopidy through the `Ubuntu Sound practice, this makes it possible to control Mopidy through the `Ubuntu Sound
Menu <https://wiki.ubuntu.com/SoundMenu>`_. Menu <https://wiki.ubuntu.com/Sound#menu>`_.
**Changes** **Changes**

View File

@ -30,14 +30,17 @@ supported)" mode because the client tries to fetch all known metadata and do
the search on the client side. The two other search modes works nicely, so this the search on the client side. The two other search modes works nicely, so this
is not a problem. is not a problem.
The library view is very slow when used together with Mopidy-Spotify. A With ncmpcpp <= 0.5, the library view is very slow when used together with
workaround is to edit the ncmpcpp configuration file Mopidy-Spotify. A workaround is to edit the ncmpcpp configuration file
(:file:`~/.ncmpcpp/config`) and set:: (:file:`~/.ncmpcpp/config`) and set::
media_library_display_date = "no" media_library_display_date = "no"
With this change ncmpcpp's library view will still be a bit slow, but usable. With this change ncmpcpp's library view will still be a bit slow, but usable.
Note that this option was removed in ncmpcpp 0.6, but with this version, the
library view works well without it.
ncmpc ncmpc
----- -----
@ -59,7 +62,7 @@ MPD graphical clients
GMPC GMPC
---- ----
`GMPC <http://gmpc.wikia.com>`_ is a graphical MPD client (GTK+) which works `GMPC <http://gmpc.wikia.com/wiki/Gnome_Music_Player_Client>`_ is a graphical MPD client (GTK+) which works
well with Mopidy. well with Mopidy.
.. image:: mpd-client-gmpc.png .. image:: mpd-client-gmpc.png
@ -76,7 +79,7 @@ before it will catch up.
Sonata Sonata
------ ------
`Sonata <http://sonata.berlios.de/>`_ is a graphical MPD client (GTK+). `Sonata <https://github.com/multani/sonata>`_ is a graphical MPD client (GTK+).
It generally works well with Mopidy, except for search. It generally works well with Mopidy, except for search.
.. image:: mpd-client-sonata.png .. image:: mpd-client-sonata.png
@ -87,11 +90,7 @@ When you search in Sonata, it only sends the first to letters of the search
query to Mopidy, and then does the rest of the filtering itself on the client query to Mopidy, and then does the rest of the filtering itself on the client
side. Since Spotify has a collection of millions of tracks and they only return side. Since Spotify has a collection of millions of tracks and they only return
the first 100 hits for any search query, searching for two-letter combinations the first 100 hits for any search query, searching for two-letter combinations
seldom returns any useful results. See :issue:`1` and the closed `Sonata bug`_ seldom returns any useful results. See :issue:`1` for details.
for details.
.. _Sonata bug: http://developer.berlios.de/feature/?func=detailfeature&feature_id=5038&group_id=7323
Theremin Theremin
-------- --------

View File

@ -4,7 +4,7 @@
MPRIS clients MPRIS clients
************* *************
`MPRIS <http://www.mpris.org/>`_ is short for Media Player Remote Interfacing `MPRIS <http://specifications.freedesktop.org/mpris-spec/latest/>`_ is short for Media Player Remote Interfacing
Specification. It's a spec that describes a standard D-Bus interface for making Specification. It's a spec that describes a standard D-Bus interface for making
media players available to other applications on the same system. media players available to other applications on the same system.
@ -19,7 +19,7 @@ implement the optional tracklist interface.
Ubuntu Sound Menu Ubuntu Sound Menu
================= =================
The `Ubuntu Sound Menu <https://wiki.ubuntu.com/SoundMenu>`_ is the default The `Ubuntu Sound Menu <https://wiki.ubuntu.com/Sound#menu>`_ is the default
sound menu in Ubuntu since 10.10 or 11.04. By default, it only includes the sound menu in Ubuntu since 10.10 or 11.04. By default, it only includes the
Rhytmbox music player, but many other players can integrate with the sound Rhytmbox music player, but many other players can integrate with the sound
menu, including the official Spotify player and Mopidy. menu, including the official Spotify player and Mopidy.

View File

@ -4,11 +4,11 @@
UPnP clients UPnP clients
************ ************
`UPnP <http://en.wikipedia.org/wiki/Universal_Plug_and_Play>`_ is a set of `UPnP <https://en.wikipedia.org/wiki/Universal_Plug_and_Play>`_ is a set of
specifications for media sharing, playing, remote control, etc, across a home specifications for media sharing, playing, remote control, etc, across a home
network. The specs are supported by a lot of consumer devices (like network. The specs are supported by a lot of consumer devices (like
smartphones, TVs, Xbox, and PlayStation) that are often labeled as being `DLNA smartphones, TVs, Xbox, and PlayStation) that are often labeled as being `DLNA
<http://en.wikipedia.org/wiki/DLNA>`_ compatible or certified. <https://en.wikipedia.org/wiki/DLNA>`_ compatible or certified.
The DLNA guidelines and UPnP specifications defines several device roles, of The DLNA guidelines and UPnP specifications defines several device roles, of
which Mopidy may play two: which Mopidy may play two:
@ -149,4 +149,4 @@ Other clients
For a long list of UPnP clients for all possible platforms, see Wikipedia's For a long list of UPnP clients for all possible platforms, see Wikipedia's
`List of UPnP AV media servers and clients `List of UPnP AV media servers and clients
<http://en.wikipedia.org/wiki/List_of_UPnP_AV_media_servers_and_clients>`_. <https://en.wikipedia.org/wiki/List_of_UPnP_AV_media_servers_and_clients>`_.

View File

@ -21,7 +21,7 @@ Code style
bar = 'I am a bytestring, but was it intentional?' bar = 'I am a bytestring, but was it intentional?'
- Follow :pep:`8` unless otherwise noted. `flake8 - Follow :pep:`8` unless otherwise noted. `flake8
<http://pypi.python.org/pypi/flake8>`_ should be used to check your code <https://pypi.python.org/pypi/flake8>`_ should be used to check your code
against the guidelines. against the guidelines.
- Use four spaces for indentation, *never* tabs. - Use four spaces for indentation, *never* tabs.

View File

@ -15,7 +15,6 @@ sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + '/../'))
class Mock(object): class Mock(object):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
pass pass
@ -27,39 +26,21 @@ class Mock(object):
@classmethod @classmethod
def __getattr__(self, name): def __getattr__(self, name):
if name in ('__file__', '__path__'): if name == 'get_system_config_dirs': # GLib.get_system_config_dirs()
return '/dev/null' return list
elif name == 'get_system_config_dirs': elif name == 'get_user_config_dir': # GLib.get_user_config_dir()
# glib.get_system_config_dirs()
return tuple
elif name == 'get_user_config_dir':
# glib.get_user_config_dir()
return str return str
elif (name[0] == name[0].upper() and
# gst.Caps
not name.startswith('Caps') and
# gst.PadTemplate
not name.startswith('PadTemplate') and
# dbus.String()
not name == 'String'):
return type(name, (), {})
else: else:
return Mock() return Mock()
MOCK_MODULES = [ MOCK_MODULES = [
'dbus', 'dbus',
'dbus.mainloop', 'dbus.mainloop',
'dbus.mainloop.glib', 'dbus.mainloop.glib',
'dbus.service', 'dbus.service',
'glib', 'mopidy.internal.gi',
'gobject',
'gst',
'gst.pbutils',
'pygst',
'pykka', 'pykka',
'pykka.actor',
'pykka.future',
'pykka.registry',
] ]
for mod_name in MOCK_MODULES: for mod_name in MOCK_MODULES:
sys.modules[mod_name] = Mock() sys.modules[mod_name] = Mock()
@ -111,11 +92,7 @@ modindex_common_prefix = ['mopidy.']
# -- Options for HTML output -------------------------------------------------- # -- Options for HTML output --------------------------------------------------
# 'sphinx_rtd_theme' is bundled with Sphinx 1.3, which we don't have when html_theme = 'sphinx_rtd_theme'
# building the docs as part of the Debian packages on e.g. Debian wheezy.
# html_theme = 'sphinx_rtd_theme'
html_theme = 'default'
html_theme_path = ['_themes']
html_static_path = ['_static'] html_static_path = ['_static']
html_use_modindex = True html_use_modindex = True
@ -167,7 +144,17 @@ extlinks = {
# -- Options for intersphinx extension ---------------------------------------- # -- Options for intersphinx extension ----------------------------------------
intersphinx_mapping = { intersphinx_mapping = {
'python': ('http://docs.python.org/2', None), 'python': ('https://docs.python.org/2', None),
'pykka': ('http://www.pykka.org/en/latest/', None), 'pykka': ('https://www.pykka.org/en/latest/', None),
'tornado': ('http://www.tornadoweb.org/en/stable/', None), 'tornado': ('http://www.tornadoweb.org/en/stable/', None),
} }
# -- Options for linkcheck builder -------------------------------------------
linkcheck_ignore = [ # Some sites work in browser but linkcheck fails.
r'http://localhost:\d+/',
r'http://wiki.commonjs.org',
r'http://vk.com',
r'http://$']
linkcheck_anchors = False # This breaks on links that use # for other stuff

View File

@ -25,6 +25,10 @@ create the configuration file yourself, or run the ``mopidy`` command, and it
will create an empty config file for you and print what config values must be will create an empty config file for you and print what config values must be
set to successfully start Mopidy. set to successfully start Mopidy.
If running Mopidy as a service, the location of the config file and other
details documented here differs a bit. See :ref:`service` for details about
this.
When you have created the configuration file, open it in a text editor, and add When you have created the configuration file, open it in a text editor, and add
the config values you want to change. If you want to keep the default for a the config values you want to change. If you want to keep the default for a
config value, you **should not** add it to the config file, but leave it out so config value, you **should not** add it to the config file, but leave it out so
@ -45,21 +49,18 @@ below, together with their default values. In addition, all :ref:`extensions
defaults are documented on the :ref:`extension pages <ext>`. defaults are documented on the :ref:`extension pages <ext>`.
Default core configuration Default configuration
========================== =====================
This is the default configuration for Mopidy itself. All extensions bring
additional configuration values with their own defaults.
.. literalinclude:: ../mopidy/config/default.conf .. literalinclude:: ../mopidy/config/default.conf
:language: ini :language: ini
Core configuration values Core config section
========================= ===================
Mopidy's core has the following configuration values that you can change.
Core configuration
------------------
.. confval:: core/cache_dir .. confval:: core/cache_dir
@ -111,8 +112,13 @@ Core configuration
MPD clients will crash if this limit is exceeded. MPD clients will crash if this limit is exceeded.
.. _audio-config:
Audio configuration Audio configuration
------------------- ===================
These are the available audio configurations. For specific use cases, see
:ref:`audio`.
.. confval:: audio/mixer .. confval:: audio/mixer
@ -146,11 +152,23 @@ Audio configuration
Expects a GStreamer sink. Typical values are ``autoaudiosink``, Expects a GStreamer sink. Typical values are ``autoaudiosink``,
``alsasink``, ``osssink``, ``oss4sink``, ``pulsesink``, and ``shout2send``, ``alsasink``, ``osssink``, ``oss4sink``, ``pulsesink``, and ``shout2send``,
and additional arguments specific to each sink. You can use the command and additional arguments specific to each sink. You can use the command
``gst-inspect-0.10`` to see what output properties can be set on the sink. ``gst-inspect-1.0`` to see what output properties can be set on the sink.
For example: ``gst-inspect-0.10 shout2send`` For example: ``gst-inspect-1.0 shout2send``
.. confval:: audio/buffer_time
Buffer size in milliseconds.
Expects an integer above 0.
Sets the buffer size of the GStreamer queue. If you experience buffering
before track changes, it may help to increase this, possibly by at least a
few seconds. The default is letting GStreamer decide the size, which at the
time of this writing is 1000.
Logging configuration Logging configuration
--------------------- =====================
.. confval:: logging/color .. confval:: logging/color
@ -195,16 +213,16 @@ Logging configuration
to use for that logger, one of ``black``, ``red``, ``green``, ``yellow``, to use for that logger, one of ``black``, ``red``, ``green``, ``yellow``,
``blue``, ``magenta``, ``cyan`` or ``white``. ``blue``, ``magenta``, ``cyan`` or ``white``.
.. _the Python logging docs: http://docs.python.org/2/library/logging.config.html .. _the Python logging docs: https://docs.python.org/2/library/logging.config.html
.. _proxy-config: .. _proxy-config:
Proxy configuration Proxy configuration
------------------- ===================
Not all parts of Mopidy or all Mopidy extensions respect the proxy Not all parts of Mopidy or all Mopidy extensions respect the proxy
server configuration when connecting to the Internt. Currently, this is at server configuration when connecting to the Internet. Currently, this is at
least used when Mopidy's audio subsystem reads media directly from the network, least used when Mopidy's audio subsystem reads media directly from the network,
like when listening to Internet radio streams, and by the Mopidy-Spotify like when listening to Internet radio streams, and by the Mopidy-Spotify
extension. With time, we hope that more of the Mopidy ecosystem will respect extension. With time, we hope that more of the Mopidy ecosystem will respect
@ -235,9 +253,10 @@ these configurations to help users on locked down networks.
Extension configuration Extension configuration
======================= =======================
Mopidy's extensions have their own config values that you may want to tweak. Each installed Mopidy extension adds its own configuration section with one or
For the available config values, please refer to the docs for each extension. more config values that you may want to tweak. For the available config
Most, if not all, can be found at :ref:`ext`. values, please refer to the docs for each extension. Most, if not all, can be
found at :ref:`ext`.
Mopidy extensions are enabled by default when they are installed. If you want Mopidy extensions are enabled by default when they are installed. If you want
to disable an extension without uninstalling it, all extensions support the to disable an extension without uninstalling it, all extensions support the
@ -250,118 +269,14 @@ following to your ``mopidy.conf``::
enabled = false enabled = false
Advanced configurations Adding new configuration values
======================= ===============================
Custom audio sink Mopidy's config validator will validate all of its own config sections and the
----------------- config sections belonging to any installed extension. It will raise an error if
you add any config values in your config file that Mopidy doesn't know about.
If you have successfully installed GStreamer, and then run the ``gst-inspect`` This may sound obnoxious, but it helps us detect typos in your config, and to
or ``gst-inspect-0.10`` command, you should see a long listing of installed warn about deprecated config values that should be removed or updated.
plugins, ending in a summary line::
$ gst-inspect-0.10
... long list of installed plugins ...
Total count: 254 plugins (1 blacklist entry not shown), 1156 features
Next, you should be able to produce a audible tone by running::
gst-launch-0.10 audiotestsrc ! audioresample ! autoaudiosink
If you cannot hear any sound when running this command, you won't hear any
sound from Mopidy either, as Mopidy by default uses GStreamer's
``autoaudiosink`` to play audio. Thus, make this work before you file a bug
against Mopidy.
If you for some reason want to use some other GStreamer audio sink than
``autoaudiosink``, you can set the :confval:`audio/output` config value to a
partial GStreamer pipeline description describing the GStreamer sink you want
to use.
Example ``mopidy.conf`` for using OSS4:
.. code-block:: ini
[audio]
output = oss4sink
Again, this is the equivalent of the following ``gst-inspect`` command, so make
this work first::
gst-launch-0.10 audiotestsrc ! audioresample ! oss4sink
Streaming through SHOUTcast/Icecast
-----------------------------------
.. warning:: Known issue
Currently, Mopidy does not handle end-of-track vs end-of-stream signalling
in GStreamer correctly. This causes the SHOUTcast stream to be disconnected
at the end of each track, rendering it quite useless. For further details,
see :issue:`492`. You can also try the workaround_ mentioned below.
If you want to play the audio on another computer than the one running Mopidy,
you can stream the audio from Mopidy through an SHOUTcast or Icecast audio
streaming server. Multiple media players can then be connected to the streaming
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.
#. Set the :confval:`audio/output` config value to ``lame ! shout2send``. An
Ogg Vorbis encoder could be used instead of the lame MP3 encoder.
#. 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``.
Example for MP3 streaming:
.. code-block:: ini
[audio]
output = lame ! shout2send mount=mopidy ip=127.0.0.1 port=8000 password=hackme
Example for Ogg Vorbis streaming:
.. code-block:: ini
[audio]
output = audioresample ! audioconvert ! vorbisenc ! oggmux ! shout2send mount=mopidy ip=127.0.0.1 port=8000 password=hackme
Other advanced setups are also possible for outputs. Basically, anything you
can use with the ``gst-launch-0.10`` command can be plugged into
:confval:`audio/output`.
.. _workaround:
**Workaround for end-of-track issues - fallback streams**
By using a *fallback stream* playing silence, you can somewhat mitigate the
signalling issues.
Example Icecast configuration:
.. code-block:: xml
<mount>
<mount-name>/mopidy</mount-name>
<fallback-mount>/silence.mp3</fallback-mount>
<fallback-override>1</fallback-override>
</mount>
The ``silence.mp3`` file needs to be placed in the directory defined by
``<webroot>...</webroot>``.
New configuration values
------------------------
Mopidy's config validator will stop you from defining any config values in
your config file that Mopidy doesn't know about. This may sound obnoxious,
but it helps us detect typos in your config, and deprecated config values that
should be removed or updated.
If you're extending Mopidy, and want to use Mopidy's configuration If you're extending Mopidy, and want to use Mopidy's configuration
system, you can add new sections to the config without triggering the config system, you can add new sections to the config without triggering the config

View File

@ -300,7 +300,7 @@ the given module, ``mopidy`` in this example, are covered by the test suite::
.. note:: .. note::
Up to date test coverage statistics can also be viewed online at Up to date test coverage statistics can also be viewed online at
`coveralls.io <https://coveralls.io/r/mopidy/mopidy>`_. `coveralls.io <https://coveralls.io/github/mopidy/mopidy>`_.
If we want to speed up the test suite, we can even get a list of the ten If we want to speed up the test suite, we can even get a list of the ten
slowest tests:: slowest tests::
@ -322,7 +322,7 @@ CI, and the build status will be visible in the GitHub pull request interface,
making it easier to evaluate the quality of pull requests. making it easier to evaluate the quality of pull requests.
For each successful build, Travis submits code coverage data to `coveralls.io For each successful build, Travis submits code coverage data to `coveralls.io
<https://coveralls.io/r/mopidy/mopidy>`_. If you're out of work, coveralls might <https://coveralls.io/github/mopidy/mopidy>`_. If you're out of work, coveralls might
help you find areas in the code which could need better test coverage. help you find areas in the code which could need better test coverage.
@ -392,7 +392,7 @@ OS::
open _build/html/index.html # OS X open _build/html/index.html # OS X
The documentation at https://docs.mopidy.com/ is hosted by `Read the Docs The documentation at https://docs.mopidy.com/ is hosted by `Read the Docs
<https://www.readhtedocs.org/>`_, which automatically updates the documentation <https://readthedocs.org/>`_, which automatically updates the documentation
when a change is pushed to the ``mopidy/mopidy`` repo at GitHub. when a change is pushed to the ``mopidy/mopidy`` repo at GitHub.

View File

@ -54,7 +54,7 @@ Mopidy-Dirble
https://github.com/mopidy/mopidy-dirble https://github.com/mopidy/mopidy-dirble
Provides a backend for browsing the Internet radio channels from the `Dirble Provides a backend for browsing the Internet radio channels from the `Dirble
<http://dirble.com/>`_ directory. <https://dirble.com/>`_ directory.
Mopidy-dLeyna Mopidy-dLeyna
@ -63,7 +63,7 @@ Mopidy-dLeyna
https://github.com/tkem/mopidy-dleyna https://github.com/tkem/mopidy-dleyna
Provides a backend for playing music from Digital Media Servers using Provides a backend for playing music from Digital Media Servers using
the `dLeyna <http://01.org/dleyna>`_ D-Bus interface. the `dLeyna <https://01.org/dleyna>`_ D-Bus interface.
Mopidy-File Mopidy-File
=========== ===========
@ -76,13 +76,13 @@ Mopidy-Grooveshark
https://github.com/camilonova/mopidy-grooveshark https://github.com/camilonova/mopidy-grooveshark
Provides a backend for playing music from `Grooveshark Provides a backend for playing music from `Grooveshark
<http://grooveshark.com/>`_. <http://grooveshark.im/>`_.
Mopidy-GMusic Mopidy-GMusic
============= =============
https://github.com/hechtus/mopidy-gmusic https://github.com/mopidy/mopidy-gmusic
Provides a backend for playing music from `Google Play Music Provides a backend for playing music from `Google Play Music
<https://play.google.com/music/>`_. <https://play.google.com/music/>`_.
@ -115,7 +115,7 @@ Bundled with Mopidy. See :ref:`ext-local`.
Mopidy-Local-Images Mopidy-Local-Images
=================== ===================
https://github.com/tkem/mopidy-local-images https://github.com/mopidy/mopidy-local-images
Extension which plugs into Mopidy-Local to allow Web clients access to Extension which plugs into Mopidy-Local to allow Web clients access to
album art embedded in local media files. Not to be used on its own, album art embedded in local media files. Not to be used on its own,
@ -126,7 +126,7 @@ local library provider being used.
Mopidy-Local-SQLite Mopidy-Local-SQLite
=================== ===================
https://github.com/tkem/mopidy-local-sqlite https://github.com/mopidy/mopidy-local-sqlite
Extension which plugs into Mopidy-Local to use an SQLite database to keep Extension which plugs into Mopidy-Local to use an SQLite database to keep
track of your local media. This extension lets you browse your music collection track of your local media. This extension lets you browse your music collection
@ -153,13 +153,13 @@ https://github.com/tkem/mopidy-podcast
Extension for browsing RSS feeds of podcasts and stream the episodes. Extension for browsing RSS feeds of podcasts and stream the episodes.
Mopidy-Podcast-gpodder.net Mopidy-Podcast-gpodder
========================== ======================
https://github.com/tkem/mopidy-podcast-gpodder https://github.com/tkem/mopidy-podcast-gpodder
Extension for Mopidy-Podcast that lets you search and browse podcasts from the Extension for Mopidy-Podcast that lets you search and browse podcasts from the
`gpodder.net <https://gpodder.net/>`_ web site. `gpodder <http://gpodder.org/>`_ web site.
Mopidy-Podcast-iTunes Mopidy-Podcast-iTunes
@ -177,7 +177,7 @@ Mopidy-radio-de
https://github.com/hechtus/mopidy-radio-de https://github.com/hechtus/mopidy-radio-de
Extension for listening to Internet radio stations and podcasts listed at Extension for listening to Internet radio stations and podcasts listed at
`radio.de <http://www.radio.de/>`_, `rad.io <http://www.rad.io/>`_, `radio.de <http://www.radio.de/>`_, `radio.net <http://www.radio.net/>`_,
`radio.fr <http://www.radio.fr/>`_, and `radio.at <http://www.radio.at/>`_. `radio.fr <http://www.radio.fr/>`_, and `radio.at <http://www.radio.at/>`_.
@ -196,7 +196,7 @@ Mopidy-SoundCloud
https://github.com/mopidy/mopidy-soundcloud https://github.com/mopidy/mopidy-soundcloud
Provides a backend for playing music from the `SoundCloud Provides a backend for playing music from the `SoundCloud
<http://www.soundcloud.com/>`_ service. <https://soundcloud.com/>`_ service.
Mopidy-Spotify Mopidy-Spotify
@ -204,7 +204,7 @@ Mopidy-Spotify
https://github.com/mopidy/mopidy-spotify https://github.com/mopidy/mopidy-spotify
Extension for playing music from the `Spotify <http://www.spotify.com/>`_ music Extension for playing music from the `Spotify <https://www.spotify.com/>`_ music
streaming service. streaming service.
@ -214,7 +214,7 @@ Mopidy-Spotify-Tunigo
https://github.com/trygveaa/mopidy-spotify-tunigo https://github.com/trygveaa/mopidy-spotify-tunigo
Extension for providing the browse feature of `Spotify Extension for providing the browse feature of `Spotify
<http://www.spotify.com/>`_. This lets you browse playlists, genres and new <https://www.spotify.com/>`_. This lets you browse playlists, genres and new
releases. releases.
@ -239,7 +239,7 @@ Mopidy-TuneIn
https://github.com/kingosticks/mopidy-tunein https://github.com/kingosticks/mopidy-tunein
Provides a backend for playing music from the `TuneIn Provides a backend for playing music from the `TuneIn
<http://www.tunein.com/>`_ online radio service. <http://tunein.com/>`_ online radio service.
Mopidy-VKontakte Mopidy-VKontakte
@ -254,7 +254,7 @@ Provides a backend for playing music from the `VKontakte social network
Mopidy-YouTube Mopidy-YouTube
============== ==============
https://github.com/dz0ny/mopidy-youtube https://github.com/mopidy/mopidy-youtube
Provides a backend for playing music from the `YouTube Provides a backend for playing music from the `YouTube
<http://www.youtube.com/>`_ service. <https://www.youtube.com/>`_ service.

View File

@ -89,15 +89,6 @@ See :ref:`config` for general help on configuring Mopidy.
Path to directory with local media files. Path to directory with local media files.
.. confval:: local/data_dir
Path to directory to store local metadata such as libraries and playlists
in.
.. confval:: local/playlists_dir
Path to playlists directory with m3u files for local media.
.. confval:: local/scan_timeout .. confval:: local/scan_timeout
Number of milliseconds before giving up scanning a file and moving on to Number of milliseconds before giving up scanning a file and moving on to

View File

@ -54,3 +54,20 @@ See :ref:`config` for general help on configuring Mopidy.
Path to directory with M3U files. Unset by default, in which case the Path to directory with M3U files. Unset by default, in which case the
extension's data dir is used to store playlists. extension's data dir is used to store playlists.
.. confval:: m3u/base_dir
Path to base directory for resolving relative paths in M3U files.
If not set, relative paths are resolved based on the M3U file's
location.
.. confval:: m3u/default_encoding
Text encoding used for files with extension ``.m3u``. Default is
``latin-1``. Note that files with extension ``.m3u8`` are always
expected to be UTF-8 encoded.
.. confval:: m3u/default_extension
The file extension for M3U playlists created using the core playlist
API. Default is ``.m3u8``.

View File

@ -45,7 +45,6 @@ Items on this list will probably not be supported in the near future.
The following items are currently not supported, but should be added in the The following items are currently not supported, but should be added in the
near future: near future:
- Modifying stored playlists is not supported
- ``tagtypes`` is not supported - ``tagtypes`` is not supported
- Live update of the music database is not supported - Live update of the music database is not supported

View File

@ -118,7 +118,7 @@ To install, run::
Mopidy-MusicBox-Webclient Mopidy-MusicBox-Webclient
========================= =========================
https://github.com/woutervanwijk/Mopidy-MusicBox-Webclient https://github.com/pimusicbox/mopidy-musicbox-webclient
The first web client for Mopidy, made with jQuery Mobile by Wouter van Wijk. The first web client for Mopidy, made with jQuery Mobile by Wouter van Wijk.
Also the web client used for Wouter's popular `Pi Musicbox Also the web client used for Wouter's popular `Pi Musicbox
@ -183,7 +183,7 @@ To install, run::
Mopidy-WebSettings Mopidy-WebSettings
================== ==================
https://github.com/woutervanwijk/mopidy-websettings https://github.com/pimusicbox/mopidy-websettings
A web extension for changing settings. Used by the Pi MusicBox distribution A web extension for changing settings. Used by the Pi MusicBox distribution
for Raspberry Pi, but also usable for other projects. for Raspberry Pi, but also usable for other projects.

View File

@ -214,7 +214,7 @@ file::
include mopidy_soundspot/ext.conf include mopidy_soundspot/ext.conf
For details on the ``MANIFEST.in`` file format, check out the `distutils docs For details on the ``MANIFEST.in`` file format, check out the `distutils docs
<http://docs.python.org/2/distutils/sourcedist.html#manifest-template>`_. <https://docs.python.org/2/distutils/sourcedist.html#manifest-template>`_.
`check-manifest <https://pypi.python.org/pypi/check-manifest>`_ is a very `check-manifest <https://pypi.python.org/pypi/check-manifest>`_ is a very
useful tool to check your ``MANIFEST.in`` file for completeness. useful tool to check your ``MANIFEST.in`` file for completeness.
@ -542,3 +542,245 @@ your HTTP requests::
For further details, see Requests' docs on `session objects For further details, see Requests' docs on `session objects
<http://www.python-requests.org/en/latest/user/advanced/#session-objects>`__. <http://www.python-requests.org/en/latest/user/advanced/#session-objects>`__.
Testing extensions
==================
Creating test cases for your extensions makes them much simpler to maintain
over the long term. It can also make it easier for you to review and accept
pull requests from other contributors knowing that they will not break the
extension in some unanticipated way.
Before getting started, it is important to familiarize yourself with the
Python `mock library <https://docs.python.org/dev/library/unittest.mock.html>`_.
When it comes to running tests, Mopidy typically makes use of testing tools
like `tox <https://tox.readthedocs.org/en/latest/>`_ and
`pytest <http://pytest.org/latest/>`_.
Testing approach
----------------
To a large extent the testing approach to follow depends on how your extension
is structured, which parts of Mopidy it interacts with, and if it uses any 3rd
party APIs or makes any HTTP requests to the outside world.
The sections that follow contain code extracts that highlight some of the
key areas that should be tested. For more exhaustive examples, you may want to
take a look at the test cases that ship with Mopidy itself which covers
everything from instantiating various controllers, reading configuration files,
and simulating events that your extension can listen to.
In general your tests should cover the extension definition, the relevant
Mopidy controllers, and the Pykka backend and / or frontend actors that form
part of the extension.
Testing the extension definition
--------------------------------
Test cases for checking the definition of the extension should ensure that:
- the extension provides a ``ext.conf`` configuration file containing the
relevant parameters with their default values,
- that the config schema is fully defined, and
- that the extension's actor(s) are added to the Mopidy registry on setup.
An example of what these tests could look like is provided below::
def test_get_default_config(self):
ext = Extension()
config = ext.get_default_config()
assert '[my_extension]' in config
assert 'enabled = true' in config
assert 'param_1 = value_1' in config
assert 'param_2 = value_2' in config
assert 'param_n = value_n' in config
def test_get_config_schema(self):
ext = Extension()
schema = ext.get_config_schema()
assert 'enabled' in schema
assert 'param_1' in schema
assert 'param_2' in schema
assert 'param_n' in schema
def test_setup(self):
registry = mock.Mock()
ext = Extension()
ext.setup(registry)
calls = [mock.call('frontend', frontend_lib.MyFrontend),
mock.call('backend', backend_lib.MyBackend)]
registry.add.assert_has_calls(calls, any_order=True)
Testing backend actors
----------------------
Backends can usually be constructed with a small mockup of the configuration
file, and mocking the audio actor::
@pytest.fixture
def config():
return {
'http': {
'hostname': '127.0.0.1',
'port': '6680'
},
'proxy': {
'hostname': 'host_mock',
'port': 'port_mock'
},
'my_extension': {
'enabled': True,
'param_1': 'value_1',
'param_2': 'value_2',
'param_n': 'value_n',
}
}
def get_backend(config):
return backend.MyBackend(config=config, audio=mock.Mock())
The following libraries might be useful for mocking any HTTP requests that
your extension makes:
- `responses <https://pypi.python.org/pypi/responses>`_ - A utility library for
mocking out the requests Python library.
- `vcrpy <https://pypi.python.org/pypi/vcrpy>`_ - Automatically mock your HTTP
interactions to simplify and speed up testing.
At the very least, you'll probably want to patch ``requests`` or any other web
API's that you use to avoid any unintended HTTP requests from being made by
your backend during testing::
from mock import patch
@mock.patch('requests.get',
mock.Mock(side_effect=Exception('Intercepted unintended HTTP call')))
Backend tests should also ensure that:
- the backend provides a unique URI scheme,
- that it sets up the various providers (e.g. library, playback, etc.)
::
def test_uri_schemes(config):
backend = get_backend(config)
assert 'my_scheme' in backend.uri_schemes
def test_init_sets_up_the_providers(config):
backend = get_backend(config)
assert isinstance(backend.library, library.MyLibraryProvider)
assert isinstance(backend.playback, playback.MyPlaybackProvider)
Once you have a backend instance to work with, testing the various playback,
library, and other providers is straight forward and should not require any
special setup or processing.
Testing libraries
-----------------
Library test cases should cover the implementations of the standard Mopidy
API (e.g. ``browse``, ``lookup``, ``refresh``, ``get_images``, ``search``,
etc.)
Testing playback controllers
----------------------------
Testing ``change_track`` and ``translate_uri`` is probably the highest
priority, since these methods are used to prepare the track and provide its
audio URL to Mopidy's core for playback.
Testing frontends
-----------------
Because most frontends will interact with the Mopidy core, it will most likely
be necessary to have a full core running for testing purposes::
self.core = core.Core.start(
config, backends=[get_backend(config)]).proxy()
It may be advisable to take a quick look at the
`Pykka API <https://www.pykka.org/en/latest/>`_ at this point to make sure that
you are familiar with ``ThreadingActor``, ``ThreadingFuture``, and the
``proxies`` that allow you to access the attributes and methods of the actor
directly.
You'll also need a list of :class:`~mopidy.models.Track` and a list of URIs in
order to populate the core with some simple tracks that can be used for
testing::
class BaseTest(unittest.TestCase):
tracks = [
models.Track(uri='my_scheme:track:id1', length=40000), # Regular track
models.Track(uri='my_scheme:track:id2', length=None), # No duration
]
uris = [ 'my_scheme:track:id1', 'my_scheme:track:id2']
In the ``setup()`` method of your test class, you will then probably need to
monkey patch looking up tracks in the library (so that it will always use the
lists that you defined), and then populate the core's tracklist::
def lookup(uris):
result = {uri: [] for uri in uris}
for track in self.tracks:
if track.uri in result:
result[track.uri].append(track)
return result
self.core.library.lookup = lookup
self.tl_tracks = self.core.tracklist.add(uris=self.uris).get()
With all of that done you should finally be ready to instantiate your frontend::
self.frontend = frontend.MyFrontend.start(config(), self.core).proxy()
Keep in mind that the normal core and frontend methods will usually return
``pykka.ThreadingFuture`` objects, so you will need to add ``.get()`` at
the end of most method calls in order to get to the actual return values.
Triggering events
-----------------
There may be test case scenarios that require simulating certain event triggers
that your extension's actors can listen for and respond on. An example for
patching the listener to store these events, and then play them back for your
actor, may look something like this::
self.events = []
self.patcher = mock.patch('mopidy.listener.send')
self.send_mock = self.patcher.start()
def send(cls, event, **kwargs):
self.events.append((event, kwargs))
self.send_mock.side_effect = send
Once all of the events have been captured, a method like
``replay_events()`` can be called at the relevant points in the code to have
the events fire::
def replay_events(self, my_actor, until=None):
while self.events:
if self.events[0][0] == until:
break
event, kwargs = self.events.pop(0)
frontend.on_event(event, **kwargs).get()
For further details and examples, refer to the
`/tests <https://github.com/mopidy/mopidy/tree/develop/tests>`_
directory on the Mopidy development branch.

View File

@ -82,6 +82,7 @@ announcements related to Mopidy and Mopidy extensions.
config config
running running
service service
audio
troubleshooting troubleshooting

View File

@ -4,7 +4,7 @@
Raspberry Pi Raspberry Pi
************ ************
Mopidy runs on all versions of `Raspberry Pi <http://www.raspberrypi.org/>`_. Mopidy runs on all versions of `Raspberry Pi <https://www.raspberrypi.org/>`_.
However, note that Raspberry Pi 2 B's CPU is approximately six times as However, note that Raspberry Pi 2 B's CPU is approximately six times as
powerful as Raspberry Pi 1 and Raspberry Pi Zero, so Mopidy will be more joyful powerful as Raspberry Pi 1 and Raspberry Pi Zero, so Mopidy will be more joyful
to use on a Raspberry Pi 2. to use on a Raspberry Pi 2.

View File

@ -37,36 +37,34 @@ please follow the directions :ref:`here <contributing>`.
On Fedora Linux, you must replace ``pip`` with ``pip-python`` in the On Fedora Linux, you must replace ``pip`` with ``pip-python`` in the
following steps. following steps.
#. Then you'll need to install GStreamer 0.10 (>= 0.10.31, < 0.11), with Python #. Then you'll need to install GStreamer >= 1.2.3, with Python bindings.
bindings. GStreamer is packaged for most popular Linux distributions. Search GStreamer is packaged for most popular Linux distributions. Search for
for GStreamer in your package manager, and make sure to install the Python GStreamer in your package manager, and make sure to install the Python
bindings, and the "good" and "ugly" plugin sets. bindings, and the "good" and "ugly" plugin sets.
If you use Debian/Ubuntu you can install GStreamer like this:: If you use Debian/Ubuntu you can install GStreamer like this::
sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \ sudo apt-get install python-gst-1.0 \
gstreamer0.10-plugins-ugly gstreamer0.10-tools gir1.2-gstreamer-1.0 gir1.2-gst-plugins-base-1.0 \
gstreamer1.0-plugins-good gstreamer1.0-plugins-ugly \
gstreamer1.0-tools
If you use Arch Linux, install the following packages from the official If you use Arch Linux, install the following packages from the official
repository:: repository::
sudo pacman -S gstreamer0.10-python gstreamer0.10-good-plugins \ sudo pacman -S gst-python2 gst-plugins-good gst-plugins-ugly
gstreamer0.10-ugly-plugins
If you use Fedora you can install GStreamer like this:: If you use Fedora you can install GStreamer like this::
sudo yum install -y python-gst0.10 gstreamer0.10-plugins-good \ sudo yum install -y python-gstreamer1 gstreamer1-plugins-good \
gstreamer0.10-plugins-ugly gstreamer0.10-tools gstreamer1-plugins-ugly
If you use Gentoo you need to be careful because GStreamer 0.10 is in a If you use Gentoo you can install GStreamer like this::
different lower slot than 1.0, the default. Your emerge commands will need
to include the slot::
emerge -av gst-python gst-plugins-bad:0.10 gst-plugins-good:0.10 \ emerge -av gst-python gst-plugins-meta
gst-plugins-ugly:0.10 gst-plugins-meta:0.10
``gst-plugins-meta:0.10`` is the one that actually pulls in the plugins you ``gst-plugins-meta`` is the one that actually pulls in the plugins you want,
want, so pay attention to the use flags, e.g. ``alsa``, ``mp3``, etc. so pay attention to the USE flags, e.g. ``alsa``, ``mp3``, etc.
#. Install the latest release of Mopidy:: #. Install the latest release of Mopidy::
@ -76,11 +74,6 @@ please follow the directions :ref:`here <contributing>`.
<https://pypi.python.org/pypi/Mopidy>`_. To upgrade Mopidy to future <https://pypi.python.org/pypi/Mopidy>`_. To upgrade Mopidy to future
releases, just rerun this command. releases, just rerun this command.
Alternatively, if you want to track Mopidy development closer, you may
install a snapshot of Mopidy's ``develop`` Git branch using pip::
sudo pip install --allow-unverified=mopidy mopidy==dev
#. Finally, you need to set a couple of :doc:`config values </config>`, and #. Finally, you need to set a couple of :doc:`config values </config>`, and
then you're ready to :doc:`run Mopidy </running>`. then you're ready to :doc:`run Mopidy </running>`.

View File

@ -14,20 +14,16 @@ the same way on their distribution.
Configuration Configuration
============= =============
All configuration is in :file:`/etc/mopidy`, not in your user's home directory. All configuration is in :file:`/etc/mopidy/mopidy.conf`, not in your user's
home directory.
The main configuration file is :file:`/etc/mopidy/mopidy.conf`. If there are
more than one configuration file, this is the configuration file with the
highest priority, so it can override configs from all other config files.
Thus, you can do all your changes in this file.
mopidy user mopidy user
=========== ===========
The init script runs Mopidy as the ``mopidy`` user, which is automatically The Mopidy service runs as the ``mopidy`` user, which is automatically created
created when you install the Mopidy package. The ``mopidy`` user will need read when you install the Mopidy package. The ``mopidy`` user will need read access
access to any local music you want Mopidy to play. to any local music you want Mopidy to play.
Subcommands Subcommands
@ -96,3 +92,46 @@ Service on OS X
=============== ===============
If you're installing Mopidy on OS X, see :ref:`osx-service`. If you're installing Mopidy on OS X, see :ref:`osx-service`.
Configure PulseAudio
====================
When using PulseAudio, you will typically have a PulseAudio server run by your
main user. Since Mopidy is running as its own user, it can't access this server
directly. Running PulseAudio as a system-wide daemon is discouraged by upstream
(see `here
<http://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/WhatIsWrongWithSystemWide/>`_
for details). Rather you can configure PulseAudio and Mopidy so Mopidy sends
the sound to the PulseAudio server already running as your main user.
First, configure PulseAudio to accept sound over TCP from localhost by
uncommenting or adding the TCP module to :file:`/etc/pulse/default.pa` or
:file:`$XDG_CONFIG_HOME/pulse/default.pa` (typically
:file:`~/.config/pulse/default.pa`)::
### Network access (may be configured with paprefs, so leave this commented
### here if you plan to use paprefs)
#load-module module-esound-protocol-tcp
load-module module-native-protocol-tcp auth-ip-acl=127.0.0.1
#load-module module-zeroconf-publish
Next, configure Mopidy to use this PulseAudio server::
[audio]
output = pulsesink server=127.0.0.1
After this, restart both PulseAudio and Mopidy::
pulseaudio --kill
start-pulseaudio-x11
sudo systemctl restart mopidy
If you are not running any X server, run ``pulseaudio --start`` instead of
``start-pulseaudio-x11``.
If you don't want to hard code the output in your Mopidy config, you can
instead of adding any config to Mopidy add this to
:file:`~mopidy/.pulse/client.conf`::
default-server=127.0.0.1

View File

@ -14,4 +14,4 @@ if not (2, 7) <= sys.version_info < (3,):
warnings.filterwarnings('ignore', 'could not open display') warnings.filterwarnings('ignore', 'could not open display')
__version__ = '1.1.2' __version__ = '2.0.0'

View File

@ -4,24 +4,8 @@ import logging
import os import os
import signal import signal
import sys import sys
import textwrap
try: from mopidy.internal.gi import Gst # noqa: Import to initialize
import gobject # noqa
except ImportError:
print(textwrap.dedent("""
ERROR: The gobject Python package was not found.
Mopidy requires GStreamer (and GObject) to work. These are C libraries
with a number of dependencies themselves, and cannot be installed with
the regular Python tools like pip.
Please see http://docs.mopidy.com/en/latest/installation/ for
instructions on how to install the required dependencies.
"""))
raise
gobject.threads_init()
try: try:
# Make GObject's mainloop the event loop for python-dbus # Make GObject's mainloop the event loop for python-dbus
@ -33,13 +17,6 @@ except ImportError:
import pykka.debug import pykka.debug
# Extract any command line arguments. This needs to be done before GStreamer is
# imported, so that GStreamer doesn't hijack e.g. ``--help``.
mopidy_args = sys.argv[1:]
sys.argv[1:] = []
from mopidy import commands, config as config_lib, ext from mopidy import commands, config as config_lib, ext
from mopidy.internal import encoding, log, path, process, versioning from mopidy.internal import encoding, log, path, process, versioning
@ -50,7 +27,7 @@ def main():
log.bootstrap_delayed_logging() log.bootstrap_delayed_logging()
logger.info('Starting Mopidy %s', versioning.get_version()) logger.info('Starting Mopidy %s', versioning.get_version())
signal.signal(signal.SIGTERM, process.exit_handler) signal.signal(signal.SIGTERM, process.sigterm_handler)
# Windows does not have signal.SIGUSR1 # Windows does not have signal.SIGUSR1
if hasattr(signal, 'SIGUSR1'): if hasattr(signal, 'SIGUSR1'):
signal.signal(signal.SIGUSR1, pykka.debug.log_thread_tracebacks) signal.signal(signal.SIGUSR1, pykka.debug.log_thread_tracebacks)
@ -73,7 +50,7 @@ def main():
data.command.set(extension=data.extension) data.command.set(extension=data.extension)
root_cmd.add_child(data.extension.ext_name, data.command) root_cmd.add_child(data.extension.ext_name, data.command)
args = root_cmd.parse(mopidy_args) args = root_cmd.parse(sys.argv[1:])
config, config_errors = config_lib.load( config, config_errors = config_lib.load(
args.config_files, args.config_files,
@ -83,7 +60,6 @@ def main():
create_core_dirs(config) create_core_dirs(config)
create_initial_config_file(args, extensions_data) create_initial_config_file(args, extensions_data)
check_old_locations()
verbosity_level = args.base_verbosity_level verbosity_level = args.base_verbosity_level
if args.verbosity_level: if args.verbosity_level:
@ -191,22 +167,6 @@ def create_initial_config_file(args, extensions_data):
config_file, encoding.locale_decode(error)) config_file, encoding.locale_decode(error))
def check_old_locations():
dot_mopidy_dir = path.expand_path(b'~/.mopidy')
if os.path.isdir(dot_mopidy_dir):
logger.warning(
'Old Mopidy dot dir found at %s. Please migrate your config to '
'the ini-file based config format. See release notes for further '
'instructions.', dot_mopidy_dir)
old_settings_file = path.expand_path(b'$XDG_CONFIG_DIR/mopidy/settings.py')
if os.path.isfile(old_settings_file):
logger.warning(
'Old Mopidy settings file found at %s. Please migrate your '
'config to the ini-file based config format. See release notes '
'for further instructions.', old_settings_file)
def log_extension_info(all_extensions, enabled_extensions): def log_extension_info(all_extensions, enabled_extensions):
# TODO: distinguish disabled vs blocked by env? # TODO: distinguish disabled vs blocked by env?
enabled_names = set(e.ext_name for e in enabled_extensions) enabled_names = set(e.ext_name for e in enabled_extensions)

View File

@ -2,66 +2,30 @@ from __future__ import absolute_import, unicode_literals
import logging import logging
import os import os
import threading
import gobject
import pygst
pygst.require('0.10')
import gst # noqa
import gst.pbutils # noqa
import pykka import pykka
from mopidy import exceptions from mopidy import exceptions
from mopidy.audio import icy, utils from mopidy.audio import tags as tags_lib, utils
from mopidy.audio.constants import PlaybackState from mopidy.audio.constants import PlaybackState
from mopidy.audio.listener import AudioListener from mopidy.audio.listener import AudioListener
from mopidy.internal import deprecation, process from mopidy.internal import deprecation, process
from mopidy.internal.gi import GObject, Gst, GstPbutils
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# This logger is only meant for debug logging of low level gstreamer info such # This logger is only meant for debug logging of low level GStreamer info such
# as callbacks, event, messages and direct interaction with GStreamer such as # as callbacks, event, messages and direct interaction with GStreamer such as
# set_state on a pipeline. # set_state() on a pipeline.
gst_logger = logging.getLogger('mopidy.audio.gst') gst_logger = logging.getLogger('mopidy.audio.gst')
icy.register()
_GST_STATE_MAPPING = { _GST_STATE_MAPPING = {
gst.STATE_PLAYING: PlaybackState.PLAYING, Gst.State.PLAYING: PlaybackState.PLAYING,
gst.STATE_PAUSED: PlaybackState.PAUSED, Gst.State.PAUSED: PlaybackState.PAUSED,
gst.STATE_NULL: PlaybackState.STOPPED} Gst.State.NULL: PlaybackState.STOPPED,
}
class _Signals(object):
"""Helper for tracking gobject signal registrations"""
def __init__(self):
self._ids = {}
def connect(self, element, event, func, *args):
"""Connect a function + args to signal event on an element.
Each event may only be handled by one callback in this implementation.
"""
assert (element, event) not in self._ids
self._ids[(element, event)] = element.connect(event, func, *args)
def disconnect(self, element, event):
"""Disconnect whatever handler we have for and element+event pair.
Does nothing it the handler has already been removed.
"""
signal_id = self._ids.pop((element, event), None)
if signal_id is not None:
element.disconnect(signal_id)
def clear(self):
"""Clear all registered signal handlers."""
for element, event in self._ids.keys():
element.disconnect(self._ids.pop((element, event)))
# TODO: expose this as a property on audio? # TODO: expose this as a property on audio?
@ -70,7 +34,7 @@ class _Appsrc(object):
"""Helper class for dealing with appsrc based playback.""" """Helper class for dealing with appsrc based playback."""
def __init__(self): def __init__(self):
self._signals = _Signals() self._signals = utils.Signals()
self.reset() self.reset()
def reset(self): def reset(self):
@ -119,9 +83,11 @@ class _Appsrc(object):
if buffer_ is None: if buffer_ is None:
gst_logger.debug('Sending appsrc end-of-stream event.') gst_logger.debug('Sending appsrc end-of-stream event.')
return self._source.emit('end-of-stream') == gst.FLOW_OK result = self._source.emit('end-of-stream')
return result == Gst.FlowReturn.OK
else: else:
return self._source.emit('push-buffer', buffer_) == gst.FLOW_OK result = self._source.emit('push-buffer', buffer_)
return result == Gst.FlowReturn.OK
def _on_signal(self, element, clocktime, func): def _on_signal(self, element, clocktime, func):
# This shim is used to ensure we always return true, and also handles # This shim is used to ensure we always return true, and also handles
@ -134,29 +100,30 @@ class _Appsrc(object):
# TODO: expose this as a property on audio when #790 gets further along. # TODO: expose this as a property on audio when #790 gets further along.
class _Outputs(gst.Bin): class _Outputs(Gst.Bin):
def __init__(self): def __init__(self):
gst.Bin.__init__(self, 'outputs') Gst.Bin.__init__(self)
# TODO gst1: Set 'outputs' as the Bin name for easier debugging
self._tee = gst.element_factory_make('tee') self._tee = Gst.ElementFactory.make('tee')
self.add(self._tee) self.add(self._tee)
ghost_pad = gst.GhostPad('sink', self._tee.get_pad('sink')) ghost_pad = Gst.GhostPad.new('sink', self._tee.get_static_pad('sink'))
self.add_pad(ghost_pad) self.add_pad(ghost_pad)
# Add an always connected fakesink which respects the clock so the tee # Add an always connected fakesink which respects the clock so the tee
# doesn't fail even if we don't have any outputs. # doesn't fail even if we don't have any outputs.
fakesink = gst.element_factory_make('fakesink') fakesink = Gst.ElementFactory.make('fakesink')
fakesink.set_property('sync', True) fakesink.set_property('sync', True)
self._add(fakesink) self._add(fakesink)
def add_output(self, description): def add_output(self, description):
# XXX This only works for pipelines not in use until #790 gets done. # XXX This only works for pipelines not in use until #790 gets done.
try: try:
output = gst.parse_bin_from_description( output = Gst.parse_bin_from_description(
description, ghost_unconnected_pads=True) description, ghost_unlinked_pads=True)
except gobject.GError as ex: except GObject.GError as ex:
logger.error( logger.error(
'Failed to create audio output "%s": %s', description, ex) 'Failed to create audio output "%s": %s', description, ex)
raise exceptions.AudioException(bytes(ex)) raise exceptions.AudioException(bytes(ex))
@ -165,7 +132,7 @@ class _Outputs(gst.Bin):
logger.info('Audio output set to "%s"', description) logger.info('Audio output set to "%s"', description)
def _add(self, element): def _add(self, element):
queue = gst.element_factory_make('queue') queue = Gst.ElementFactory.make('queue')
self.add(element) self.add(element)
self.add(queue) self.add(queue)
queue.link(element) queue.link(element)
@ -180,7 +147,7 @@ class SoftwareMixer(object):
self._element = None self._element = None
self._last_volume = None self._last_volume = None
self._last_mute = None self._last_mute = None
self._signals = _Signals() self._signals = utils.Signals()
def setup(self, element, mixer_ref): def setup(self, element, mixer_ref):
self._element = element self._element = element
@ -222,7 +189,8 @@ class _Handler(object):
def setup_event_handling(self, pad): def setup_event_handling(self, pad):
self._pad = pad self._pad = pad
self._event_handler_id = pad.add_event_probe(self.on_event) self._event_handler_id = pad.add_probe(
Gst.PadProbeType.EVENT_BOTH, self.on_pad_event)
def teardown_message_handling(self): def teardown_message_handling(self):
bus = self._element.get_bus() bus = self._element.get_bus()
@ -231,61 +199,69 @@ class _Handler(object):
self._message_handler_id = None self._message_handler_id = None
def teardown_event_handling(self): def teardown_event_handling(self):
self._pad.remove_event_probe(self._event_handler_id) self._pad.remove_probe(self._event_handler_id)
self._event_handler_id = None self._event_handler_id = None
def on_message(self, bus, msg): def on_message(self, bus, msg):
if msg.type == gst.MESSAGE_STATE_CHANGED and msg.src == self._element: if msg.type == Gst.MessageType.STATE_CHANGED:
self.on_playbin_state_changed(*msg.parse_state_changed()) if msg.src != self._element:
elif msg.type == gst.MESSAGE_BUFFERING: return
self.on_buffering(msg.parse_buffering(), msg.structure) old_state, new_state, pending_state = msg.parse_state_changed()
elif msg.type == gst.MESSAGE_EOS: self.on_playbin_state_changed(old_state, new_state, pending_state)
elif msg.type == Gst.MessageType.BUFFERING:
self.on_buffering(msg.parse_buffering(), msg.get_structure())
elif msg.type == Gst.MessageType.EOS:
self.on_end_of_stream() self.on_end_of_stream()
elif msg.type == gst.MESSAGE_ERROR: elif msg.type == Gst.MessageType.ERROR:
self.on_error(*msg.parse_error()) error, debug = msg.parse_error()
elif msg.type == gst.MESSAGE_WARNING: self.on_error(error, debug)
self.on_warning(*msg.parse_warning()) elif msg.type == Gst.MessageType.WARNING:
elif msg.type == gst.MESSAGE_ASYNC_DONE: error, debug = msg.parse_warning()
self.on_warning(error, debug)
elif msg.type == Gst.MessageType.ASYNC_DONE:
self.on_async_done() self.on_async_done()
elif msg.type == gst.MESSAGE_TAG: elif msg.type == Gst.MessageType.TAG:
self.on_tag(msg.parse_tag()) taglist = msg.parse_tag()
elif msg.type == gst.MESSAGE_ELEMENT: self.on_tag(taglist)
if gst.pbutils.is_missing_plugin_message(msg): elif msg.type == Gst.MessageType.ELEMENT:
if GstPbutils.is_missing_plugin_message(msg):
self.on_missing_plugin(msg) self.on_missing_plugin(msg)
elif msg.type == Gst.MessageType.STREAM_START:
self.on_stream_start()
def on_event(self, pad, event): def on_pad_event(self, pad, pad_probe_info):
if event.type == gst.EVENT_NEWSEGMENT: event = pad_probe_info.get_event()
self.on_new_segment(*event.parse_new_segment()) if event.type == Gst.EventType.SEGMENT:
elif event.type == gst.EVENT_SINK_MESSAGE: self.on_segment(event.parse_segment())
# Handle stream changed messages when they reach our output bin. return Gst.PadProbeReturn.OK
# If we listen for it on the bus we get one per tee branch.
msg = event.parse_sink_message()
if msg.structure.has_name('playbin2-stream-changed'):
self.on_stream_changed(msg.structure['uri'])
return True
def on_playbin_state_changed(self, old_state, new_state, pending_state): def on_playbin_state_changed(self, old_state, new_state, pending_state):
gst_logger.debug('Got state-changed message: old=%s new=%s pending=%s', gst_logger.debug(
old_state.value_name, new_state.value_name, 'Got STATE_CHANGED bus message: old=%s new=%s pending=%s',
pending_state.value_name) old_state.value_name, new_state.value_name,
pending_state.value_name)
if new_state == gst.STATE_READY and pending_state == gst.STATE_NULL: if new_state == Gst.State.READY and pending_state == Gst.State.NULL:
# XXX: We're not called on the last state change when going down to # XXX: We're not called on the last state change when going down to
# NULL, so we rewrite the second to last call to get the expected # NULL, so we rewrite the second to last call to get the expected
# behavior. # behavior.
new_state = gst.STATE_NULL new_state = Gst.State.NULL
pending_state = gst.STATE_VOID_PENDING pending_state = Gst.State.VOID_PENDING
if pending_state != gst.STATE_VOID_PENDING: if pending_state != Gst.State.VOID_PENDING:
return # Ignore intermediate state changes return # Ignore intermediate state changes
if new_state == gst.STATE_READY: if new_state == Gst.State.READY:
return # Ignore READY state as it's GStreamer specific return # Ignore READY state as it's GStreamer specific
new_state = _GST_STATE_MAPPING[new_state] new_state = _GST_STATE_MAPPING[new_state]
old_state, self._audio.state = self._audio.state, new_state old_state, self._audio.state = self._audio.state, new_state
target_state = _GST_STATE_MAPPING[self._audio._target_state] target_state = _GST_STATE_MAPPING.get(self._audio._target_state)
if target_state is None:
# XXX: Workaround for #1430, to be fixed properly by #1222.
logger.debug('Race condition happened. See #1222 and #1430.')
return
if target_state == new_state: if target_state == new_state:
target_state = None target_state = None
@ -298,80 +274,119 @@ class _Handler(object):
AudioListener.send('stream_changed', uri=None) AudioListener.send('stream_changed', uri=None)
if 'GST_DEBUG_DUMP_DOT_DIR' in os.environ: if 'GST_DEBUG_DUMP_DOT_DIR' in os.environ:
gst.DEBUG_BIN_TO_DOT_FILE( Gst.debug_bin_to_dot_file(
self._audio._playbin, gst.DEBUG_GRAPH_SHOW_ALL, 'mopidy') self._audio._playbin, Gst.DebugGraphDetails.ALL, 'mopidy')
def on_buffering(self, percent, structure=None): def on_buffering(self, percent, structure=None):
if structure and structure.has_field('buffering-mode'): if structure is not None and structure.has_field('buffering-mode'):
if structure['buffering-mode'] == gst.BUFFERING_LIVE: buffering_mode = structure.get_enum(
'buffering-mode', Gst.BufferingMode)
if buffering_mode == Gst.BufferingMode.LIVE:
return # Live sources stall in paused. return # Live sources stall in paused.
level = logging.getLevelName('TRACE') level = logging.getLevelName('TRACE')
if percent < 10 and not self._audio._buffering: if percent < 10 and not self._audio._buffering:
self._audio._playbin.set_state(gst.STATE_PAUSED) self._audio._playbin.set_state(Gst.State.PAUSED)
self._audio._buffering = True self._audio._buffering = True
level = logging.DEBUG level = logging.DEBUG
if percent == 100: if percent == 100:
self._audio._buffering = False self._audio._buffering = False
if self._audio._target_state == gst.STATE_PLAYING: if self._audio._target_state == Gst.State.PLAYING:
self._audio._playbin.set_state(gst.STATE_PLAYING) self._audio._playbin.set_state(Gst.State.PLAYING)
level = logging.DEBUG level = logging.DEBUG
gst_logger.log(level, 'Got buffering message: percent=%d%%', percent) gst_logger.log(
level, 'Got BUFFERING bus message: percent=%d%%', percent)
def on_end_of_stream(self): def on_end_of_stream(self):
gst_logger.debug('Got end-of-stream message.') gst_logger.debug('Got EOS (end of stream) bus message.')
logger.debug('Audio event: reached_end_of_stream()') logger.debug('Audio event: reached_end_of_stream()')
self._audio._tags = {} self._audio._tags = {}
AudioListener.send('reached_end_of_stream') AudioListener.send('reached_end_of_stream')
def on_error(self, error, debug): def on_error(self, error, debug):
gst_logger.error(str(error).decode('utf-8')) error_msg = str(error).decode('utf-8')
if debug: debug_msg = debug.decode('utf-8')
gst_logger.debug(debug.decode('utf-8')) gst_logger.debug(
'Got ERROR bus message: error=%r debug=%r', error_msg, debug_msg)
gst_logger.error('GStreamer error: %s', error_msg)
# TODO: is this needed? # TODO: is this needed?
self._audio.stop_playback() self._audio.stop_playback()
def on_warning(self, error, debug): def on_warning(self, error, debug):
gst_logger.warning(str(error).decode('utf-8')) error_msg = str(error).decode('utf-8')
if debug: debug_msg = debug.decode('utf-8')
gst_logger.debug(debug.decode('utf-8')) gst_logger.warning('GStreamer warning: %s', error_msg)
gst_logger.debug(
'Got WARNING bus message: error=%r debug=%r', error_msg, debug_msg)
def on_async_done(self): def on_async_done(self):
gst_logger.debug('Got async-done.') gst_logger.debug('Got ASYNC_DONE bus message.')
def on_tag(self, taglist): def on_tag(self, taglist):
tags = utils.convert_taglist(taglist) tags = tags_lib.convert_taglist(taglist)
self._audio._tags.update(tags) gst_logger.debug('Got TAG bus message: tags=%r', dict(tags))
logger.debug('Audio event: tags_changed(tags=%r)', tags.keys())
AudioListener.send('tags_changed', tags=tags.keys()) # Postpone emitting tags until stream start.
if self._audio._pending_tags is not None:
self._audio._pending_tags.update(tags)
return
# TODO: Add proper tests for only emitting changed tags.
unique = object()
changed = []
for key, value in tags.items():
# Update any tags that changed, and store changed keys.
if self._audio._tags.get(key, unique) != value:
self._audio._tags[key] = value
changed.append(key)
if changed:
logger.debug('Audio event: tags_changed(tags=%r)', changed)
AudioListener.send('tags_changed', tags=changed)
def on_missing_plugin(self, msg): def on_missing_plugin(self, msg):
desc = gst.pbutils.missing_plugin_message_get_description(msg) desc = GstPbutils.missing_plugin_message_get_description(msg)
debug = gst.pbutils.missing_plugin_message_get_installer_detail(msg) debug = GstPbutils.missing_plugin_message_get_installer_detail(msg)
gst_logger.debug(
gst_logger.debug('Got missing-plugin message: description:%s', desc) 'Got missing-plugin bus message: description=%r', desc)
logger.warning('Could not find a %s to handle media.', desc) logger.warning('Could not find a %s to handle media.', desc)
if gst.pbutils.install_plugins_supported(): if GstPbutils.install_plugins_supported():
logger.info('You might be able to fix this by running: ' logger.info('You might be able to fix this by running: '
'gst-installer "%s"', debug) 'gst-installer "%s"', debug)
# TODO: store the missing plugins installer info in a file so we can # TODO: store the missing plugins installer info in a file so we can
# can provide a 'mopidy install-missing-plugins' if the system has the # can provide a 'mopidy install-missing-plugins' if the system has the
# required helper installed? # required helper installed?
def on_new_segment(self, update, rate, format_, start, stop, position): def on_stream_start(self):
gst_logger.debug('Got new-segment event: update=%s rate=%s format=%s ' gst_logger.debug('Got STREAM_START bus message')
'start=%s stop=%s position=%s', update, rate, uri = self._audio._pending_uri
format_.value_name, start, stop, position) logger.debug('Audio event: stream_changed(uri=%r)', uri)
position_ms = position // gst.MSECOND
logger.debug('Audio event: position_changed(position=%s)', position_ms)
AudioListener.send('position_changed', position=position_ms)
def on_stream_changed(self, uri):
gst_logger.debug('Got stream-changed message: uri=%s', uri)
logger.debug('Audio event: stream_changed(uri=%s)', uri)
AudioListener.send('stream_changed', uri=uri) AudioListener.send('stream_changed', uri=uri)
# Emit any postponed tags that we got after about-to-finish.
tags, self._audio._pending_tags = self._audio._pending_tags, None
self._audio._tags = tags
if tags:
logger.debug('Audio event: tags_changed(tags=%r)', tags.keys())
AudioListener.send('tags_changed', tags=tags.keys())
def on_segment(self, segment):
gst_logger.debug(
'Got SEGMENT pad event: '
'rate=%(rate)s format=%(format)s start=%(start)s stop=%(stop)s '
'position=%(position)s', {
'rate': segment.rate,
'format': Gst.Format.get_name(segment.format),
'start': segment.start,
'stop': segment.stop,
'position': segment.position
})
position_ms = segment.position // Gst.MSECOND
logger.debug('Audio event: position_changed(position=%r)', position_ms)
AudioListener.send('position_changed', position=position_ms)
# TODO: create a player class which replaces the actors internals # TODO: create a player class which replaces the actors internals
class Audio(pykka.ThreadingActor): class Audio(pykka.ThreadingActor):
@ -390,28 +405,32 @@ class Audio(pykka.ThreadingActor):
super(Audio, self).__init__() super(Audio, self).__init__()
self._config = config self._config = config
self._target_state = gst.STATE_NULL self._target_state = Gst.State.NULL
self._buffering = False self._buffering = False
self._tags = {} self._tags = {}
self._pending_uri = None
self._pending_tags = None
self._playbin = None self._playbin = None
self._outputs = None self._outputs = None
self._queue = None
self._about_to_finish_callback = None self._about_to_finish_callback = None
self._handler = _Handler(self) self._handler = _Handler(self)
self._appsrc = _Appsrc() self._appsrc = _Appsrc()
self._signals = _Signals() self._signals = utils.Signals()
if mixer and self._config['audio']['mixer'] == 'software': if mixer and self._config['audio']['mixer'] == 'software':
self.mixer = SoftwareMixer(mixer) self.mixer = SoftwareMixer(mixer)
def on_start(self): def on_start(self):
self._thread = threading.current_thread()
try: try:
self._setup_preferences() self._setup_preferences()
self._setup_playbin() self._setup_playbin()
self._setup_outputs() self._setup_outputs()
self._setup_audio_sink() self._setup_audio_sink()
except gobject.GError as ex: except GObject.GError as ex:
logger.exception(ex) logger.exception(ex)
process.exit_process() process.exit_process()
@ -422,19 +441,18 @@ class Audio(pykka.ThreadingActor):
def _setup_preferences(self): def _setup_preferences(self):
# TODO: move out of audio actor? # TODO: move out of audio actor?
# Fix for https://github.com/mopidy/mopidy/issues/604 # Fix for https://github.com/mopidy/mopidy/issues/604
registry = gst.registry_get_default() registry = Gst.Registry.get()
jacksink = registry.find_feature( jacksink = registry.find_feature('jackaudiosink', Gst.ElementFactory)
'jackaudiosink', gst.TYPE_ELEMENT_FACTORY)
if jacksink: if jacksink:
jacksink.set_rank(gst.RANK_SECONDARY) jacksink.set_rank(Gst.Rank.SECONDARY)
def _setup_playbin(self): def _setup_playbin(self):
playbin = gst.element_factory_make('playbin2') playbin = Gst.ElementFactory.make('playbin')
playbin.set_property('flags', 2) # GST_PLAY_FLAG_AUDIO playbin.set_property('flags', 2) # GST_PLAY_FLAG_AUDIO
# TODO: turn into config values... # TODO: turn into config values...
playbin.set_property('buffer-size', 5 << 20) # 5MB playbin.set_property('buffer-size', 5 << 20) # 5MB
playbin.set_property('buffer-duration', 5 * gst.SECOND) playbin.set_property('buffer-duration', 5 * Gst.SECOND)
self._signals.connect(playbin, 'source-setup', self._on_source_setup) self._signals.connect(playbin, 'source-setup', self._on_source_setup)
self._signals.connect(playbin, 'about-to-finish', self._signals.connect(playbin, 'about-to-finish',
@ -448,13 +466,13 @@ class Audio(pykka.ThreadingActor):
self._handler.teardown_event_handling() self._handler.teardown_event_handling()
self._signals.disconnect(self._playbin, 'about-to-finish') self._signals.disconnect(self._playbin, 'about-to-finish')
self._signals.disconnect(self._playbin, 'source-setup') self._signals.disconnect(self._playbin, 'source-setup')
self._playbin.set_state(gst.STATE_NULL) self._playbin.set_state(Gst.State.NULL)
def _setup_outputs(self): def _setup_outputs(self):
# We don't want to use outputs for regular testing, so just install # We don't want to use outputs for regular testing, so just install
# an unsynced fakesink when someone asks for a 'testoutput'. # an unsynced fakesink when someone asks for a 'testoutput'.
if self._config['audio']['output'] == 'testoutput': if self._config['audio']['output'] == 'testoutput':
self._outputs = gst.element_factory_make('fakesink') self._outputs = Gst.ElementFactory.make('fakesink')
else: else:
self._outputs = _Outputs() self._outputs = _Outputs()
try: try:
@ -462,26 +480,30 @@ class Audio(pykka.ThreadingActor):
except exceptions.AudioException: except exceptions.AudioException:
process.exit_process() # TODO: move this up the chain process.exit_process() # TODO: move this up the chain
self._handler.setup_event_handling(self._outputs.get_pad('sink')) self._handler.setup_event_handling(
self._outputs.get_static_pad('sink'))
def _setup_audio_sink(self): def _setup_audio_sink(self):
audio_sink = gst.Bin('audio-sink') audio_sink = Gst.ElementFactory.make('bin', 'audio-sink')
# Queue element to buy us time between the about to finish event and # Queue element to buy us time between the about-to-finish event and
# the actual switch, i.e. about to switch can block for longer thanks # the actual switch, i.e. about to switch can block for longer thanks
# to this queue. # to this queue.
# TODO: make the min-max values a setting? # TODO: See if settings should be set to minimize latency. Previous
queue = gst.element_factory_make('queue') # setting breaks appsrc, and settings before that broke on a few
queue.set_property('max-size-buffers', 0) # systems. So leave the default to play it safe.
queue.set_property('max-size-bytes', 0) queue = Gst.ElementFactory.make('queue')
queue.set_property('max-size-time', 3 * gst.SECOND)
queue.set_property('min-threshold-time', 1 * gst.SECOND) if self._config['audio']['buffer_time'] > 0:
queue.set_property(
'max-size-time',
self._config['audio']['buffer_time'] * Gst.MSECOND)
audio_sink.add(queue) audio_sink.add(queue)
audio_sink.add(self._outputs) audio_sink.add(self._outputs)
if self.mixer: if self.mixer:
volume = gst.element_factory_make('volume') volume = Gst.ElementFactory.make('volume')
audio_sink.add(volume) audio_sink.add(volume)
queue.link(volume) queue.link(volume)
volume.link(self._outputs) volume.link(self._outputs)
@ -489,23 +511,30 @@ class Audio(pykka.ThreadingActor):
else: else:
queue.link(self._outputs) queue.link(self._outputs)
ghost_pad = gst.GhostPad('sink', queue.get_pad('sink')) ghost_pad = Gst.GhostPad.new('sink', queue.get_static_pad('sink'))
audio_sink.add_pad(ghost_pad) audio_sink.add_pad(ghost_pad)
self._playbin.set_property('audio-sink', audio_sink) self._playbin.set_property('audio-sink', audio_sink)
self._queue = queue
def _teardown_mixer(self): def _teardown_mixer(self):
if self.mixer: if self.mixer:
self.mixer.teardown() self.mixer.teardown()
def _on_about_to_finish(self, element): def _on_about_to_finish(self, element):
if self._thread == threading.current_thread():
logger.error(
'about-to-finish in actor, aborting to avoid deadlock.')
return
gst_logger.debug('Got about-to-finish event.') gst_logger.debug('Got about-to-finish event.')
if self._about_to_finish_callback: if self._about_to_finish_callback:
logger.debug('Running about to finish callback.') logger.debug('Running about-to-finish callback.')
self._about_to_finish_callback() self._about_to_finish_callback()
def _on_source_setup(self, element, source): def _on_source_setup(self, element, source):
gst_logger.debug('Got source-setup: element=%s', source) gst_logger.debug(
'Got source-setup signal: element=%s', source.__class__.__name__)
if source.get_factory().get_name() == 'appsrc': if source.get_factory().get_name() == 'appsrc':
self._appsrc.configure(source) self._appsrc.configure(source)
@ -531,7 +560,8 @@ class Audio(pykka.ThreadingActor):
else: else:
current_volume = None current_volume = None
self._tags = {} # TODO: add test for this somehow self._pending_uri = uri
self._pending_tags = {}
self._playbin.set_property('uri', uri) self._playbin.set_property('uri', uri)
if self.mixer is not None and current_volume is not None: if self.mixer is not None and current_volume is not None:
@ -556,8 +586,10 @@ class Audio(pykka.ThreadingActor):
:type seek_data: callable which takes time position in ms :type seek_data: callable which takes time position in ms
""" """
self._appsrc.prepare( self._appsrc.prepare(
gst.Caps(bytes(caps)), need_data, enough_data, seek_data) Gst.Caps.from_string(caps), need_data, enough_data, seek_data)
self._playbin.set_property('uri', 'appsrc://') uri = 'appsrc://'
self._pending_uri = uri
self._playbin.set_property('uri', uri)
def emit_data(self, buffer_): def emit_data(self, buffer_):
""" """
@ -572,7 +604,7 @@ class Audio(pykka.ThreadingActor):
Returns :class:`True` if data was delivered. Returns :class:`True` if data was delivered.
:param buffer_: buffer to pass to appsrc :param buffer_: buffer to pass to appsrc
:type buffer_: :class:`gst.Buffer` or :class:`None` :type buffer_: :class:`Gst.Buffer` or :class:`None`
:rtype: boolean :rtype: boolean
""" """
return self._appsrc.push(buffer_) return self._appsrc.push(buffer_)
@ -610,15 +642,16 @@ class Audio(pykka.ThreadingActor):
:rtype: int :rtype: int
""" """
try: success, position = self._playbin.query_position(Gst.Format.TIME)
gst_position = self._playbin.query_position(gst.FORMAT_TIME)[0]
return utils.clocktime_to_millisecond(gst_position) if not success:
except gst.QueryError:
# TODO: take state into account for this and possibly also return # TODO: take state into account for this and possibly also return
# None as the unknown value instead of zero? # None as the unknown value instead of zero?
logger.debug('Position query failed') logger.debug('Position query failed')
return 0 return 0
return utils.clocktime_to_millisecond(position)
def set_position(self, position): def set_position(self, position):
""" """
Set position in milliseconds. Set position in milliseconds.
@ -629,9 +662,14 @@ class Audio(pykka.ThreadingActor):
""" """
# TODO: double check seek flags in use. # TODO: double check seek flags in use.
gst_position = utils.millisecond_to_clocktime(position) gst_position = utils.millisecond_to_clocktime(position)
result = self._playbin.seek_simple( gst_logger.debug('Sending flushing seek: position=%r', gst_position)
gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, gst_position) # Send seek event to the queue not the playbin. The default behavior
gst_logger.debug('Sent flushing seek: position=%s', gst_position) # for bins is to forward this event to all sinks. Which results in
# duplicate seek events making it to appsrc. Since elements are not
# allowed to act on the seek event, only modify it, this should be safe
# to do.
result = self._queue.seek_simple(
Gst.Format.TIME, Gst.SeekFlags.FLUSH, gst_position)
return result return result
def start_playback(self): def start_playback(self):
@ -640,7 +678,7 @@ class Audio(pykka.ThreadingActor):
:rtype: :class:`True` if successfull, else :class:`False` :rtype: :class:`True` if successfull, else :class:`False`
""" """
return self._set_state(gst.STATE_PLAYING) return self._set_state(Gst.State.PLAYING)
def pause_playback(self): def pause_playback(self):
""" """
@ -648,7 +686,7 @@ class Audio(pykka.ThreadingActor):
:rtype: :class:`True` if successfull, else :class:`False` :rtype: :class:`True` if successfull, else :class:`False`
""" """
return self._set_state(gst.STATE_PAUSED) return self._set_state(Gst.State.PAUSED)
def prepare_change(self): def prepare_change(self):
""" """
@ -657,9 +695,9 @@ class Audio(pykka.ThreadingActor):
This function *MUST* be called before changing URIs or doing This function *MUST* be called before changing URIs or doing
changes like updating data that is being pushed. The reason for this changes like updating data that is being pushed. The reason for this
is that GStreamer will reset all its state when it changes to is that GStreamer will reset all its state when it changes to
:attr:`gst.STATE_READY`. :attr:`Gst.State.READY`.
""" """
return self._set_state(gst.STATE_READY) return self._set_state(Gst.State.READY)
def stop_playback(self): def stop_playback(self):
""" """
@ -668,14 +706,14 @@ class Audio(pykka.ThreadingActor):
:rtype: :class:`True` if successfull, else :class:`False` :rtype: :class:`True` if successfull, else :class:`False`
""" """
self._buffering = False self._buffering = False
return self._set_state(gst.STATE_NULL) return self._set_state(Gst.State.NULL)
def wait_for_state_change(self): def wait_for_state_change(self):
"""Block until any pending state changes are complete. """Block until any pending state changes are complete.
Should only be used by tests. Should only be used by tests.
""" """
self._playbin.get_state() self._playbin.get_state(timeout=Gst.CLOCK_TIME_NONE)
def enable_sync_handler(self): def enable_sync_handler(self):
"""Enable manual processing of messages from bus. """Enable manual processing of messages from bus.
@ -684,7 +722,7 @@ class Audio(pykka.ThreadingActor):
""" """
def sync_handler(bus, message): def sync_handler(bus, message):
self._handler.on_message(bus, message) self._handler.on_message(bus, message)
return gst.BUS_DROP return Gst.BusSyncReply.DROP
bus = self._playbin.get_bus() bus = self._playbin.get_bus()
bus.set_sync_handler(sync_handler) bus.set_sync_handler(sync_handler)
@ -705,17 +743,18 @@ class Audio(pykka.ThreadingActor):
"READY" -> "NULL" "READY" -> "NULL"
"READY" -> "PAUSED" "READY" -> "PAUSED"
:param state: State to set playbin to. One of: `gst.STATE_NULL`, :param state: State to set playbin to. One of: `Gst.State.NULL`,
`gst.STATE_READY`, `gst.STATE_PAUSED` and `gst.STATE_PLAYING`. `Gst.State.READY`, `Gst.State.PAUSED` and `Gst.State.PLAYING`.
:type state: :class:`gst.State` :type state: :class:`Gst.State`
:rtype: :class:`True` if successfull, else :class:`False` :rtype: :class:`True` if successfull, else :class:`False`
""" """
self._target_state = state self._target_state = state
result = self._playbin.set_state(state) result = self._playbin.set_state(state)
gst_logger.debug('State change to %s: result=%s', state.value_name, gst_logger.debug(
result.value_name) 'Changing state to %s: result=%s', state.value_name,
result.value_name)
if result == gst.STATE_CHANGE_FAILURE: if result == Gst.StateChangeReturn.FAILURE:
logger.warning( logger.warning(
'Setting GStreamer state to %s failed', state.value_name) 'Setting GStreamer state to %s failed', state.value_name)
return False return False
@ -728,35 +767,44 @@ class Audio(pykka.ThreadingActor):
""" """
Set track metadata for currently playing song. Set track metadata for currently playing song.
Only needs to be called by sources such as `appsrc` which do not 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 already inject tags in playbin, e.g. when using :meth:`emit_data` to
deliver raw audio data to GStreamer. deliver raw audio data to GStreamer.
:param track: the current track :param track: the current track
:type track: :class:`mopidy.models.Track` :type track: :class:`mopidy.models.Track`
""" """
taglist = gst.TagList() taglist = Gst.TagList.new_empty()
artists = [a for a in (track.artists or []) if a.name] artists = [a for a in (track.artists or []) if a.name]
def set_value(tag, value):
gobject_value = GObject.Value()
gobject_value.init(GObject.TYPE_STRING)
gobject_value.set_string(value)
taglist.add_value(Gst.TagMergeMode.REPLACE, tag, gobject_value)
# Default to blank data to trick shoutcast into clearing any previous # Default to blank data to trick shoutcast into clearing any previous
# values it might have. # values it might have.
taglist[gst.TAG_ARTIST] = ' ' # TODO: Verify if this works at all, likely it doesn't.
taglist[gst.TAG_TITLE] = ' ' set_value(Gst.TAG_ARTIST, ' ')
taglist[gst.TAG_ALBUM] = ' ' set_value(Gst.TAG_TITLE, ' ')
set_value(Gst.TAG_ALBUM, ' ')
if artists: if artists:
taglist[gst.TAG_ARTIST] = ', '.join([a.name for a in artists]) set_value(Gst.TAG_ARTIST, ', '.join([a.name for a in artists]))
if track.name: if track.name:
taglist[gst.TAG_TITLE] = track.name set_value(Gst.TAG_TITLE, track.name)
if track.album and track.album.name: if track.album and track.album.name:
taglist[gst.TAG_ALBUM] = track.album.name set_value(Gst.TAG_ALBUM, track.album.name)
event = gst.event_new_tag(taglist) gst_logger.debug(
'Sending TAG event for track %r: %r',
track.uri, taglist.to_string())
event = Gst.Event.new_tag(taglist)
# TODO: check if we get this back on our own bus? # TODO: check if we get this back on our own bus?
self._playbin.send_event(event) self._playbin.send_event(event)
gst_logger.debug('Sent tag event: track=%s', track.uri)
def get_current_tags(self): def get_current_tags(self):
""" """

View File

@ -1,63 +0,0 @@
from __future__ import absolute_import, unicode_literals
import gobject
import pygst
pygst.require('0.10')
import gst # noqa
class IcySrc(gst.Bin, gst.URIHandler):
__gstdetails__ = ('IcySrc',
'Src',
'HTTP src wrapper for icy:// support.',
'Mopidy')
srcpad_template = gst.PadTemplate(
'src', gst.PAD_SRC, gst.PAD_ALWAYS,
gst.caps_new_any())
__gsttemplates__ = (srcpad_template,)
def __init__(self):
super(IcySrc, self).__init__()
self._httpsrc = gst.element_make_from_uri(gst.URI_SRC, 'http://')
try:
self._httpsrc.set_property('iradio-mode', True)
except TypeError:
pass
self.add(self._httpsrc)
self._srcpad = gst.GhostPad('src', self._httpsrc.get_pad('src'))
self.add_pad(self._srcpad)
@classmethod
def do_get_type_full(cls):
return gst.URI_SRC
@classmethod
def do_get_protocols_full(cls):
return [b'icy', b'icyx']
def do_set_uri(self, uri):
if uri.startswith('icy://'):
return self._httpsrc.set_uri(b'http://' + uri[len('icy://'):])
elif uri.startswith('icyx://'):
return self._httpsrc.set_uri(b'https://' + uri[len('icyx://'):])
else:
return False
def do_get_uri(self):
uri = self._httpsrc.get_uri()
if uri.startswith('http://'):
return b'icy://' + uri[len('http://'):]
else:
return b'icyx://' + uri[len('https://'):]
def register():
# Only register icy if gst install can't handle it on it's own.
if not gst.element_make_from_uri(gst.URI_SRC, 'icy://'):
gobject.type_register(IcySrc)
gst.element_register(
IcySrc, IcySrc.__name__.lower(), gst.RANK_MARGINAL)

View File

@ -18,7 +18,7 @@ class AudioListener(listener.Listener):
@staticmethod @staticmethod
def send(event, **kwargs): def send(event, **kwargs):
"""Helper to allow calling of audio listener events""" """Helper to allow calling of audio listener events"""
listener.send_async(AudioListener, event, **kwargs) listener.send(AudioListener, event, **kwargs)
def reached_end_of_stream(self): def reached_end_of_stream(self):
""" """

View File

@ -2,21 +2,27 @@ from __future__ import (
absolute_import, division, print_function, unicode_literals) absolute_import, division, print_function, unicode_literals)
import collections import collections
import time
import pygst
pygst.require('0.10')
import gst # noqa
import gst.pbutils # noqa
from mopidy import exceptions from mopidy import exceptions
from mopidy.audio import utils from mopidy.audio import tags as tags_lib, utils
from mopidy.internal import encoding from mopidy.internal import encoding
from mopidy.internal.gi import Gst, GstPbutils
# GST_ELEMENT_FACTORY_LIST:
_DECODER = 1 << 0
_AUDIO = 1 << 50
_DEMUXER = 1 << 5
_DEPAYLOADER = 1 << 8
_PARSER = 1 << 6
# GST_TYPE_AUTOPLUG_SELECT_RESULT:
_SELECT_TRY = 0
_SELECT_EXPOSE = 1
_Result = collections.namedtuple( _Result = collections.namedtuple(
'Result', ('uri', 'tags', 'duration', 'seekable', 'mime', 'playable')) 'Result', ('uri', 'tags', 'duration', 'seekable', 'mime', 'playable'))
_RAW_AUDIO = gst.Caps(b'audio/x-raw-int; audio/x-raw-float')
# TODO: replace with a scan(uri, timeout=1000, proxy_config=None)? # TODO: replace with a scan(uri, timeout=1000, proxy_config=None)?
class Scanner(object): class Scanner(object):
@ -51,7 +57,7 @@ class Scanner(object):
""" """
timeout = int(timeout or self._timeout_ms) timeout = int(timeout or self._timeout_ms)
tags, duration, seekable, mime = None, None, None, None tags, duration, seekable, mime = None, None, None, None
pipeline = _setup_pipeline(uri, self._proxy_config) pipeline, signals = _setup_pipeline(uri, self._proxy_config)
try: try:
_start_pipeline(pipeline) _start_pipeline(pipeline)
@ -59,7 +65,8 @@ class Scanner(object):
duration = _query_duration(pipeline) duration = _query_duration(pipeline)
seekable = _query_seekable(pipeline) seekable = _query_seekable(pipeline)
finally: finally:
pipeline.set_state(gst.STATE_NULL) signals.clear()
pipeline.set_state(Gst.State.NULL)
del pipeline del pipeline
return _Result(uri, tags, duration, seekable, mime, have_audio) return _Result(uri, tags, duration, seekable, mime, have_audio)
@ -68,117 +75,149 @@ class Scanner(object):
# Turns out it's _much_ faster to just create a new pipeline for every as # Turns out it's _much_ faster to just create a new pipeline for every as
# decodebins and other elements don't seem to take well to being reused. # decodebins and other elements don't seem to take well to being reused.
def _setup_pipeline(uri, proxy_config=None): def _setup_pipeline(uri, proxy_config=None):
src = gst.element_make_from_uri(gst.URI_SRC, uri) src = Gst.Element.make_from_uri(Gst.URIType.SRC, uri)
if not src: if not src:
raise exceptions.ScannerError('GStreamer can not open: %s' % uri) raise exceptions.ScannerError('GStreamer can not open: %s' % uri)
typefind = gst.element_factory_make('typefind') typefind = Gst.ElementFactory.make('typefind')
decodebin = gst.element_factory_make('decodebin2') decodebin = Gst.ElementFactory.make('decodebin')
pipeline = gst.element_factory_make('pipeline') pipeline = Gst.ElementFactory.make('pipeline')
for e in (src, typefind, decodebin): for e in (src, typefind, decodebin):
pipeline.add(e) pipeline.add(e)
gst.element_link_many(src, typefind, decodebin) src.link(typefind)
typefind.link(decodebin)
if proxy_config: if proxy_config:
utils.setup_proxy(src, proxy_config) utils.setup_proxy(src, proxy_config)
typefind.connect('have-type', _have_type, decodebin) signals = utils.Signals()
decodebin.connect('pad-added', _pad_added, pipeline) signals.connect(typefind, 'have-type', _have_type, decodebin)
signals.connect(decodebin, 'pad-added', _pad_added, pipeline)
signals.connect(decodebin, 'autoplug-select', _autoplug_select)
return pipeline return pipeline, signals
def _have_type(element, probability, caps, decodebin): def _have_type(element, probability, caps, decodebin):
decodebin.set_property('sink-caps', caps) decodebin.set_property('sink-caps', caps)
struct = gst.Structure('have-type') struct = Gst.Structure.new_empty('have-type')
struct['caps'] = caps.get_structure(0) struct.set_value('caps', caps.get_structure(0))
element.get_bus().post(gst.message_new_application(element, struct)) element.get_bus().post(Gst.Message.new_application(element, struct))
def _pad_added(element, pad, pipeline): def _pad_added(element, pad, pipeline):
sink = gst.element_factory_make('fakesink') sink = Gst.ElementFactory.make('fakesink')
sink.set_property('sync', False) sink.set_property('sync', False)
pipeline.add(sink) pipeline.add(sink)
sink.sync_state_with_parent() sink.sync_state_with_parent()
pad.link(sink.get_pad('sink')) pad.link(sink.get_static_pad('sink'))
if pad.get_caps().is_subset(_RAW_AUDIO): if pad.query_caps().is_subset(Gst.Caps.from_string('audio/x-raw')):
struct = gst.Structure('have-audio') # Probably won't happen due to autoplug-select fix, but lets play it
element.get_bus().post(gst.message_new_application(element, struct)) # safe until we've tested more.
struct = Gst.Structure.new_empty('have-audio')
element.get_bus().post(Gst.Message.new_application(element, struct))
def _autoplug_select(element, pad, caps, factory):
if factory.list_is_type(_DECODER | _AUDIO):
struct = Gst.Structure.new_empty('have-audio')
element.get_bus().post(Gst.Message.new_application(element, struct))
if not factory.list_is_type(_DEMUXER | _DEPAYLOADER | _PARSER):
return _SELECT_EXPOSE
return _SELECT_TRY
def _start_pipeline(pipeline): def _start_pipeline(pipeline):
if pipeline.set_state(gst.STATE_PAUSED) == gst.STATE_CHANGE_NO_PREROLL: result = pipeline.set_state(Gst.State.PAUSED)
pipeline.set_state(gst.STATE_PLAYING) if result == Gst.StateChangeReturn.NO_PREROLL:
pipeline.set_state(Gst.State.PLAYING)
def _query_duration(pipeline): def _query_duration(pipeline, timeout=100):
try: # 1. Try and get a duration, return if success.
duration = pipeline.query_duration(gst.FORMAT_TIME, None)[0] # 2. Some formats need to play some buffers before duration is found.
except gst.QueryError: # 3. Wait for a duration change event.
# 4. Try and get a duration again.
success, duration = pipeline.query_duration(Gst.Format.TIME)
if success and duration >= 0:
return duration // Gst.MSECOND
result = pipeline.set_state(Gst.State.PLAYING)
if result == Gst.StateChangeReturn.FAILURE:
return None return None
if duration < 0: gst_timeout = timeout * Gst.MSECOND
return None bus = pipeline.get_bus()
else: bus.timed_pop_filtered(gst_timeout, Gst.MessageType.DURATION_CHANGED)
return duration // gst.MSECOND
success, duration = pipeline.query_duration(Gst.Format.TIME)
if success and duration >= 0:
return duration // Gst.MSECOND
return None
def _query_seekable(pipeline): def _query_seekable(pipeline):
query = gst.query_new_seeking(gst.FORMAT_TIME) query = Gst.Query.new_seeking(Gst.Format.TIME)
pipeline.query(query) pipeline.query(query)
return query.parse_seeking()[1] return query.parse_seeking()[1]
def _process(pipeline, timeout_ms): def _process(pipeline, timeout_ms):
clock = pipeline.get_clock()
bus = pipeline.get_bus() bus = pipeline.get_bus()
timeout = timeout_ms * gst.MSECOND
tags = {} tags = {}
mime = None mime = None
have_audio = False have_audio = False
missing_message = None missing_message = None
types = ( types = (
gst.MESSAGE_ELEMENT | gst.MESSAGE_APPLICATION | gst.MESSAGE_ERROR | Gst.MessageType.ELEMENT |
gst.MESSAGE_EOS | gst.MESSAGE_ASYNC_DONE | gst.MESSAGE_TAG) Gst.MessageType.APPLICATION |
Gst.MessageType.ERROR |
Gst.MessageType.EOS |
Gst.MessageType.ASYNC_DONE |
Gst.MessageType.TAG
)
previous = clock.get_time() timeout = timeout_ms
previous = int(time.time() * 1000)
while timeout > 0: while timeout > 0:
message = bus.timed_pop_filtered(timeout, types) message = bus.timed_pop_filtered(timeout * Gst.MSECOND, types)
if message is None: if message is None:
break break
elif message.type == gst.MESSAGE_ELEMENT: elif message.type == Gst.MessageType.ELEMENT:
if gst.pbutils.is_missing_plugin_message(message): if GstPbutils.is_missing_plugin_message(message):
missing_message = message missing_message = message
elif message.type == gst.MESSAGE_APPLICATION: elif message.type == Gst.MessageType.APPLICATION:
if message.structure.get_name() == 'have-type': if message.get_structure().get_name() == 'have-type':
mime = message.structure['caps'].get_name() mime = message.get_structure().get_value('caps').get_name()
if mime.startswith('text/') or mime == 'application/xml': if mime and (
mime.startswith('text/') or mime == 'application/xml'):
return tags, mime, have_audio return tags, mime, have_audio
elif message.structure.get_name() == 'have-audio': elif message.get_structure().get_name() == 'have-audio':
have_audio = True have_audio = True
elif message.type == gst.MESSAGE_ERROR: elif message.type == Gst.MessageType.ERROR:
error = encoding.locale_decode(message.parse_error()[0]) error = encoding.locale_decode(message.parse_error()[0])
if missing_message and not mime: if missing_message and not mime:
caps = missing_message.structure['detail'] caps = missing_message.get_structure().get_value('detail')
mime = caps.get_structure(0).get_name() mime = caps.get_structure(0).get_name()
return tags, mime, have_audio return tags, mime, have_audio
raise exceptions.ScannerError(error) raise exceptions.ScannerError(error)
elif message.type == gst.MESSAGE_EOS: elif message.type == Gst.MessageType.EOS:
return tags, mime, have_audio return tags, mime, have_audio
elif message.type == gst.MESSAGE_ASYNC_DONE: elif message.type == Gst.MessageType.ASYNC_DONE:
if message.src == pipeline: if message.src == pipeline:
return tags, mime, have_audio return tags, mime, have_audio
elif message.type == gst.MESSAGE_TAG: elif message.type == Gst.MessageType.TAG:
taglist = message.parse_tag() taglist = message.parse_tag()
# Note that this will only keep the last tag. # Note that this will only keep the last tag.
tags.update(utils.convert_taglist(taglist)) tags.update(tags_lib.convert_taglist(taglist))
now = clock.get_time() now = int(time.time() * 1000)
timeout -= now - previous timeout -= now - previous
previous = now previous = now
@ -189,15 +228,11 @@ if __name__ == '__main__':
import os import os
import sys import sys
import gobject
from mopidy.internal import path from mopidy.internal import path
gobject.threads_init()
scanner = Scanner(5000) scanner = Scanner(5000)
for uri in sys.argv[1:]: for uri in sys.argv[1:]:
if not gst.uri_is_valid(uri): if not Gst.uri_is_valid(uri):
uri = path.path_to_uri(os.path.abspath(uri)) uri = path.path_to_uri(os.path.abspath(uri))
try: try:
result = scanner.scan(uri) result = scanner.scan(uri)

140
mopidy/audio/tags.py Normal file
View File

@ -0,0 +1,140 @@
from __future__ import absolute_import, unicode_literals
import collections
import datetime
import logging
import numbers
from mopidy import compat
from mopidy.internal import log
from mopidy.internal.gi import GLib, Gst
from mopidy.models import Album, Artist, Track
logger = logging.getLogger(__name__)
def convert_taglist(taglist):
"""Convert a :class:`Gst.TagList` to plain Python types.
Knows how to convert:
- Dates
- Buffers
- Numbers
- Strings
- Booleans
Unknown types will be ignored and trace logged. Tag keys are all strings
defined as part GStreamer under GstTagList_.
.. _GstTagList: https://developer.gnome.org/gstreamer/stable/\
gstreamer-GstTagList.html
:param taglist: A GStreamer taglist to be converted.
:type taglist: :class:`Gst.TagList`
:rtype: dictionary of tag keys with a list of values.
"""
result = collections.defaultdict(list)
for n in range(taglist.n_tags()):
tag = taglist.nth_tag_name(n)
for i in range(taglist.get_tag_size(tag)):
value = taglist.get_value_index(tag, i)
if isinstance(value, GLib.Date):
date = datetime.date(
value.get_year(), value.get_month(), value.get_day())
result[tag].append(date.isoformat().decode('utf-8'))
if isinstance(value, Gst.DateTime):
result[tag].append(value.to_iso8601_string().decode('utf-8'))
elif isinstance(value, bytes):
result[tag].append(value.decode('utf-8', 'replace'))
elif isinstance(value, (compat.text_type, bool, numbers.Number)):
result[tag].append(value)
else:
logger.log(
log.TRACE_LOG_LEVEL,
'Ignoring unknown tag data: %r = %r', tag, value)
# TODO: dict(result) to not leak the defaultdict, or just use setdefault?
return result
# TODO: split based on "stream" and "track" based conversion? i.e. handle data
# from radios in it's own helper instead?
def convert_tags_to_track(tags):
"""Convert our normalized tags to a track.
:param tags: dictionary of tag keys with a list of values
:type tags: :class:`dict`
:rtype: :class:`mopidy.models.Track`
"""
album_kwargs = {}
track_kwargs = {}
track_kwargs['composers'] = _artists(tags, Gst.TAG_COMPOSER)
track_kwargs['performers'] = _artists(tags, Gst.TAG_PERFORMER)
track_kwargs['artists'] = _artists(tags, Gst.TAG_ARTIST,
'musicbrainz-artistid',
'musicbrainz-sortname')
album_kwargs['artists'] = _artists(
tags, Gst.TAG_ALBUM_ARTIST, 'musicbrainz-albumartistid')
track_kwargs['genre'] = '; '.join(tags.get(Gst.TAG_GENRE, []))
track_kwargs['name'] = '; '.join(tags.get(Gst.TAG_TITLE, []))
if not track_kwargs['name']:
track_kwargs['name'] = '; '.join(tags.get(Gst.TAG_ORGANIZATION, []))
track_kwargs['comment'] = '; '.join(tags.get('comment', []))
if not track_kwargs['comment']:
track_kwargs['comment'] = '; '.join(tags.get(Gst.TAG_LOCATION, []))
if not track_kwargs['comment']:
track_kwargs['comment'] = '; '.join(tags.get(Gst.TAG_COPYRIGHT, []))
track_kwargs['track_no'] = tags.get(Gst.TAG_TRACK_NUMBER, [None])[0]
track_kwargs['disc_no'] = tags.get(Gst.TAG_ALBUM_VOLUME_NUMBER, [None])[0]
track_kwargs['bitrate'] = tags.get(Gst.TAG_BITRATE, [None])[0]
track_kwargs['musicbrainz_id'] = tags.get('musicbrainz-trackid', [None])[0]
album_kwargs['name'] = tags.get(Gst.TAG_ALBUM, [None])[0]
album_kwargs['num_tracks'] = tags.get(Gst.TAG_TRACK_COUNT, [None])[0]
album_kwargs['num_discs'] = tags.get(Gst.TAG_ALBUM_VOLUME_COUNT, [None])[0]
album_kwargs['musicbrainz_id'] = tags.get('musicbrainz-albumid', [None])[0]
album_kwargs['date'] = tags.get(Gst.TAG_DATE, [None])[0]
if not album_kwargs['date']:
datetime = tags.get(Gst.TAG_DATE_TIME, [None])[0]
if datetime is not None:
album_kwargs['date'] = datetime.split('T')[0]
# Clear out any empty values we found
track_kwargs = {k: v for k, v in track_kwargs.items() if v}
album_kwargs = {k: v for k, v in album_kwargs.items() if v}
# Only bother with album if we have a name to show.
if album_kwargs.get('name'):
track_kwargs['album'] = Album(**album_kwargs)
return Track(**track_kwargs)
def _artists(
tags, artist_name, artist_id=None, artist_sortname=None):
# Name missing, don't set artist
if not tags.get(artist_name):
return None
# One artist name and either id or sortname, include all available fields
if len(tags[artist_name]) == 1 and \
(artist_id in tags or artist_sortname in tags):
attrs = {'name': tags[artist_name][0]}
if artist_id in tags:
attrs['musicbrainz_id'] = tags[artist_id][0]
if artist_sortname in tags:
attrs['sortname'] = tags[artist_sortname][0]
return [Artist(**attrs)]
# Multiple artist, provide artists with name only to avoid ambiguity.
return [Artist(name=name) for name in tags[artist_name]]

View File

@ -1,50 +1,41 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
import datetime from mopidy import httpclient
import logging from mopidy.internal.gi import Gst
import numbers
import pygst
pygst.require('0.10')
import gst # noqa
from mopidy import compat, httpclient
from mopidy.models import Album, Artist, Track
logger = logging.getLogger(__name__)
def calculate_duration(num_samples, sample_rate): def calculate_duration(num_samples, sample_rate):
"""Determine duration of samples using GStreamer helper for precise """Determine duration of samples using GStreamer helper for precise
math.""" math."""
return gst.util_uint64_scale(num_samples, gst.SECOND, sample_rate) return Gst.util_uint64_scale(num_samples, Gst.SECOND, sample_rate)
def create_buffer(data, capabilites=None, timestamp=None, duration=None): def create_buffer(data, timestamp=None, duration=None):
"""Create a new GStreamer buffer based on provided data. """Create a new GStreamer buffer based on provided data.
Mainly intended to keep gst imports out of non-audio modules. Mainly intended to keep gst imports out of non-audio modules.
.. versionchanged:: 2.0
``capabilites`` argument was removed.
""" """
buffer_ = gst.Buffer(data) if not data:
if capabilites: raise ValueError('Cannot create buffer without data')
if isinstance(capabilites, compat.string_types): buffer_ = Gst.Buffer.new_wrapped(data)
capabilites = gst.caps_from_string(capabilites) if timestamp is not None:
buffer_.set_caps(capabilites) buffer_.pts = timestamp
if timestamp: if duration is not None:
buffer_.timestamp = timestamp
if duration:
buffer_.duration = duration buffer_.duration = duration
return buffer_ return buffer_
def millisecond_to_clocktime(value): def millisecond_to_clocktime(value):
"""Convert a millisecond time to internal GStreamer time.""" """Convert a millisecond time to internal GStreamer time."""
return value * gst.MSECOND return value * Gst.MSECOND
def clocktime_to_millisecond(value): def clocktime_to_millisecond(value):
"""Convert an internal GStreamer time to millisecond time.""" """Convert an internal GStreamer time to millisecond time."""
return value // gst.MSECOND return value // Gst.MSECOND
def supported_uri_schemes(uri_schemes): def supported_uri_schemes(uri_schemes):
@ -55,9 +46,9 @@ def supported_uri_schemes(uri_schemes):
:rtype: set of URI schemes we can support via this GStreamer install. :rtype: set of URI schemes we can support via this GStreamer install.
""" """
supported_schemes = set() supported_schemes = set()
registry = gst.registry_get_default() registry = Gst.Registry.get()
for factory in registry.get_feature_list(gst.TYPE_ELEMENT_FACTORY): for factory in registry.get_feature_list(Gst.ElementFactory):
for uri in factory.get_uri_protocols(): for uri in factory.get_uri_protocols():
if uri in uri_schemes: if uri in uri_schemes:
supported_schemes.add(uri) supported_schemes.add(uri)
@ -65,84 +56,11 @@ def supported_uri_schemes(uri_schemes):
return supported_schemes return supported_schemes
def _artists(tags, artist_name, artist_id=None, artist_sortname=None):
# Name missing, don't set artist
if not tags.get(artist_name):
return None
# One artist name and either id or sortname, include all available fields
if len(tags[artist_name]) == 1 and \
(artist_id in tags or artist_sortname in tags):
attrs = {'name': tags[artist_name][0]}
if artist_id in tags:
attrs['musicbrainz_id'] = tags[artist_id][0]
if artist_sortname in tags:
attrs['sortname'] = tags[artist_sortname][0]
return [Artist(**attrs)]
# Multiple artist, provide artists with name only to avoid ambiguity.
return [Artist(name=name) for name in tags[artist_name]]
# TODO: split based on "stream" and "track" based conversion? i.e. handle data
# from radios in it's own helper instead?
def convert_tags_to_track(tags):
"""Convert our normalized tags to a track.
:param tags: dictionary of tag keys with a list of values
:type tags: :class:`dict`
:rtype: :class:`mopidy.models.Track`
"""
album_kwargs = {}
track_kwargs = {}
track_kwargs['composers'] = _artists(tags, gst.TAG_COMPOSER)
track_kwargs['performers'] = _artists(tags, gst.TAG_PERFORMER)
track_kwargs['artists'] = _artists(tags, gst.TAG_ARTIST,
'musicbrainz-artistid',
'musicbrainz-sortname')
album_kwargs['artists'] = _artists(
tags, gst.TAG_ALBUM_ARTIST, 'musicbrainz-albumartistid')
track_kwargs['genre'] = '; '.join(tags.get(gst.TAG_GENRE, []))
track_kwargs['name'] = '; '.join(tags.get(gst.TAG_TITLE, []))
if not track_kwargs['name']:
track_kwargs['name'] = '; '.join(tags.get(gst.TAG_ORGANIZATION, []))
track_kwargs['comment'] = '; '.join(tags.get('comment', []))
if not track_kwargs['comment']:
track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_LOCATION, []))
if not track_kwargs['comment']:
track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_COPYRIGHT, []))
track_kwargs['track_no'] = tags.get(gst.TAG_TRACK_NUMBER, [None])[0]
track_kwargs['disc_no'] = tags.get(gst.TAG_ALBUM_VOLUME_NUMBER, [None])[0]
track_kwargs['bitrate'] = tags.get(gst.TAG_BITRATE, [None])[0]
track_kwargs['musicbrainz_id'] = tags.get('musicbrainz-trackid', [None])[0]
album_kwargs['name'] = tags.get(gst.TAG_ALBUM, [None])[0]
album_kwargs['num_tracks'] = tags.get(gst.TAG_TRACK_COUNT, [None])[0]
album_kwargs['num_discs'] = tags.get(gst.TAG_ALBUM_VOLUME_COUNT, [None])[0]
album_kwargs['musicbrainz_id'] = tags.get('musicbrainz-albumid', [None])[0]
if tags.get(gst.TAG_DATE) and tags.get(gst.TAG_DATE)[0]:
track_kwargs['date'] = tags[gst.TAG_DATE][0].isoformat()
# Clear out any empty values we found
track_kwargs = {k: v for k, v in track_kwargs.items() if v}
album_kwargs = {k: v for k, v in album_kwargs.items() if v}
# Only bother with album if we have a name to show.
if album_kwargs.get('name'):
track_kwargs['album'] = Album(**album_kwargs)
return Track(**track_kwargs)
def setup_proxy(element, config): def setup_proxy(element, config):
"""Configure a GStreamer element with proxy settings. """Configure a GStreamer element with proxy settings.
:param element: element to setup proxy in. :param element: element to setup proxy in.
:type element: :class:`gst.GstElement` :type element: :class:`Gst.GstElement`
:param config: proxy settings to use. :param config: proxy settings to use.
:type config: :class:`dict` :type config: :class:`dict`
""" """
@ -154,50 +72,31 @@ def setup_proxy(element, config):
element.set_property('proxy-pw', config.get('password')) element.set_property('proxy-pw', config.get('password'))
def convert_taglist(taglist): class Signals(object):
"""Convert a :class:`gst.Taglist` to plain Python types.
Knows how to convert: """Helper for tracking gobject signal registrations"""
- Dates def __init__(self):
- Buffers self._ids = {}
- Numbers
- Strings
- Booleans
Unknown types will be ignored and debug logged. Tag keys are all strings def connect(self, element, event, func, *args):
defined as part GStreamer under GstTagList_. """Connect a function + args to signal event on an element.
.. _GstTagList: http://gstreamer.freedesktop.org/data/doc/gstreamer/\ Each event may only be handled by one callback in this implementation.
0.10.36/gstreamer/html/gstreamer-GstTagList.html """
assert (element, event) not in self._ids
self._ids[(element, event)] = element.connect(event, func, *args)
:param taglist: A GStreamer taglist to be converted. def disconnect(self, element, event):
:type taglist: :class:`gst.Taglist` """Disconnect whatever handler we have for an element+event pair.
:rtype: dictionary of tag keys with a list of values.
"""
result = {}
# Taglists are not really dicts, hence the lack of .items() and Does nothing it the handler has already been removed.
# explicit use of .keys() """
for key in taglist.keys(): signal_id = self._ids.pop((element, event), None)
result.setdefault(key, []) if signal_id is not None:
element.disconnect(signal_id)
values = taglist[key] def clear(self):
if not isinstance(values, list): """Clear all registered signal handlers."""
values = [values] for element, event in self._ids.keys():
element.disconnect(self._ids.pop((element, event)))
for value in values:
if isinstance(value, gst.Date):
try:
date = datetime.date(value.year, value.month, value.day)
result[key].append(date)
except ValueError:
logger.debug('Ignoring invalid date: %r = %r', key, value)
elif isinstance(value, gst.Buffer):
result[key].append(bytes(value))
elif isinstance(value, (basestring, bool, numbers.Number)):
result[key].append(value)
else:
logger.debug('Ignoring unknown data: %r = %r', key, value)
return result

View File

@ -347,13 +347,14 @@ class PlaylistsProvider(object):
""" """
Create a new empty playlist with the given name. Create a new empty playlist with the given name.
Returns a new playlist with the given name and an URI. Returns a new playlist with the given name and an URI, or :class:`None`
on failure.
*MUST be implemented by subclass.* *MUST be implemented by subclass.*
:param name: name of the new playlist :param name: name of the new playlist
:type name: string :type name: string
:rtype: :class:`mopidy.models.Playlist` :rtype: :class:`mopidy.models.Playlist` or :class:`None`
""" """
raise NotImplementedError raise NotImplementedError
@ -426,7 +427,7 @@ class BackendListener(listener.Listener):
@staticmethod @staticmethod
def send(event, **kwargs): def send(event, **kwargs):
"""Helper to allow calling of backend listener events""" """Helper to allow calling of backend listener events"""
listener.send_async(BackendListener, event, **kwargs) listener.send(BackendListener, event, **kwargs)
def playlists_loaded(self): def playlists_loaded(self):
""" """

View File

@ -5,23 +5,21 @@ import collections
import contextlib import contextlib
import logging import logging
import os import os
import signal
import sys import sys
import glib
import gobject
import pykka import pykka
from mopidy import config as config_lib, exceptions from mopidy import config as config_lib, exceptions
from mopidy.audio import Audio from mopidy.audio import Audio
from mopidy.core import Core from mopidy.core import Core
from mopidy.internal import deps, process, timer, versioning from mopidy.internal import deps, process, timer, versioning
from mopidy.internal.gi import GLib
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_default_config = [] _default_config = []
for base in glib.get_system_config_dirs() + (glib.get_user_config_dir(),): for base in GLib.get_system_config_dirs() + [GLib.get_user_config_dir()]:
_default_config.append(os.path.join(base, b'mopidy', b'mopidy.conf')) _default_config.append(os.path.join(base, b'mopidy', b'mopidy.conf'))
DEFAULT_CONFIG = b':'.join(_default_config) DEFAULT_CONFIG = b':'.join(_default_config)
@ -286,7 +284,13 @@ class RootCommand(Command):
help='`section/key=value` values to override config options') help='`section/key=value` values to override config options')
def run(self, args, config): def run(self, args, config):
loop = gobject.MainLoop() def on_sigterm(loop):
logger.info('GLib mainloop got SIGTERM. Exiting...')
loop.quit()
loop = GLib.MainLoop()
GLib.unix_signal_add(
GLib.PRIORITY_DEFAULT, signal.SIGTERM, on_sigterm, loop)
mixer_class = self.get_mixer_class(config, args.registry['mixer']) mixer_class = self.get_mixer_class(config, args.registry['mixer'])
backend_classes = args.registry['backend'] backend_classes = args.registry['backend']
@ -303,6 +307,7 @@ class RootCommand(Command):
backends = self.start_backends(config, backend_classes, audio) backends = self.start_backends(config, backend_classes, audio)
core = self.start_core(config, mixer, backends, audio) core = self.start_core(config, mixer, backends, audio)
self.start_frontends(config, frontend_classes, core) self.start_frontends(config, frontend_classes, core)
logger.info('Starting GLib mainloop')
loop.run() loop.run()
except (exceptions.BackendError, except (exceptions.BackendError,
exceptions.FrontendError, exceptions.FrontendError,

View File

@ -1,3 +1,5 @@
from __future__ import absolute_import, unicode_literals
import sys import sys
PY2 = sys.version_info[0] == 2 PY2 = sys.version_info[0] == 2
@ -8,10 +10,31 @@ if PY2:
import Queue as queue # noqa import Queue as queue # noqa
import thread # noqa import thread # noqa
string_types = basestring def fake_python3_urllib_module():
text_type = unicode import types
import urllib as py2_urllib
import urlparse as py2_urlparse
input = raw_input urllib = types.ModuleType(b'urllib') # noqa
urllib.parse = types.ModuleType(b'urlib.parse')
urllib.parse.quote = py2_urllib.quote
urllib.parse.unquote = py2_urllib.unquote
urllib.parse.urlparse = py2_urlparse.urlparse
urllib.parse.urlsplit = py2_urlparse.urlsplit
urllib.parse.urlunsplit = py2_urlparse.urlunsplit
return urllib
urllib = fake_python3_urllib_module()
integer_types = (int, long) # noqa
string_types = basestring # noqa
text_type = unicode # noqa
input = raw_input # noqa
intern = intern # noqa
def itervalues(dct, **kwargs): def itervalues(dct, **kwargs):
return iter(dct.itervalues(**kwargs)) return iter(dct.itervalues(**kwargs))
@ -20,11 +43,14 @@ else:
import configparser # noqa import configparser # noqa
import queue # noqa import queue # noqa
import _thread as thread # noqa import _thread as thread # noqa
import urllib # noqa
integer_types = (int,)
string_types = (str,) string_types = (str,)
text_type = str text_type = str
input = input input = input
intern = sys.intern
def itervalues(dct, **kwargs): def itervalues(dct, **kwargs):
return iter(dct.values(**kwargs)) return iter(dct.values(**kwargs))

View File

@ -38,6 +38,7 @@ _audio_schema['mixer_track'] = Deprecated()
_audio_schema['mixer_volume'] = Integer(optional=True, minimum=0, maximum=100) _audio_schema['mixer_volume'] = Integer(optional=True, minimum=0, maximum=100)
_audio_schema['output'] = String() _audio_schema['output'] = String()
_audio_schema['visualizer'] = Deprecated() _audio_schema['visualizer'] = Deprecated()
_audio_schema['buffer_time'] = Integer(optional=True, minimum=1)
_proxy_schema = ConfigSchema('proxy') _proxy_schema = ConfigSchema('proxy')
_proxy_schema['scheme'] = String(optional=True, _proxy_schema['scheme'] = String(optional=True,

View File

@ -15,6 +15,7 @@ config_file =
mixer = software mixer = software
mixer_volume = mixer_volume =
output = autoaudiosink output = autoaudiosink
buffer_time =
[proxy] [proxy]
scheme = scheme =

View File

@ -54,7 +54,8 @@ class Core(
self.library = LibraryController(backends=self.backends, core=self) self.library = LibraryController(backends=self.backends, core=self)
self.history = HistoryController() self.history = HistoryController()
self.mixer = MixerController(mixer=mixer) self.mixer = MixerController(mixer=mixer)
self.playback = PlaybackController(backends=self.backends, core=self) self.playback = PlaybackController(
audio=audio, backends=self.backends, core=self)
self.playlists = PlaylistsController(backends=self.backends, core=self) self.playlists = PlaylistsController(backends=self.backends, core=self)
self.tracklist = TracklistController(core=self) self.tracklist = TracklistController(core=self)
@ -84,11 +85,14 @@ class Core(
""" """
def reached_end_of_stream(self): def reached_end_of_stream(self):
self.playback._on_end_of_track() self.playback._on_end_of_stream()
def stream_changed(self, uri): def stream_changed(self, uri):
self.playback._on_stream_changed(uri) self.playback._on_stream_changed(uri)
def position_changed(self, position):
self.playback._on_position_changed(position)
def state_changed(self, old_state, new_state, target_state): def state_changed(self, old_state, new_state, target_state):
# XXX: This is a temporary fix for issue #232 while we wait for a more # XXX: This is a temporary fix for issue #232 while we wait for a more
# permanent solution with the implementation of issue #234. When the # permanent solution with the implementation of issue #234. When the

View File

@ -4,9 +4,9 @@ import collections
import contextlib import contextlib
import logging import logging
import operator import operator
import urlparse
from mopidy import compat, exceptions, models from mopidy import compat, exceptions, models
from mopidy.compat import urllib
from mopidy.internal import deprecation, validation from mopidy.internal import deprecation, validation
@ -35,7 +35,7 @@ class LibraryController(object):
self.core = core self.core = core
def _get_backend(self, uri): def _get_backend(self, uri):
uri_scheme = urlparse.urlparse(uri).scheme uri_scheme = urllib.parse.urlparse(uri).scheme
return self.backends.with_library.get(uri_scheme, None) return self.backends.with_library.get(uri_scheme, None)
def _get_backends_to_uris(self, uris): def _get_backends_to_uris(self, uris):
@ -102,7 +102,7 @@ class LibraryController(object):
return sorted(directories, key=operator.attrgetter('name')) return sorted(directories, key=operator.attrgetter('name'))
def _browse(self, uri): def _browse(self, uri):
scheme = urlparse.urlparse(uri).scheme scheme = urllib.parse.urlparse(uri).scheme
backend = self.backends.with_library_browse.get(scheme) backend = self.backends.with_library_browse.get(scheme)
if not backend: if not backend:
@ -149,7 +149,7 @@ class LibraryController(object):
"""Lookup the images for the given URIs """Lookup the images for the given URIs
Backends can use this to return image URIs for any URI they know about Backends can use this to return image URIs for any URI they know about
be it tracks, albums, playlists... The lookup result is a dictionary be it tracks, albums, playlists. The lookup result is a dictionary
mapping the provided URIs to lists of images. mapping the provided URIs to lists of images.
Unknown URIs or URIs the corresponding backend couldn't find anything Unknown URIs or URIs the corresponding backend couldn't find anything
@ -255,7 +255,7 @@ class LibraryController(object):
futures = {} futures = {}
backends = {} backends = {}
uri_scheme = urlparse.urlparse(uri).scheme if uri else None uri_scheme = urllib.parse.urlparse(uri).scheme if uri else None
for backend_scheme, backend in self.backends.with_library.items(): for backend_scheme, backend in self.backends.with_library.items():
backends.setdefault(backend, set()).add(backend_scheme) backends.setdefault(backend, set()).add(backend_scheme)
@ -271,6 +271,9 @@ class LibraryController(object):
def search(self, query=None, uris=None, exact=False, **kwargs): def search(self, query=None, uris=None, exact=False, **kwargs):
""" """
Search the library for tracks where ``field`` contains ``values``. Search the library for tracks where ``field`` contains ``values``.
``field`` can be one of ``uri``, ``track_name``, ``album``, ``artist``,
``albumartist``, ``composer``, ``performer``, ``track_no``, ``genre``,
``date``, ``comment`` or ``any``.
If ``uris`` is given, the search is limited to results from within the If ``uris`` is given, the search is limited to results from within the
URI roots. For example passing ``uris=['file:']`` will limit the search URI roots. For example passing ``uris=['file:']`` will limit the search
@ -358,7 +361,7 @@ def _normalize_query(query):
broken_client = False broken_client = False
# TODO: this breaks if query is not a dictionary like object... # TODO: this breaks if query is not a dictionary like object...
for (field, values) in query.items(): for (field, values) in query.items():
if isinstance(values, basestring): if isinstance(values, compat.string_types):
broken_client = True broken_client = True
query[field] = [values] query[field] = [values]
if broken_client: if broken_client:

View File

@ -18,7 +18,7 @@ class CoreListener(listener.Listener):
@staticmethod @staticmethod
def send(event, **kwargs): def send(event, **kwargs):
"""Helper to allow calling of core listener events""" """Helper to allow calling of core listener events"""
listener.send_async(CoreListener, event, **kwargs) listener.send(CoreListener, event, **kwargs)
def on_event(self, event, **kwargs): def on_event(self, event, **kwargs):
""" """
@ -182,5 +182,8 @@ class CoreListener(listener.Listener):
Called whenever the currently playing stream title changes. Called whenever the currently playing stream title changes.
*MAY* be implemented by actor. *MAY* be implemented by actor.
:param title: the new stream title
:type title: string
""" """
pass pass

View File

@ -1,10 +1,10 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
import logging import logging
import urlparse
from mopidy import models from mopidy import models
from mopidy.audio import PlaybackState from mopidy.audio import PlaybackState
from mopidy.compat import urllib
from mopidy.core import listener from mopidy.core import listener
from mopidy.internal import deprecation, validation from mopidy.internal import deprecation, validation
@ -14,21 +14,30 @@ logger = logging.getLogger(__name__)
class PlaybackController(object): class PlaybackController(object):
pykka_traversable = True pykka_traversable = True
def __init__(self, backends, core): def __init__(self, audio, backends, core):
# TODO: these should be internal
self.backends = backends self.backends = backends
self.core = core self.core = core
self._audio = audio
self._current_tl_track = None
self._stream_title = None self._stream_title = None
self._state = PlaybackState.STOPPED self._state = PlaybackState.STOPPED
def _get_backend(self): self._current_tl_track = None
# TODO: take in track instead self._pending_tl_track = None
track = self.get_current_track()
if track is None: self._pending_position = None
self._last_position = None
self._previous = False
if self._audio:
self._audio.set_about_to_finish_callback(
self._on_about_to_finish_callback)
def _get_backend(self, tl_track):
if tl_track is None:
return None return None
uri = track.uri uri_scheme = urllib.parse.urlparse(tl_track.track.uri).scheme
uri_scheme = urlparse.urlparse(uri).scheme
return self.backends.with_playback.get(uri_scheme, None) return self.backends.with_playback.get(uri_scheme, None)
# Properties # Properties
@ -122,8 +131,11 @@ class PlaybackController(object):
def get_time_position(self): def get_time_position(self):
"""Get time position in milliseconds.""" """Get time position in milliseconds."""
backend = self._get_backend() if self._pending_position is not None:
return self._pending_position
backend = self._get_backend(self.get_current_tl_track())
if backend: if backend:
# TODO: Wrap backend call in error handling.
return backend.playback.get_time_position().get() return backend.playback.get_time_position().get()
else: else:
return 0 return 0
@ -190,62 +202,72 @@ class PlaybackController(object):
# Methods # Methods
# TODO: remove this. def _on_end_of_stream(self):
def _change_track(self, tl_track, on_error_step=1): self.set_state(PlaybackState.STOPPED)
""" if self._current_tl_track:
Change to the given track, keeping the current playback state. self._trigger_track_playback_ended(self.get_time_position())
self._set_current_tl_track(None)
:param tl_track: track to change to def _on_stream_changed(self, uri):
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` if self._last_position is None:
:param on_error_step: direction to step at play error, 1 for next position = self.get_time_position()
track (default), -1 for previous track. **INTERNAL** else:
:type on_error_step: int, -1 or 1 # This code path handles the stop() case, uri should be none.
""" position, self._last_position = self._last_position, None
old_state = self.get_state()
self.stop()
self._set_current_tl_track(tl_track)
if old_state == PlaybackState.PLAYING:
self._play(on_error_step=on_error_step)
elif old_state == PlaybackState.PAUSED:
# NOTE: this is just a quick hack to fix #1177, #1352, and #1378
# as this code has already been killed in the gapless branch.
backend = self._get_backend()
if backend:
backend.playback.prepare_change()
success = backend.playback.change_track(tl_track.track).get()
if success:
self.core.tracklist._mark_playing(tl_track)
self.core.history._add_track(tl_track.track)
else:
self.core.tracklist._mark_unplayable(tl_track)
if on_error_step == 1:
# TODO: can cause an endless loop for single track
# repeat.
self.next()
elif on_error_step == -1:
self.previous()
self.pause()
# TODO: this is not really end of track, this is on_need_next_track if self._pending_position is None:
def _on_end_of_track(self): self._trigger_track_playback_ended(position)
"""
Tell the playback controller that end of track is reached.
Used by event handler in :class:`mopidy.core.Core`. self._stream_title = None
if self._pending_tl_track:
self._set_current_tl_track(self._pending_tl_track)
self._pending_tl_track = None
if self._pending_position is None:
self.set_state(PlaybackState.PLAYING)
self._trigger_track_playback_started()
else:
self._seek(self._pending_position)
def _on_position_changed(self, position):
if self._pending_position == position:
self._trigger_seeked(position)
self._pending_position = None
def _on_about_to_finish_callback(self):
"""Callback that performs a blocking actor call to the real callback.
This is passed to audio, which is allowed to call this code from the
audio thread. We pass execution into the core actor to ensure that
there is no unsafe access of state in core. This must block until
we get a response.
""" """
if self.get_state() == PlaybackState.STOPPED: self.core.actor_ref.ask({
'command': 'pykka_call', 'args': tuple(), 'kwargs': {},
'attr_path': ('playback', '_on_about_to_finish'),
})
def _on_about_to_finish(self):
if self._state == PlaybackState.STOPPED:
return return
original_tl_track = self.get_current_tl_track() pending = self.core.tracklist.eot_track(self._current_tl_track)
next_tl_track = self.core.tracklist.eot_track(original_tl_track) while pending:
# TODO: Avoid infinite loops if all tracks are unplayable.
backend = self._get_backend(pending)
if not backend:
continue
if next_tl_track: try:
self._change_track(next_tl_track) if backend.playback.change_track(pending.track).get():
else: self._pending_tl_track = pending
self.stop() break
self._set_current_tl_track(None) except Exception:
logger.exception('%s backend caused an exception.',
backend.actor_ref.actor_class.__name__)
self.core.tracklist._mark_played(original_tl_track) self.core.tracklist._mark_unplayable(pending)
pending = self.core.tracklist.eot_track(pending)
def _on_tracklist_change(self): def _on_tracklist_change(self):
""" """
@ -253,13 +275,11 @@ class PlaybackController(object):
Used by :class:`mopidy.core.TracklistController`. Used by :class:`mopidy.core.TracklistController`.
""" """
tracklist = self.core.tracklist.get_tl_tracks() if not self.core.tracklist.tl_tracks:
if self.get_current_tl_track() not in tracklist:
self.stop() self.stop()
self._set_current_tl_track(None) self._set_current_tl_track(None)
elif self.get_current_tl_track() not in self.core.tracklist.tl_tracks:
def _on_stream_changed(self, uri): self._set_current_tl_track(None)
self._stream_title = None
def next(self): def next(self):
""" """
@ -268,23 +288,26 @@ class PlaybackController(object):
The current playback state will be kept. If it was playing, playing The current playback state will be kept. If it was playing, playing
will continue. If it was paused, it will still be paused, etc. will continue. If it was paused, it will still be paused, etc.
""" """
original_tl_track = self.get_current_tl_track() state = self.get_state()
next_tl_track = self.core.tracklist.next_track(original_tl_track) current = self._pending_tl_track or self._current_tl_track
if next_tl_track: while current:
# TODO: switch to: pending = self.core.tracklist.next_track(current)
# backend.play(track) if self._change(pending, state):
# wait for state change? break
self._change_track(next_tl_track) else:
else: self.core.tracklist._mark_unplayable(pending)
self.stop() # TODO: this could be needed to prevent a loop in rare cases
self._set_current_tl_track(None) # if current == pending:
# break
current = pending
self.core.tracklist._mark_played(original_tl_track) # TODO return result?
def pause(self): def pause(self):
"""Pause playback.""" """Pause playback."""
backend = self._get_backend() backend = self._get_backend(self.get_current_tl_track())
# TODO: Wrap backend call in error handling.
if not backend or backend.playback.pause().get(): if not backend or backend.playback.pause().get():
# TODO: switch to: # TODO: switch to:
# backend.track(pause) # backend.track(pause)
@ -308,14 +331,11 @@ class PlaybackController(object):
raise ValueError('At most one of "tl_track" and "tlid" may be set') raise ValueError('At most one of "tl_track" and "tlid" may be set')
tl_track is None or validation.check_instance(tl_track, models.TlTrack) tl_track is None or validation.check_instance(tl_track, models.TlTrack)
tlid is None or validation.check_integer(tlid, min=0) tlid is None or validation.check_integer(tlid, min=1)
if tl_track: if tl_track:
deprecation.warn('core.playback.play:tl_track_kwarg', pending=True) deprecation.warn('core.playback.play:tl_track_kwarg', pending=True)
self._play(tl_track=tl_track, tlid=tlid, on_error_step=1)
def _play(self, tl_track=None, tlid=None, on_error_step=1):
if tl_track is None and tlid is not None: if tl_track is None and tlid is not None:
for tl_track in self.core.tracklist.get_tl_tracks(): for tl_track in self.core.tracklist.get_tl_tracks():
if tl_track.tlid == tlid: if tl_track.tlid == tlid:
@ -323,60 +343,68 @@ class PlaybackController(object):
else: else:
tl_track = None tl_track = None
if tl_track is None: if tl_track is not None:
if self.get_state() == PlaybackState.PAUSED: # TODO: allow from outside tracklist, would make sense given refs?
return self.resume() assert tl_track in self.core.tracklist.get_tl_tracks()
elif tl_track is None and self.get_state() == PlaybackState.PAUSED:
self.resume()
return
if self.get_current_tl_track() is not None: current = self._pending_tl_track or self._current_tl_track
tl_track = self.get_current_tl_track() pending = tl_track or current or self.core.tracklist.next_track(None)
while pending:
if self._change(pending, PlaybackState.PLAYING):
break
else: else:
if on_error_step == 1: self.core.tracklist._mark_unplayable(pending)
tl_track = self.core.tracklist.next_track(tl_track) current = pending
elif on_error_step == -1: pending = self.core.tracklist.next_track(current)
tl_track = self.core.tracklist.previous_track(tl_track)
if tl_track is None: # TODO return result?
return
assert tl_track in self.core.tracklist.get_tl_tracks() def _change(self, pending_tl_track, state):
self._pending_tl_track = pending_tl_track
# TODO: switch to: if not pending_tl_track:
# backend.play(track)
# wait for state change?
if self.get_state() == PlaybackState.PLAYING:
self.stop() self.stop()
self._on_end_of_stream() # pretend an EOS happened for cleanup
return True
self._set_current_tl_track(tl_track) backend = self._get_backend(pending_tl_track)
self.set_state(PlaybackState.PLAYING) if not backend:
backend = self._get_backend() return False
success = False
if backend: # TODO: Wrap backend call in error handling.
backend.playback.prepare_change() backend.playback.prepare_change()
try:
if not backend.playback.change_track(pending_tl_track.track).get():
return False
except Exception:
logger.exception('%s backend caused an exception.',
backend.actor_ref.actor_class.__name__)
return False
# TODO: Wrap backend calls in error handling.
if state == PlaybackState.PLAYING:
try: try:
success = ( return backend.playback.play().get()
backend.playback.change_track(tl_track.track).get() and
backend.playback.play().get())
except TypeError: except TypeError:
logger.error( # TODO: check by binding against underlying play method using
'%s needs to be updated to work with this ' # inspect and otherwise re-raise?
'version of Mopidy.', logger.error('%s needs to be updated to work with this '
backend.actor_ref.actor_class.__name__) 'version of Mopidy.', backend)
logger.debug('Backend exception', exc_info=True) return False
elif state == PlaybackState.PAUSED:
return backend.playback.pause().get()
elif state == PlaybackState.STOPPED:
# TODO: emit some event now?
self._current_tl_track = self._pending_tl_track
self._pending_tl_track = None
return True
if success: raise Exception('Unknown state: %s' % state)
self.core.tracklist._mark_playing(tl_track)
self.core.history._add_track(tl_track.track)
# TODO: replace with stream-changed
self._trigger_track_playback_started()
else:
self.core.tracklist._mark_unplayable(tl_track)
if on_error_step == 1:
# TODO: can cause an endless loop for single track repeat.
self.next()
elif on_error_step == -1:
self.previous()
def previous(self): def previous(self):
""" """
@ -385,18 +413,29 @@ class PlaybackController(object):
The current playback state will be kept. If it was playing, playing The current playback state will be kept. If it was playing, playing
will continue. If it was paused, it will still be paused, etc. will continue. If it was paused, it will still be paused, etc.
""" """
tl_track = self.get_current_tl_track() self._previous = True
# TODO: switch to: state = self.get_state()
# self.play(....) current = self._pending_tl_track or self._current_tl_track
# wait for state change?
self._change_track( while current:
self.core.tracklist.previous_track(tl_track), on_error_step=-1) pending = self.core.tracklist.previous_track(current)
if self._change(pending, state):
break
else:
self.core.tracklist._mark_unplayable(pending)
# TODO: this could be needed to prevent a loop in rare cases
# if current == pending:
# break
current = pending
# TODO: no return value?
def resume(self): def resume(self):
"""If paused, resume playing the current track.""" """If paused, resume playing the current track."""
if self.get_state() != PlaybackState.PAUSED: if self.get_state() != PlaybackState.PAUSED:
return return
backend = self._get_backend() backend = self._get_backend(self.get_current_tl_track())
# TODO: Wrap backend call in error handling.
if backend and backend.playback.resume().get(): if backend and backend.playback.resume().get():
self.set_state(PlaybackState.PLAYING) self.set_state(PlaybackState.PLAYING)
# TODO: trigger via gst messages # TODO: trigger via gst messages
@ -413,6 +452,7 @@ class PlaybackController(object):
:type time_position: int :type time_position: int
:rtype: :class:`True` if successful, else :class:`False` :rtype: :class:`True` if successful, else :class:`False`
""" """
# TODO: seek needs to take pending tracks into account :(
validation.check_integer(time_position) validation.check_integer(time_position)
if time_position < 0: if time_position < 0:
@ -423,35 +463,47 @@ class PlaybackController(object):
if not self.core.tracklist.tracks: if not self.core.tracklist.tracks:
return False return False
if self.current_track and self.current_track.length is None:
return False
if self.get_state() == PlaybackState.STOPPED: if self.get_state() == PlaybackState.STOPPED:
self.play() self.play()
# We need to prefer the still playing track, but if nothing is playing
# we fall back to the pending one.
tl_track = self._current_tl_track or self._pending_tl_track
if tl_track and tl_track.track.length is None:
return False
if time_position < 0: if time_position < 0:
time_position = 0 time_position = 0
elif time_position > self.current_track.length: elif time_position > tl_track.track.length:
# TODO: GStreamer will trigger a about-to-finish for us, use that?
self.next() self.next()
return True return True
backend = self._get_backend() # Store our target position.
self._pending_position = time_position
# Make sure we switch back to previous track if we get a seek while we
# have a pending track.
if self._current_tl_track and self._pending_tl_track:
self._change(self._current_tl_track, self.get_state())
else:
return self._seek(time_position)
def _seek(self, time_position):
backend = self._get_backend(self.get_current_tl_track())
if not backend: if not backend:
return False return False
# TODO: Wrap backend call in error handling.
success = backend.playback.seek(time_position).get() return backend.playback.seek(time_position).get()
if success:
self._trigger_seeked(time_position)
return success
def stop(self): def stop(self):
"""Stop playing.""" """Stop playing."""
if self.get_state() != PlaybackState.STOPPED: if self.get_state() != PlaybackState.STOPPED:
backend = self._get_backend() self._last_position = self.get_time_position()
time_position_before_stop = self.get_time_position() backend = self._get_backend(self.get_current_tl_track())
# TODO: Wrap backend call in error handling.
if not backend or backend.playback.stop().get(): if not backend or backend.playback.stop().get():
self.set_state(PlaybackState.STOPPED) self.set_state(PlaybackState.STOPPED)
self._trigger_track_playback_ended(time_position_before_stop)
def _trigger_track_playback_paused(self): def _trigger_track_playback_paused(self):
logger.debug('Triggering track playback paused event') logger.debug('Triggering track playback paused event')
@ -472,20 +524,30 @@ class PlaybackController(object):
time_position=self.get_time_position()) time_position=self.get_time_position())
def _trigger_track_playback_started(self): def _trigger_track_playback_started(self):
logger.debug('Triggering track playback started event')
if self.get_current_tl_track() is None: if self.get_current_tl_track() is None:
return return
listener.CoreListener.send(
'track_playback_started', logger.debug('Triggering track playback started event')
tl_track=self.get_current_tl_track()) tl_track = self.get_current_tl_track()
self.core.tracklist._mark_playing(tl_track)
self.core.history._add_track(tl_track.track)
listener.CoreListener.send('track_playback_started', tl_track=tl_track)
def _trigger_track_playback_ended(self, time_position_before_stop): def _trigger_track_playback_ended(self, time_position_before_stop):
logger.debug('Triggering track playback ended event') tl_track = self.get_current_tl_track()
if self.get_current_tl_track() is None: if tl_track is None:
return return
logger.debug('Triggering track playback ended event')
if not self._previous:
self.core.tracklist._mark_played(self._current_tl_track)
self._previous = False
# TODO: Use the lowest of track duration and position.
listener.CoreListener.send( listener.CoreListener.send(
'track_playback_ended', 'track_playback_ended',
tl_track=self.get_current_tl_track(), tl_track=tl_track,
time_position=time_position_before_stop) time_position=time_position_before_stop)
def _trigger_playback_state_changed(self, old_state, new_state): def _trigger_playback_state_changed(self, old_state, new_state):
@ -495,5 +557,6 @@ class PlaybackController(object):
old_state=old_state, new_state=new_state) old_state=old_state, new_state=new_state)
def _trigger_seeked(self, time_position): def _trigger_seeked(self, time_position):
# TODO: Trigger this from audio events?
logger.debug('Triggering seeked event') logger.debug('Triggering seeked event')
listener.CoreListener.send('seeked', time_position=time_position) listener.CoreListener.send('seeked', time_position=time_position)

View File

@ -2,9 +2,9 @@ from __future__ import absolute_import, unicode_literals
import contextlib import contextlib
import logging import logging
import urlparse
from mopidy import exceptions from mopidy import exceptions
from mopidy.compat import urllib
from mopidy.core import listener from mopidy.core import listener
from mopidy.internal import deprecation, validation from mopidy.internal import deprecation, validation
from mopidy.models import Playlist, Ref from mopidy.models import Playlist, Ref
@ -33,6 +33,16 @@ class PlaylistsController(object):
self.backends = backends self.backends = backends
self.core = core self.core = core
def get_uri_schemes(self):
"""
Get the list of URI schemes that support playlists.
:rtype: list of string
.. versionadded:: 2.0
"""
return list(sorted(self.backends.with_playlists.keys()))
def as_list(self): def as_list(self):
""" """
Get a list of the currently available playlists. Get a list of the currently available playlists.
@ -81,7 +91,7 @@ class PlaylistsController(object):
""" """
validation.check_uri(uri) validation.check_uri(uri)
uri_scheme = urlparse.urlparse(uri).scheme uri_scheme = urllib.parse.urlparse(uri).scheme
backend = self.backends.with_playlists.get(uri_scheme, None) backend = self.backends.with_playlists.get(uri_scheme, None)
if not backend: if not backend:
@ -175,7 +185,7 @@ class PlaylistsController(object):
""" """
validation.check_uri(uri) validation.check_uri(uri)
uri_scheme = urlparse.urlparse(uri).scheme uri_scheme = urllib.parse.urlparse(uri).scheme
backend = self.backends.with_playlists.get(uri_scheme, None) backend = self.backends.with_playlists.get(uri_scheme, None)
if not backend: if not backend:
return None # TODO: error reporting to user return None # TODO: error reporting to user
@ -229,7 +239,7 @@ class PlaylistsController(object):
:type uri: string :type uri: string
:rtype: :class:`mopidy.models.Playlist` or :class:`None` :rtype: :class:`mopidy.models.Playlist` or :class:`None`
""" """
uri_scheme = urlparse.urlparse(uri).scheme uri_scheme = urllib.parse.urlparse(uri).scheme
backend = self.backends.with_playlists.get(uri_scheme, None) backend = self.backends.with_playlists.get(uri_scheme, None)
if not backend: if not backend:
return None return None
@ -303,7 +313,7 @@ class PlaylistsController(object):
if playlist.uri is None: if playlist.uri is None:
return # TODO: log this problem? return # TODO: log this problem?
uri_scheme = urlparse.urlparse(playlist.uri).scheme uri_scheme = urllib.parse.urlparse(playlist.uri).scheme
backend = self.backends.with_playlists.get(uri_scheme, None) backend = self.backends.with_playlists.get(uri_scheme, None)
if not backend: if not backend:
return None return None

View File

@ -16,7 +16,7 @@ class TracklistController(object):
def __init__(self, core): def __init__(self, core):
self.core = core self.core = core
self._next_tlid = 0 self._next_tlid = 1
self._tl_tracks = [] self._tl_tracks = []
self._version = 0 self._version = 0
@ -218,7 +218,7 @@ class TracklistController(object):
The *tlid* parameter The *tlid* parameter
""" """
tl_track is None or validation.check_instance(tl_track, TlTrack) tl_track is None or validation.check_instance(tl_track, TlTrack)
tlid is None or validation.check_integer(tlid, min=0) tlid is None or validation.check_integer(tlid, min=1)
if tl_track is None and tlid is None: if tl_track is None and tlid is None:
tl_track = self.core.playback.get_current_tl_track() tl_track = self.core.playback.get_current_tl_track()
@ -318,10 +318,11 @@ class TracklistController(object):
return self._shuffled[0] return self._shuffled[0]
return None return None
if tl_track is None: next_index = self.index(tl_track)
if next_index is None:
next_index = 0 next_index = 0
else: else:
next_index = self.index(tl_track) + 1 next_index += 1
if self.get_repeat(): if self.get_repeat():
next_index %= len(self._tl_tracks) next_index %= len(self._tl_tracks)
@ -620,12 +621,14 @@ class TracklistController(object):
def _mark_unplayable(self, tl_track): def _mark_unplayable(self, tl_track):
"""Internal method for :class:`mopidy.core.PlaybackController`.""" """Internal method for :class:`mopidy.core.PlaybackController`."""
logger.warning('Track is not playable: %s', tl_track.track.uri) logger.warning('Track is not playable: %s', tl_track.track.uri)
if self.get_consume() and tl_track is not None:
self.remove({'tlid': [tl_track.tlid]})
if self.get_random() and tl_track in self._shuffled: if self.get_random() and tl_track in self._shuffled:
self._shuffled.remove(tl_track) self._shuffled.remove(tl_track)
def _mark_played(self, tl_track): def _mark_played(self, tl_track):
"""Internal method for :class:`mopidy.core.PlaybackController`.""" """Internal method for :class:`mopidy.core.PlaybackController`."""
if self.consume and tl_track is not None: if self.get_consume() and tl_track is not None:
self.remove({'tlid': [tl_track.tlid]}) self.remove({'tlid': [tl_track.tlid]})
return True return True
return False return False

View File

@ -198,7 +198,12 @@ def load_extensions():
for entry_point in pkg_resources.iter_entry_points('mopidy.ext'): for entry_point in pkg_resources.iter_entry_points('mopidy.ext'):
logger.debug('Loading entry point: %s', entry_point) logger.debug('Loading entry point: %s', entry_point)
extension_class = entry_point.load(require=False) try:
extension_class = entry_point.load(require=False)
except Exception as e:
logger.exception("Failed to load extension %s: %s" % (
entry_point.name, e))
continue
try: try:
if not issubclass(extension_class, Extension): if not issubclass(extension_class, Extension):

View File

@ -7,7 +7,7 @@ import sys
import urllib2 import urllib2
from mopidy import backend, exceptions, models from mopidy import backend, exceptions, models
from mopidy.audio import scan, utils from mopidy.audio import scan, tags
from mopidy.internal import path from mopidy.internal import path
@ -83,7 +83,7 @@ class FileLibraryProvider(backend.LibraryProvider):
try: try:
result = self._scanner.scan(uri) result = self._scanner.scan(uri)
track = utils.convert_tags_to_track(result.tags).copy( track = tags.convert_tags_to_track(result.tags).copy(
uri=uri, length=result.duration) uri=uri, length=result.duration)
except exceptions.ScannerError as e: except exceptions.ScannerError as e:
logger.warning('Failed looking up %s: %s', uri, e) logger.warning('Failed looking up %s: %s', uri, e)

View File

@ -57,10 +57,12 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener):
if self.zeroconf_name: if self.zeroconf_name:
self.zeroconf_http = zeroconf.Zeroconf( self.zeroconf_http = zeroconf.Zeroconf(
stype='_http._tcp', name=self.zeroconf_name, name=self.zeroconf_name,
stype='_http._tcp',
port=self.port) port=self.port)
self.zeroconf_mopidy_http = zeroconf.Zeroconf( self.zeroconf_mopidy_http = zeroconf.Zeroconf(
stype='_mopidy-http._tcp', name=self.zeroconf_name, name=self.zeroconf_name,
stype='_mopidy-http._tcp',
port=self.port) port=self.port)
self.zeroconf_http.publish() self.zeroconf_http.publish()
self.zeroconf_mopidy_http.publish() self.zeroconf_mopidy_http.publish()

View File

@ -4,6 +4,9 @@ import contextlib
import re import re
import warnings import warnings
from mopidy import compat
# Messages used in deprecation warnings are collected here so we can target # Messages used in deprecation warnings are collected here so we can target
# them easily when ignoring warnings. # them easily when ignoring warnings.
_MESSAGES = { _MESSAGES = {
@ -74,7 +77,7 @@ def warn(msg_id, pending=False):
@contextlib.contextmanager @contextlib.contextmanager
def ignore(ids=None): def ignore(ids=None):
with warnings.catch_warnings(): with warnings.catch_warnings():
if isinstance(ids, basestring): if isinstance(ids, compat.string_types):
ids = [ids] ids = [ids]
if ids: if ids:

View File

@ -7,11 +7,8 @@ import sys
import pkg_resources import pkg_resources
import pygst
pygst.require('0.10')
import gst # noqa
from mopidy.internal import formatting from mopidy.internal import formatting
from mopidy.internal.gi import Gst, gi
def format_dependency_list(adapters=None): def format_dependency_list(adapters=None):
@ -110,8 +107,7 @@ def pkg_info(project_name=None, include_extras=False):
def gstreamer_info(): def gstreamer_info():
other = [] other = []
other.append('Python wrapper: gst-python %s' % ( other.append('Python wrapper: python-gi %s' % gi.__version__)
'.'.join(map(str, gst.get_pygst_version()))))
found_elements = [] found_elements = []
missing_elements = [] missing_elements = []
@ -135,8 +131,8 @@ def gstreamer_info():
return { return {
'name': 'GStreamer', 'name': 'GStreamer',
'version': '.'.join(map(str, gst.get_gst_version())), 'version': '.'.join(map(str, Gst.version())),
'path': os.path.dirname(gst.__file__), 'path': os.path.dirname(gi.__file__),
'other': '\n'.join(other), 'other': '\n'.join(other),
} }
@ -165,10 +161,10 @@ def _gstreamer_check_elements():
'flump3dec', 'flump3dec',
'id3demux', 'id3demux',
'id3v2mux', 'id3v2mux',
'lame', 'lamemp3enc',
'mad', 'mad',
'mp3parse', 'mpegaudioparse',
# 'mpg123audiodec', # Only available in GStreamer 1.x 'mpg123audiodec',
# Ogg Vorbis encoding and decoding # Ogg Vorbis encoding and decoding
'vorbisdec', 'vorbisdec',
@ -187,6 +183,6 @@ def _gstreamer_check_elements():
] ]
known_elements = [ known_elements = [
factory.get_name() for factory in factory.get_name() for factory in
gst.registry_get_default().get_feature_list(gst.TYPE_ELEMENT_FACTORY)] Gst.Registry.get().get_feature_list(Gst.ElementFactory)]
return [ return [
(element, element in known_elements) for element in elements_to_check] (element, element in known_elements) for element in elements_to_check]

43
mopidy/internal/gi.py Normal file
View File

@ -0,0 +1,43 @@
from __future__ import absolute_import, print_function, unicode_literals
import sys
import textwrap
try:
import gi
gi.require_version('Gst', '1.0')
from gi.repository import GLib, GObject, Gst
except ImportError:
print(textwrap.dedent("""
ERROR: A GObject Python package was not found.
Mopidy requires GStreamer to work. GStreamer is a C library with a
number of dependencies itself, and cannot be installed with the regular
Python tools like pip.
Please see http://docs.mopidy.com/en/latest/installation/ for
instructions on how to install the required dependencies.
"""))
raise
else:
Gst.init([])
gi.require_version('GstPbutils', '1.0')
from gi.repository import GstPbutils
REQUIRED_GST_VERSION = (1, 2, 3)
if Gst.version() < REQUIRED_GST_VERSION:
sys.exit(
'ERROR: Mopidy requires GStreamer >= %s, but found %s.' % (
'.'.join(map(str, REQUIRED_GST_VERSION)), Gst.version_string()))
__all__ = [
'GLib',
'GObject',
'Gst',
'GstPbutils',
'gi',
]

View File

@ -7,11 +7,10 @@ import socket
import sys import sys
import threading import threading
import gobject
import pykka import pykka
from mopidy.internal import encoding from mopidy.internal import encoding
from mopidy.internal.gi import GObject
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -67,7 +66,7 @@ def format_hostname(hostname):
class Server(object): class Server(object):
"""Setup listener and register it with gobject's event loop.""" """Setup listener and register it with GObject's event loop."""
def __init__(self, host, port, protocol, protocol_kwargs=None, def __init__(self, host, port, protocol, protocol_kwargs=None,
max_connections=5, timeout=30): max_connections=5, timeout=30):
@ -87,7 +86,7 @@ class Server(object):
return sock return sock
def register_server_socket(self, fileno): def register_server_socket(self, fileno):
gobject.io_add_watch(fileno, gobject.IO_IN, self.handle_connection) GObject.io_add_watch(fileno, GObject.IO_IN, self.handle_connection)
def handle_connection(self, fd, flags): def handle_connection(self, fd, flags):
try: try:
@ -132,7 +131,7 @@ class Server(object):
class Connection(object): class Connection(object):
# NOTE: the callback code is _not_ run in the actor's thread, but in the # NOTE: the callback code is _not_ run in the actor's thread, but in the
# same one as the event loop. If code in the callbacks blocks, the rest of # same one as the event loop. If code in the callbacks blocks, the rest of
# gobject code will likely be blocked as well... # GObject code will likely be blocked as well...
# #
# Also note that source_remove() return values are ignored on purpose, a # Also note that source_remove() return values are ignored on purpose, a
# false return value would only tell us that what we thought was registered # false return value would only tell us that what we thought was registered
@ -211,14 +210,14 @@ class Connection(object):
return return
self.disable_timeout() self.disable_timeout()
self.timeout_id = gobject.timeout_add_seconds( self.timeout_id = GObject.timeout_add_seconds(
self.timeout, self.timeout_callback) self.timeout, self.timeout_callback)
def disable_timeout(self): def disable_timeout(self):
"""Deactivate timeout mechanism.""" """Deactivate timeout mechanism."""
if self.timeout_id is None: if self.timeout_id is None:
return return
gobject.source_remove(self.timeout_id) GObject.source_remove(self.timeout_id)
self.timeout_id = None self.timeout_id = None
def enable_recv(self): def enable_recv(self):
@ -226,9 +225,9 @@ class Connection(object):
return return
try: try:
self.recv_id = gobject.io_add_watch( self.recv_id = GObject.io_add_watch(
self.sock.fileno(), self.sock.fileno(),
gobject.IO_IN | gobject.IO_ERR | gobject.IO_HUP, GObject.IO_IN | GObject.IO_ERR | GObject.IO_HUP,
self.recv_callback) self.recv_callback)
except socket.error as e: except socket.error as e:
self.stop('Problem with connection: %s' % e) self.stop('Problem with connection: %s' % e)
@ -236,7 +235,7 @@ class Connection(object):
def disable_recv(self): def disable_recv(self):
if self.recv_id is None: if self.recv_id is None:
return return
gobject.source_remove(self.recv_id) GObject.source_remove(self.recv_id)
self.recv_id = None self.recv_id = None
def enable_send(self): def enable_send(self):
@ -244,9 +243,9 @@ class Connection(object):
return return
try: try:
self.send_id = gobject.io_add_watch( self.send_id = GObject.io_add_watch(
self.sock.fileno(), self.sock.fileno(),
gobject.IO_OUT | gobject.IO_ERR | gobject.IO_HUP, GObject.IO_OUT | GObject.IO_ERR | GObject.IO_HUP,
self.send_callback) self.send_callback)
except socket.error as e: except socket.error as e:
self.stop('Problem with connection: %s' % e) self.stop('Problem with connection: %s' % e)
@ -255,11 +254,11 @@ class Connection(object):
if self.send_id is None: if self.send_id is None:
return return
gobject.source_remove(self.send_id) GObject.source_remove(self.send_id)
self.send_id = None self.send_id = None
def recv_callback(self, fd, flags): def recv_callback(self, fd, flags):
if flags & (gobject.IO_ERR | gobject.IO_HUP): if flags & (GObject.IO_ERR | GObject.IO_HUP):
self.stop('Bad client flags: %s' % flags) self.stop('Bad client flags: %s' % flags)
return True return True
@ -283,7 +282,7 @@ class Connection(object):
return True return True
def send_callback(self, fd, flags): def send_callback(self, fd, flags):
if flags & (gobject.IO_ERR | gobject.IO_HUP): if flags & (GObject.IO_ERR | GObject.IO_HUP):
self.stop('Bad client flags: %s' % flags) self.stop('Bad client flags: %s' % flags)
return True return True

View File

@ -5,11 +5,9 @@ import os
import stat import stat
import string import string
import threading import threading
import urllib
import urlparse
from mopidy import compat, exceptions from mopidy import compat, exceptions
from mopidy.compat import queue from mopidy.compat import queue, urllib
from mopidy.internal import encoding, xdg from mopidy.internal import encoding, xdg
@ -61,8 +59,8 @@ def path_to_uri(path):
""" """
if isinstance(path, compat.text_type): if isinstance(path, compat.text_type):
path = path.encode('utf-8') path = path.encode('utf-8')
path = urllib.quote(path) path = urllib.parse.quote(path)
return urlparse.urlunsplit((b'file', b'', path, b'', b'')) return urllib.parse.urlunsplit((b'file', b'', path, b'', b''))
def uri_to_path(uri): def uri_to_path(uri):
@ -78,7 +76,7 @@ def uri_to_path(uri):
""" """
if isinstance(uri, compat.text_type): if isinstance(uri, compat.text_type):
uri = uri.encode('utf-8') uri = uri.encode('utf-8')
return urllib.unquote(urlparse.urlsplit(uri).path) return urllib.parse.unquote(urllib.parse.urlsplit(uri).path)
def split_path(path): def split_path(path):

View File

@ -2,10 +2,6 @@ from __future__ import absolute_import, unicode_literals
import io import io
import pygst
pygst.require('0.10')
import gst # noqa
from mopidy.compat import configparser from mopidy.compat import configparser
from mopidy.internal import validation from mopidy.internal import validation

View File

@ -1,11 +1,9 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
import logging import logging
import signal
import threading import threading
from pykka import ActorDeadError import pykka
from pykka.registry import ActorRegistry
from mopidy.compat import thread from mopidy.compat import thread
@ -13,32 +11,35 @@ from mopidy.compat import thread
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SIGNALS = dict(
(k, v) for v, k in signal.__dict__.items()
if v.startswith('SIG') and not v.startswith('SIG_'))
def exit_process(): def exit_process():
logger.debug('Interrupting main...') logger.debug('Interrupting main...')
thread.interrupt_main() thread.interrupt_main()
logger.debug('Interrupted main') logger.debug('Interrupted main')
def exit_handler(signum, frame): def sigterm_handler(signum, frame):
"""A :mod:`signal` handler which will exit the program on signal.""" """A :mod:`signal` handler which will exit the program on signal.
logger.info('Got %s signal', SIGNALS[signum])
This function is not called when the process' main thread is running a GLib
mainloop. In that case, the GLib mainloop must listen for SIGTERM signals
and quit itself.
For Mopidy subcommands that does not run the GLib mainloop, this handler
ensures a proper shutdown of the process on SIGTERM.
"""
logger.info('Got SIGTERM signal. Exiting...')
exit_process() exit_process()
def stop_actors_by_class(klass): def stop_actors_by_class(klass):
actors = ActorRegistry.get_by_class(klass) actors = pykka.ActorRegistry.get_by_class(klass)
logger.debug('Stopping %d instance(s) of %s', len(actors), klass.__name__) logger.debug('Stopping %d instance(s) of %s', len(actors), klass.__name__)
for actor in actors: for actor in actors:
actor.stop() actor.stop()
def stop_remaining_actors(): def stop_remaining_actors():
num_actors = len(ActorRegistry.get_all()) num_actors = len(pykka.ActorRegistry.get_all())
while num_actors: while num_actors:
logger.error( logger.error(
'There are actor threads still running, this is probably a bug') 'There are actor threads still running, this is probably a bug')
@ -47,31 +48,6 @@ def stop_remaining_actors():
num_actors, threading.active_count() - num_actors, num_actors, threading.active_count() - num_actors,
', '.join([t.name for t in threading.enumerate()])) ', '.join([t.name for t in threading.enumerate()]))
logger.debug('Stopping %d actor(s)...', num_actors) logger.debug('Stopping %d actor(s)...', num_actors)
ActorRegistry.stop_all() pykka.ActorRegistry.stop_all()
num_actors = len(ActorRegistry.get_all()) num_actors = len(pykka.ActorRegistry.get_all())
logger.debug('All actors stopped.') logger.debug('All actors stopped.')
class BaseThread(threading.Thread):
def __init__(self):
super(BaseThread, self).__init__()
# No thread should block process from exiting
self.daemon = True
def run(self):
logger.debug('%s: Starting thread', self.name)
try:
self.run_inside_try()
except KeyboardInterrupt:
logger.info('Interrupted by user')
except ImportError as e:
logger.error(e)
except ActorDeadError as e:
logger.warning(e)
except Exception as e:
logger.exception(e)
logger.debug('%s: Exiting thread', self.name)
def run_inside_try(self):
raise NotImplementedError

View File

@ -4,13 +4,14 @@ import contextlib
import logging import logging
import time import time
from mopidy.internal import log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TRACE = logging.getLevelName('TRACE')
@contextlib.contextmanager @contextlib.contextmanager
def time_logger(name, level=TRACE): def time_logger(name, level=log.TRACE_LOG_LEVEL):
start = time.time() start = time.time()
yield yield
logger.log(level, '%s took %dms', name, (time.time() - start) * 1000) logger.log(level, '%s took %dms', name, (time.time() - start) * 1000)

View File

@ -1,9 +1,9 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
import collections import collections
import urlparse
from mopidy import compat, exceptions from mopidy import compat, exceptions
from mopidy.compat import urllib
PLAYBACK_STATES = {'paused', 'stopped', 'playing'} PLAYBACK_STATES = {'paused', 'stopped', 'playing'}
@ -56,7 +56,7 @@ def check_instances(arg, cls, msg='Expected a list of {name}, not {arg!r}'):
def check_integer(arg, min=None, max=None): def check_integer(arg, min=None, max=None):
if not isinstance(arg, (int, long)): if not isinstance(arg, compat.integer_types):
raise exceptions.ValidationError('Expected an integer, not %r' % arg) raise exceptions.ValidationError('Expected an integer, not %r' % arg)
elif min is not None and arg < min: elif min is not None and arg < min:
raise exceptions.ValidationError( raise exceptions.ValidationError(
@ -96,7 +96,7 @@ def _check_query_value(key, arg, msg):
def check_uri(arg, msg='Expected a valid URI, not {arg!r}'): def check_uri(arg, msg='Expected a valid URI, not {arg!r}'):
if not isinstance(arg, compat.string_types): if not isinstance(arg, compat.string_types):
raise exceptions.ValidationError(msg.format(arg=arg)) raise exceptions.ValidationError(msg.format(arg=arg))
elif urlparse.urlparse(arg).scheme == '': elif urllib.parse.urlparse(arg).scheme == '':
raise exceptions.ValidationError(msg.format(arg=arg)) raise exceptions.ValidationError(msg.format(arg=arg))

View File

@ -22,6 +22,6 @@ def get_git_version():
if process.wait() != 0: if process.wait() != 0:
raise EnvironmentError('Execution of "git describe" failed') raise EnvironmentError('Execution of "git describe" failed')
version = process.stdout.read().strip() version = process.stdout.read().strip()
if version.startswith('v'): if version.startswith(b'v'):
version = version[1:] version = version[1:]
return version return version

View File

@ -1,9 +1,10 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
import ConfigParser as configparser
import io import io
import os import os
from mopidy.compat import configparser
def get_dirs(): def get_dirs():
"""Returns a dict of all the known XDG Base Directories for the current user. """Returns a dict of all the known XDG Base Directories for the current user.
@ -46,21 +47,21 @@ def _get_user_dirs(xdg_config_dir):
disabled, and thus no :mod:`glib` available. disabled, and thus no :mod:`glib` available.
""" """
dirs_file = os.path.join(xdg_config_dir, 'user-dirs.dirs') dirs_file = os.path.join(xdg_config_dir, b'user-dirs.dirs')
if not os.path.exists(dirs_file): if not os.path.exists(dirs_file):
return {} return {}
with open(dirs_file, 'rb') as fh: with open(dirs_file, 'rb') as fh:
data = fh.read() data = fh.read().decode('utf-8')
data = b'[XDG_USER_DIRS]\n' + data data = '[XDG_USER_DIRS]\n' + data
data = data.replace(b'$HOME', os.path.expanduser(b'~')) data = data.replace('$HOME', os.path.expanduser('~'))
data = data.replace(b'"', b'') data = data.replace('"', '')
config = configparser.RawConfigParser() config = configparser.RawConfigParser()
config.readfp(io.BytesIO(data)) config.readfp(io.StringIO(data))
return { return {
k.decode('utf-8').upper(): os.path.abspath(v) k.upper(): os.path.abspath(v)
for k, v in config.items('XDG_USER_DIRS') if v is not None} for k, v in config.items('XDG_USER_DIRS') if v is not None}

View File

@ -7,16 +7,6 @@ import pykka
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def send_async(cls, event, **kwargs):
# This file is imported by mopidy.backends, which again is imported by all
# backend extensions. By importing modules that are not easily installable
# close to their use, we make some extensions able to run their tests in a
# virtualenv with global site-packages disabled.
import gobject
gobject.idle_add(lambda: send(cls, event, **kwargs))
def send(cls, event, **kwargs): def send(cls, event, **kwargs):
listeners = pykka.ActorRegistry.get_by_class(cls) listeners = pykka.ActorRegistry.get_by_class(cls)
logger.debug('Sending %s to %s: %s', event, cls.__name__, kwargs) logger.debug('Sending %s to %s: %s', event, cls.__name__, kwargs)
@ -55,4 +45,5 @@ class Listener(object):
getattr(self, event)(**kwargs) getattr(self, event)(**kwargs)
except Exception: except Exception:
# Ensure we don't crash the actor due to "bad" events. # Ensure we don't crash the actor due to "bad" events.
logger.exception('Triggering event failed: %s', event) logger.exception(
'Triggering event failed: %s(%s)', event, ', '.join(kwargs))

View File

@ -23,7 +23,7 @@ class Extension(ext.Extension):
schema = super(Extension, self).get_config_schema() schema = super(Extension, self).get_config_schema()
schema['library'] = config.String() schema['library'] = config.String()
schema['media_dir'] = config.Path() schema['media_dir'] = config.Path()
schema['data_dir'] = config.Path(optional=True) schema['data_dir'] = config.Deprecated()
schema['playlists_dir'] = config.Deprecated() schema['playlists_dir'] = config.Deprecated()
schema['tag_cache_file'] = config.Deprecated() schema['tag_cache_file'] = config.Deprecated()
schema['scan_timeout'] = config.Integer( schema['scan_timeout'] = config.Integer(

View File

@ -6,7 +6,7 @@ import os
import time import time
from mopidy import commands, compat, exceptions from mopidy import commands, compat, exceptions
from mopidy.audio import scan, utils from mopidy.audio import scan, tags
from mopidy.internal import path from mopidy.internal import path
from mopidy.local import translator from mopidy.local import translator
@ -140,18 +140,18 @@ class ScanCommand(commands.Command):
relpath = translator.local_track_uri_to_path(uri, media_dir) relpath = translator.local_track_uri_to_path(uri, media_dir)
file_uri = path.path_to_uri(os.path.join(media_dir, relpath)) file_uri = path.path_to_uri(os.path.join(media_dir, relpath))
result = scanner.scan(file_uri) result = scanner.scan(file_uri)
tags, duration = result.tags, result.duration
if not result.playable: if not result.playable:
logger.warning('Failed %s: No audio found in file.', uri) logger.warning('Failed %s: No audio found in file.', uri)
elif duration < MIN_DURATION_MS: elif result.duration < MIN_DURATION_MS:
logger.warning('Failed %s: Track shorter than %dms', logger.warning('Failed %s: Track shorter than %dms',
uri, MIN_DURATION_MS) uri, MIN_DURATION_MS)
else: else:
mtime = file_mtimes.get(os.path.join(media_dir, relpath)) mtime = file_mtimes.get(os.path.join(media_dir, relpath))
track = utils.convert_tags_to_track(tags).replace( track = tags.convert_tags_to_track(result.tags).replace(
uri=uri, length=duration, last_modified=mtime) uri=uri, length=result.duration, last_modified=mtime)
if library.add_supports_tags_and_duration: if library.add_supports_tags_and_duration:
library.add(track, tags=tags, duration=duration) library.add(
track, tags=result.tags, duration=result.duration)
else: else:
library.add(track) library.add(track)
logger.debug('Added %s', track.uri) logger.debug('Added %s', track.uri)

View File

@ -2,7 +2,6 @@
enabled = true enabled = true
library = json library = json
media_dir = $XDG_MUSIC_DIR media_dir = $XDG_MUSIC_DIR
data_dir = $XDG_DATA_DIR/mopidy/local
scan_timeout = 1000 scan_timeout = 1000
scan_flush_threshold = 100 scan_flush_threshold = 100
scan_follow_symlinks = false scan_follow_symlinks = false

View File

@ -42,11 +42,11 @@ def path_to_local_track_uri(relpath):
URI.""" URI."""
if isinstance(relpath, compat.text_type): if isinstance(relpath, compat.text_type):
relpath = relpath.encode('utf-8') relpath = relpath.encode('utf-8')
return b'local:track:%s' % urllib.quote(relpath) return 'local:track:%s' % urllib.quote(relpath)
def path_to_local_directory_uri(relpath): def path_to_local_directory_uri(relpath):
"""Convert path relative to :confval:`local/media_dir` directory URI.""" """Convert path relative to :confval:`local/media_dir` directory URI."""
if isinstance(relpath, compat.text_type): if isinstance(relpath, compat.text_type):
relpath = relpath.encode('utf-8') relpath = relpath.encode('utf-8')
return b'local:directory:%s' % urllib.quote(relpath) return 'local:directory:%s' % urllib.quote(relpath)

View File

@ -21,10 +21,12 @@ class Extension(ext.Extension):
def get_config_schema(self): def get_config_schema(self):
schema = super(Extension, self).get_config_schema() schema = super(Extension, self).get_config_schema()
schema['base_dir'] = config.Path(optional=True)
schema['default_encoding'] = config.String()
schema['default_extension'] = config.String(choices=['.m3u', '.m3u8'])
schema['playlists_dir'] = config.Path(optional=True) schema['playlists_dir'] = config.Path(optional=True)
return schema return schema
def setup(self, registry): def setup(self, registry):
from .actor import M3UBackend from .backend import M3UBackend
registry.add('backend', M3UBackend) registry.add('backend', M3UBackend)

View File

@ -1,36 +0,0 @@
from __future__ import absolute_import, unicode_literals
import logging
import pykka
from mopidy import backend, m3u
from mopidy.internal import encoding, path
from mopidy.m3u.library import M3ULibraryProvider
from mopidy.m3u.playlists import M3UPlaylistsProvider
logger = logging.getLogger(__name__)
class M3UBackend(pykka.ThreadingActor, backend.Backend):
uri_schemes = ['m3u']
def __init__(self, config, audio):
super(M3UBackend, self).__init__()
self._config = config
if config['m3u']['playlists_dir'] is not None:
self._playlists_dir = config['m3u']['playlists_dir']
try:
path.get_or_create_dir(self._playlists_dir)
except EnvironmentError as error:
logger.warning(
'Could not create M3U playlists dir: %s',
encoding.locale_decode(error))
else:
self._playlists_dir = m3u.Extension.get_data_dir(config)
self.playlists = M3UPlaylistsProvider(backend=self)
self.library = M3ULibraryProvider(backend=self)

15
mopidy/m3u/backend.py Normal file
View File

@ -0,0 +1,15 @@
from __future__ import absolute_import, unicode_literals
import pykka
from mopidy import backend
from . import playlists
class M3UBackend(pykka.ThreadingActor, backend.Backend):
uri_schemes = ['m3u']
def __init__(self, config, audio):
super(M3UBackend, self).__init__()
self.playlists = playlists.M3UPlaylistsProvider(self, config)

View File

@ -1,3 +1,6 @@
[m3u] [m3u]
enabled = true enabled = true
playlists_dir = playlists_dir =
base_dir = $XDG_MUSIC_DIR
default_encoding = latin-1
default_extension = .m3u8

View File

@ -1,19 +0,0 @@
from __future__ import absolute_import, unicode_literals
import logging
from mopidy import backend
logger = logging.getLogger(__name__)
class M3ULibraryProvider(backend.LibraryProvider):
"""Library for looking up M3U playlists."""
def __init__(self, backend):
super(M3ULibraryProvider, self).__init__(backend)
def lookup(self, uri):
# TODO Lookup tracks in M3U playlist
return []

View File

@ -1,117 +1,150 @@
from __future__ import absolute_import, division, unicode_literals from __future__ import absolute_import, unicode_literals
import glob import contextlib
import io
import locale
import logging import logging
import operator import operator
import os import os
import re import tempfile
import sys
from mopidy import backend from mopidy import backend
from mopidy.m3u import translator
from mopidy.models import Playlist, Ref
from . import Extension, translator
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def log_environment_error(message, error):
if isinstance(error.strerror, bytes):
strerror = error.strerror.decode(locale.getpreferredencoding())
else:
strerror = error.strerror
logger.error('%s: %s', message, strerror)
@contextlib.contextmanager
def replace(path, mode='w+b', encoding=None, errors=None):
try:
(fd, tempname) = tempfile.mkstemp(dir=os.path.dirname(path))
except TypeError:
# Python 3 requires dir to be of type str until v3.5
import sys
path = path.decode(sys.getfilesystemencoding())
(fd, tempname) = tempfile.mkstemp(dir=os.path.dirname(path))
try:
fp = io.open(fd, mode, encoding=encoding, errors=errors)
except:
os.remove(tempname)
os.close(fd)
raise
try:
yield fp
fp.flush()
os.fsync(fd)
os.rename(tempname, path)
except:
os.remove(tempname)
raise
finally:
fp.close()
class M3UPlaylistsProvider(backend.PlaylistsProvider): class M3UPlaylistsProvider(backend.PlaylistsProvider):
# TODO: currently this only handles UNIX file systems def __init__(self, backend, config):
_invalid_filename_chars = re.compile(r'[/]') super(M3UPlaylistsProvider, self).__init__(backend)
def __init__(self, *args, **kwargs): ext_config = config[Extension.ext_name]
super(M3UPlaylistsProvider, self).__init__(*args, **kwargs) if ext_config['playlists_dir'] is None:
self._playlists_dir = Extension.get_data_dir(config)
self._playlists_dir = self.backend._playlists_dir else:
self._playlists = {} self._playlists_dir = ext_config['playlists_dir']
self.refresh() self._base_dir = ext_config['base_dir'] or self._playlists_dir
self._default_encoding = ext_config['default_encoding']
self._default_extension = ext_config['default_extension']
def as_list(self): def as_list(self):
refs = [ result = []
Ref.playlist(uri=pl.uri, name=pl.name) for entry in os.listdir(self._playlists_dir):
for pl in self._playlists.values()] if not entry.endswith((b'.m3u', b'.m3u8')):
return sorted(refs, key=operator.attrgetter('name')) continue
elif not os.path.isfile(self._abspath(entry)):
def get_items(self, uri): continue
playlist = self._playlists.get(uri) else:
if playlist is None: result.append(translator.path_to_ref(entry))
return None result.sort(key=operator.attrgetter('name'))
return [Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] return result
def create(self, name): def create(self, name):
playlist = self._save_m3u(Playlist(name=name)) path = translator.path_from_name(name.strip(), self._default_extension)
self._playlists[playlist.uri] = playlist try:
logger.info('Created playlist %s', playlist.uri) with self._open(path, 'w'):
return playlist pass
mtime = os.path.getmtime(self._abspath(path))
except EnvironmentError as e:
log_environment_error('Error creating playlist %s' % name, e)
else:
return translator.playlist(path, [], mtime)
def delete(self, uri): def delete(self, uri):
if uri in self._playlists: path = translator.uri_to_path(uri)
path = translator.playlist_uri_to_path(uri, self._playlists_dir) try:
if os.path.exists(path): os.remove(self._abspath(path))
os.remove(path) except EnvironmentError as e:
else: log_environment_error('Error deleting playlist %s' % uri, e)
logger.warning(
'Trying to delete missing playlist file %s', path) def get_items(self, uri):
del self._playlists[uri] path = translator.uri_to_path(uri)
logger.info('Deleted playlist %s', uri) try:
with self._open(path, 'r') as fp:
items = translator.load_items(fp, self._base_dir)
except EnvironmentError as e:
log_environment_error('Error reading playlist %s' % uri, e)
else: else:
logger.warning('Trying to delete unknown playlist %s', uri) return items
def lookup(self, uri): def lookup(self, uri):
return self._playlists.get(uri) path = translator.uri_to_path(uri)
try:
with self._open(path, 'r') as fp:
items = translator.load_items(fp, self._base_dir)
mtime = os.path.getmtime(self._abspath(path))
except EnvironmentError as e:
log_environment_error('Error reading playlist %s' % uri, e)
else:
return translator.playlist(path, items, mtime)
def refresh(self): def refresh(self):
playlists = {} pass # nothing to do
encoding = sys.getfilesystemencoding()
for path in glob.glob(os.path.join(self._playlists_dir, b'*.m3u*')):
relpath = os.path.basename(path)
uri = translator.path_to_playlist_uri(relpath)
name = os.path.splitext(relpath)[0].decode(encoding, 'replace')
tracks = translator.parse_m3u(path)
playlists[uri] = Playlist(uri=uri, name=name, tracks=tracks)
self._playlists = playlists
logger.info(
'Loaded %d M3U playlists from %s',
len(playlists), self._playlists_dir)
# TODO Trigger playlists_loaded event?
def save(self, playlist): def save(self, playlist):
assert playlist.uri, 'Cannot save playlist without URI' path = translator.uri_to_path(playlist.uri)
assert playlist.uri in self._playlists, \ name = translator.name_from_path(path)
'Cannot save playlist with unknown URI: %s' % playlist.uri try:
with self._open(path, 'w') as fp:
original_uri = playlist.uri translator.dump_items(playlist.tracks, fp)
playlist = self._save_m3u(playlist) if playlist.name and playlist.name != name:
if playlist.uri != original_uri and original_uri in self._playlists: opath, ext = os.path.splitext(path)
self.delete(original_uri) path = translator.path_from_name(playlist.name.strip()) + ext
self._playlists[playlist.uri] = playlist os.rename(self._abspath(opath + ext), self._abspath(path))
return playlist mtime = os.path.getmtime(self._abspath(path))
except EnvironmentError as e:
def _sanitize_m3u_name(self, name, encoding=sys.getfilesystemencoding()): log_environment_error('Error saving playlist %s' % playlist.uri, e)
name = self._invalid_filename_chars.sub('|', name.strip())
# make sure we end up with a valid path segment
name = name.encode(encoding, errors='replace')
name = os.path.basename(name) # paranoia?
name = name.decode(encoding)
return name
def _save_m3u(self, playlist, encoding=sys.getfilesystemencoding()):
if playlist.name:
name = self._sanitize_m3u_name(playlist.name, encoding)
uri = translator.path_to_playlist_uri(
name.encode(encoding) + b'.m3u')
path = translator.playlist_uri_to_path(uri, self._playlists_dir)
elif playlist.uri:
uri = playlist.uri
path = translator.playlist_uri_to_path(uri, self._playlists_dir)
name, _ = os.path.splitext(os.path.basename(path).decode(encoding))
else: else:
raise ValueError('M3U playlist needs name or URI') return translator.playlist(path, playlist.tracks, mtime)
translator.save_m3u(path, playlist.tracks, 'latin1')
# assert playlist name matches file name/uri def _abspath(self, path):
return playlist.replace(uri=uri, name=name) return os.path.join(self._playlists_dir, path)
def _open(self, path, mode='r'):
if path.endswith(b'.m3u8'):
encoding = 'utf-8'
else:
encoding = self._default_encoding
if not os.path.isabs(path):
path = os.path.join(self._playlists_dir, path)
if 'w' in mode:
return replace(path, mode, encoding=encoding, errors='replace')
else:
return io.open(path, mode, encoding=encoding, errors='replace')

View File

@ -1,129 +1,119 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, print_function, unicode_literals
import codecs
import logging
import os import os
import re
import urllib
import urlparse
from mopidy import compat from mopidy import models
from mopidy.internal import encoding, path
from mopidy.models import Track from . import Extension
try:
from urllib.parse import quote_from_bytes, unquote_to_bytes
except ImportError:
import urllib
def quote_from_bytes(bytes, safe=b'/'):
# Python 3 returns Unicode string
return urllib.quote(bytes, safe).decode('utf-8')
def unquote_to_bytes(string):
if isinstance(string, bytes):
return urllib.unquote(string)
else:
return urllib.unquote(string.encode('utf-8'))
try:
from urllib.parse import urlsplit, urlunsplit
except ImportError:
from urlparse import urlsplit, urlunsplit
M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)') try:
from os import fsencode, fsdecode
except ImportError:
import sys
logger = logging.getLogger(__name__) # no 'surrogateescape' in Python 2; 'replace' for backward compatibility
def fsencode(filename, encoding=sys.getfilesystemencoding()):
return filename.encode(encoding, 'replace')
def fsdecode(filename, encoding=sys.getfilesystemencoding()):
return filename.decode(encoding, 'replace')
def playlist_uri_to_path(uri, playlists_dir): def path_to_uri(path, scheme=Extension.ext_name):
if not uri.startswith('m3u:'): """Convert file path to URI."""
raise ValueError('Invalid URI %s' % uri) assert isinstance(path, bytes), 'Mopidy paths should be bytes'
file_path = path.uri_to_path(uri) uripath = quote_from_bytes(os.path.normpath(path))
return os.path.join(playlists_dir, file_path) return urlunsplit((scheme, None, uripath, None, None))
def path_to_playlist_uri(relpath): def uri_to_path(uri):
"""Convert path relative to playlists_dir to M3U URI.""" """Convert URI to file path."""
if isinstance(relpath, compat.text_type): # TODO: decide on Unicode vs. bytes for URIs
relpath = relpath.encode('utf-8') return unquote_to_bytes(urlsplit(uri).path)
return b'm3u:%s' % urllib.quote(relpath)
def m3u_extinf_to_track(line): def name_from_path(path):
"""Convert extended M3U directive to track template.""" """Extract name from file path."""
m = M3U_EXTINF_RE.match(line) name, _ = os.path.splitext(os.path.basename(path))
if not m:
logger.warning('Invalid extended M3U directive: %s', line)
return Track()
(runtime, title) = m.groups()
if int(runtime) > 0:
return Track(name=title, length=1000 * int(runtime))
else:
return Track(name=title)
def parse_m3u(file_path, media_dir=None):
r"""
Convert M3U file list to list of tracks
Example M3U data::
# This is a comment
Alternative\Band - Song.mp3
Classical\Other Band - New Song.mp3
Stuff.mp3
D:\More Music\Foo.mp3
http://www.example.com:8000/Listen.pls
http://www.example.com/~user/Mine.mp3
Example extended M3U data::
#EXTM3U
#EXTINF:123, Sample artist - Sample title
Sample.mp3
#EXTINF:321,Example Artist - Example title
Greatest Hits\Example.ogg
#EXTINF:-1,Radio XMP
http://mp3stream.example.com:8000/
- Relative paths of songs should be with respect to location of M3U.
- Paths are normally platform specific.
- Lines starting with # are ignored, except for extended M3U directives.
- Track.name and Track.length are set from extended M3U directives.
- m3u files are latin-1.
- m3u8 files are utf-8
"""
# TODO: uris as bytes
file_encoding = 'utf-8' if file_path.endswith(b'.m3u8') else 'latin1'
tracks = []
try: try:
with codecs.open(file_path, 'rb', file_encoding, 'replace') as m3u: return fsdecode(name)
contents = m3u.readlines() except UnicodeError:
except IOError as error: return None
logger.warning('Couldn\'t open m3u: %s', encoding.locale_decode(error))
return tracks
if not contents:
return tracks
# Strip newlines left by codecs def path_from_name(name, ext=None, sep='|'):
contents = [line.strip() for line in contents] """Convert name with optional extension to file path."""
if ext:
return fsencode(name.replace(os.sep, sep) + ext)
else:
return fsencode(name.replace(os.sep, sep))
extended = contents[0].startswith('#EXTM3U')
track = Track() def path_to_ref(path):
for line in contents: return models.Ref.playlist(
uri=path_to_uri(path),
name=name_from_path(path)
)
def load_items(fp, basedir):
refs = []
name = None
for line in filter(None, (line.strip() for line in fp)):
if line.startswith('#'): if line.startswith('#'):
if extended and line.startswith('#EXTINF'): if line.startswith('#EXTINF:'):
track = m3u_extinf_to_track(line) name = line.partition(',')[2]
continue continue
elif not urlsplit(line).scheme:
if urlparse.urlsplit(line).scheme: path = os.path.join(basedir, fsencode(line))
tracks.append(track.replace(uri=line)) if not name:
elif os.path.normpath(line) == os.path.abspath(line): name = name_from_path(path)
uri = path.path_to_uri(line) uri = path_to_uri(path, scheme='file')
tracks.append(track.replace(uri=uri)) else:
elif media_dir is not None: uri = line # do *not* extract name from (stream?) URI path
uri = path.path_to_uri(os.path.join(media_dir, line)) refs.append(models.Ref.track(uri=uri, name=name))
tracks.append(track.replace(uri=uri)) name = None
return refs
track = Track()
return tracks
def save_m3u(filename, tracks, encoding='latin1', errors='replace'): def dump_items(items, fp):
extended = any(track.name for track in tracks) if any(item.name for item in items):
# codecs.open() always uses binary mode, just being explicit here print('#EXTM3U', file=fp)
with codecs.open(filename, 'wb', encoding, errors) as m3u: for item in items:
if extended: if item.name:
m3u.write('#EXTM3U' + os.linesep) print('#EXTINF:-1,%s' % item.name, file=fp)
for track in tracks: # TODO: convert file URIs to (relative) paths?
if extended and track.name: if isinstance(item.uri, bytes):
m3u.write('#EXTINF:%d,%s%s' % ( print(item.uri.decode('utf-8'), file=fp)
track.length // 1000 if track.length else -1, else:
track.name, print(item.uri, file=fp)
os.linesep))
m3u.write(track.uri + os.linesep)
def playlist(path, items=[], mtime=None):
return models.Playlist(
uri=path_to_uri(path),
name=name_from_path(path),
tracks=[models.Track(uri=item.uri, name=item.name) for item in items],
last_modified=(int(mtime * 1000) if mtime else None)
)

View File

@ -130,7 +130,7 @@ class MixerListener(listener.Listener):
@staticmethod @staticmethod
def send(event, **kwargs): def send(event, **kwargs):
"""Helper to allow calling of mixer listener events""" """Helper to allow calling of mixer listener events"""
listener.send_async(MixerListener, event, **kwargs) listener.send(MixerListener, event, **kwargs)
def volume_changed(self, volume): def volume_changed(self, volume):
""" """

View File

@ -1,5 +1,6 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
from mopidy import compat
from mopidy.models import fields from mopidy.models import fields
from mopidy.models.immutable import ImmutableObject, ValidatedImmutableObject from mopidy.models.immutable import ImmutableObject, ValidatedImmutableObject
from mopidy.models.serialize import ModelJSONEncoder, model_json_decoder from mopidy.models.serialize import ModelJSONEncoder, model_json_decoder
@ -145,6 +146,10 @@ class Album(ValidatedImmutableObject):
:type musicbrainz_id: string :type musicbrainz_id: string
:param images: album image URIs :param images: album image URIs
:type images: list of strings :type images: list of strings
.. deprecated:: 1.2
The ``images`` field is deprecated.
Use :meth:`mopidy.core.LibraryController.get_images` instead.
""" """
#: The album URI. Read-only. #: The album URI. Read-only.
@ -169,10 +174,10 @@ class Album(ValidatedImmutableObject):
musicbrainz_id = fields.Identifier() musicbrainz_id = fields.Identifier()
#: The album image URIs. Read-only. #: The album image URIs. Read-only.
images = fields.Collection(type=basestring, container=frozenset) #:
# XXX If we want to keep the order of images we shouldn't use frozenset() #: .. deprecated:: 1.2
# as it doesn't preserve order. I'm deferring this issue until we got #: Use :meth:`mopidy.core.LibraryController.get_images` instead.
# actual usage of this field with more than one image. images = fields.Collection(type=compat.string_types, container=frozenset)
class Track(ValidatedImmutableObject): class Track(ValidatedImmutableObject):

View File

@ -1,5 +1,7 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
from mopidy import compat
class Field(object): class Field(object):
@ -69,7 +71,7 @@ class String(Field):
# TODO: normalize to unicode? # TODO: normalize to unicode?
# TODO: only allow unicode? # TODO: only allow unicode?
# TODO: disallow empty strings? # TODO: disallow empty strings?
super(String, self).__init__(type=basestring, default=default) super(String, self).__init__(type=compat.string_types, default=default)
class Date(String): class Date(String):
@ -93,7 +95,7 @@ class Identifier(String):
:param default: default value for field :param default: default value for field
""" """
def validate(self, value): def validate(self, value):
return intern(str(super(Identifier, self).validate(value))) return compat.intern(str(super(Identifier, self).validate(value)))
class URI(Identifier): class URI(Identifier):
@ -119,7 +121,8 @@ class Integer(Field):
def __init__(self, default=None, min=None, max=None): def __init__(self, default=None, min=None, max=None):
self._min = min self._min = min
self._max = max self._max = max
super(Integer, self).__init__(type=(int, long), default=default) super(Integer, self).__init__(
type=compat.integer_types, default=default)
def validate(self, value): def validate(self, value):
value = super(Integer, self).validate(value) value = super(Integer, self).validate(value)
@ -144,7 +147,7 @@ class Collection(Field):
super(Collection, self).__init__(type=type, default=container()) super(Collection, self).__init__(type=type, default=container())
def validate(self, value): def validate(self, value):
if isinstance(value, basestring): if isinstance(value, compat.string_types):
raise TypeError('Expected %s to be a collection of %s, not %r' raise TypeError('Expected %s to be a collection of %s, not %r'
% (self._name, self._type.__name__, value)) % (self._name, self._type.__name__, value))
for v in value: for v in value:

View File

@ -112,7 +112,7 @@ class ImmutableObject(object):
for key, value in kwargs.items(): for key, value in kwargs.items():
if not self._is_valid_field(key): if not self._is_valid_field(key):
raise TypeError( raise TypeError(
'copy() got an unexpected keyword argument "%s"' % key) 'replace() got an unexpected keyword argument "%s"' % key)
other._set_field(key, value) other._set_field(key, value)
return other return other

View File

@ -25,6 +25,7 @@ class Extension(ext.Extension):
schema['connection_timeout'] = config.Integer(minimum=1) schema['connection_timeout'] = config.Integer(minimum=1)
schema['zeroconf'] = config.String(optional=True) schema['zeroconf'] = config.String(optional=True)
schema['command_blacklist'] = config.List(optional=True) schema['command_blacklist'] = config.List(optional=True)
schema['default_playlist_scheme'] = config.String()
return schema return schema
def validate_environment(self): def validate_environment(self):

View File

@ -4,13 +4,30 @@ import logging
import pykka import pykka
from mopidy import exceptions, zeroconf from mopidy import exceptions, listener, zeroconf
from mopidy.core import CoreListener from mopidy.core import CoreListener
from mopidy.internal import encoding, network, process from mopidy.internal import encoding, network, process
from mopidy.mpd import session, uri_mapper from mopidy.mpd import session, uri_mapper
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_CORE_EVENTS_TO_IDLE_SUBSYSTEMS = {
'track_playback_paused': None,
'track_playback_resumed': None,
'track_playback_started': None,
'track_playback_ended': None,
'playback_state_changed': 'player',
'tracklist_changed': 'playlist',
'playlists_loaded': 'stored_playlist',
'playlist_changed': 'stored_playlist',
'playlist_deleted': 'stored_playlist',
'options_changed': 'options',
'volume_changed': 'mixer',
'mute_changed': 'output',
'seeked': 'player',
'stream_title_changed': 'playlist',
}
class MpdFrontend(pykka.ThreadingActor, CoreListener): class MpdFrontend(pykka.ThreadingActor, CoreListener):
@ -24,6 +41,9 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
self.zeroconf_name = config['mpd']['zeroconf'] self.zeroconf_name = config['mpd']['zeroconf']
self.zeroconf_service = None self.zeroconf_service = None
self._setup_server(config, core)
def _setup_server(self, config, core):
try: try:
network.Server( network.Server(
self.hostname, self.port, self.hostname, self.port,
@ -45,7 +65,8 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
def on_start(self): def on_start(self):
if self.zeroconf_name: if self.zeroconf_name:
self.zeroconf_service = zeroconf.Zeroconf( self.zeroconf_service = zeroconf.Zeroconf(
stype='_mpd._tcp', name=self.zeroconf_name, name=self.zeroconf_name,
stype='_mpd._tcp',
port=self.port) port=self.port)
self.zeroconf_service.publish() self.zeroconf_service.publish()
@ -55,28 +76,13 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
process.stop_actors_by_class(session.MpdSession) process.stop_actors_by_class(session.MpdSession)
def on_event(self, event, **kwargs):
if event not in _CORE_EVENTS_TO_IDLE_SUBSYSTEMS:
logger.warning(
'Got unexpected event: %s(%s)', event, ', '.join(kwargs))
else:
self.send_idle(_CORE_EVENTS_TO_IDLE_SUBSYSTEMS[event])
def send_idle(self, subsystem): def send_idle(self, subsystem):
listeners = pykka.ActorRegistry.get_by_class(session.MpdSession) if subsystem:
for listener in listeners: listener.send(session.MpdSession, subsystem)
getattr(listener.proxy(), 'on_idle')(subsystem)
def playback_state_changed(self, old_state, new_state):
self.send_idle('player')
def tracklist_changed(self):
self.send_idle('playlist')
def options_changed(self):
self.send_idle('options')
def volume_changed(self, volume):
self.send_idle('mixer')
def mute_changed(self, mute):
self.send_idle('output')
def stream_title_changed(self, title):
self.send_idle('playlist')
def seeked(self, time_position):
self.send_idle('player')

View File

@ -80,10 +80,23 @@ class MpdNoExistError(MpdAckError):
error_code = MpdAckError.ACK_ERROR_NO_EXIST error_code = MpdAckError.ACK_ERROR_NO_EXIST
class MpdExistError(MpdAckError):
error_code = MpdAckError.ACK_ERROR_EXIST
class MpdSystemError(MpdAckError): class MpdSystemError(MpdAckError):
error_code = MpdAckError.ACK_ERROR_SYSTEM error_code = MpdAckError.ACK_ERROR_SYSTEM
class MpdInvalidPlaylistName(MpdAckError):
error_code = MpdAckError.ACK_ERROR_ARG
def __init__(self, *args, **kwargs):
super(MpdInvalidPlaylistName, self).__init__(*args, **kwargs)
self.message = ('playlist name is invalid: playlist names may not '
'contain slashes, newlines or carriage returns')
class MpdNotImplemented(MpdAckError): class MpdNotImplemented(MpdAckError):
error_code = 0 error_code = 0
@ -92,6 +105,27 @@ class MpdNotImplemented(MpdAckError):
self.message = 'Not implemented' self.message = 'Not implemented'
class MpdInvalidTrackForPlaylist(MpdAckError):
# NOTE: This is a custom error for Mopidy that does not exist in MPD.
error_code = 0
def __init__(self, playlist_scheme, track_scheme, *args, **kwargs):
super(MpdInvalidTrackForPlaylist, self).__init__(*args, **kwargs)
self.message = (
'Playlist with scheme "%s" can\'t store track scheme "%s"' %
(playlist_scheme, track_scheme))
class MpdFailedToSavePlaylist(MpdAckError):
# NOTE: This is a custom error for Mopidy that does not exist in MPD.
error_code = 0
def __init__(self, backend_scheme, *args, **kwargs):
super(MpdFailedToSavePlaylist, self).__init__(*args, **kwargs)
self.message = 'Backend with scheme "%s" failed to save playlist' % (
backend_scheme)
class MpdDisabled(MpdAckError): class MpdDisabled(MpdAckError):
# NOTE: This is a custom error for Mopidy that does not exist in MPD. # NOTE: This is a custom error for Mopidy that does not exist in MPD.
error_code = 0 error_code = 0

View File

@ -7,3 +7,4 @@ max_connections = 20
connection_timeout = 60 connection_timeout = 60
zeroconf = Mopidy MPD server on $hostname zeroconf = Mopidy MPD server on $hostname
command_blacklist = listall,listallinfo command_blacklist = listall,listallinfo
default_playlist_scheme = m3u

View File

@ -1,7 +1,6 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
import urlparse from mopidy.compat import urllib
from mopidy.internal import deprecation from mopidy.internal import deprecation
from mopidy.mpd import exceptions, protocol, translator from mopidy.mpd import exceptions, protocol, translator
@ -25,7 +24,7 @@ def add(context, uri):
# If we have an URI just try and add it directly without bothering with # If we have an URI just try and add it directly without bothering with
# jumping through browse... # jumping through browse...
if urlparse.urlparse(uri).scheme != '': if urllib.parse.urlparse(uri).scheme != '':
if context.core.tracklist.add(uris=[uri]).get(): if context.core.tracklist.add(uris=[uri]).get():
return return

View File

@ -137,13 +137,13 @@ def pause(context, state=None):
playback_state = context.core.playback.get_state().get() playback_state = context.core.playback.get_state().get()
if (playback_state == PlaybackState.PLAYING): if (playback_state == PlaybackState.PLAYING):
context.core.playback.pause() context.core.playback.pause().get()
elif (playback_state == PlaybackState.PAUSED): elif (playback_state == PlaybackState.PAUSED):
context.core.playback.resume() context.core.playback.resume().get()
elif state: elif state:
context.core.playback.pause() context.core.playback.pause().get()
else: else:
context.core.playback.resume() context.core.playback.resume().get()
@protocol.commands.add('play', songpos=protocol.INT) @protocol.commands.add('play', songpos=protocol.INT)

View File

@ -1,10 +1,20 @@
from __future__ import absolute_import, division, unicode_literals from __future__ import absolute_import, division, unicode_literals
import datetime import datetime
import logging
import re
import warnings import warnings
from mopidy.compat import urllib
from mopidy.mpd import exceptions, protocol, translator from mopidy.mpd import exceptions, protocol, translator
logger = logging.getLogger(__name__)
def _check_playlist_name(name):
if re.search('[/\n\r]', name):
raise exceptions.MpdInvalidPlaylistName()
@protocol.commands.add('listplaylist') @protocol.commands.add('listplaylist')
def listplaylist(context, name): def listplaylist(context, name):
@ -135,7 +145,7 @@ def load(context, name, playlist_slice=slice(0, None)):
@protocol.commands.add('playlistadd') @protocol.commands.add('playlistadd')
def playlistadd(context, name, uri): def playlistadd(context, name, track_uri):
""" """
*musicpd.org, stored playlists section:* *musicpd.org, stored playlists section:*
@ -145,7 +155,64 @@ def playlistadd(context, name, uri):
``NAME.m3u`` will be created if it does not exist. ``NAME.m3u`` will be created if it does not exist.
""" """
raise exceptions.MpdNotImplemented # TODO _check_playlist_name(name)
uri = context.lookup_playlist_uri_from_name(name)
old_playlist = uri is not None and context.core.playlists.lookup(uri).get()
if not old_playlist:
# Create new playlist with this single track
lookup_res = context.core.library.lookup(uris=[track_uri]).get()
tracks = [
track
for uri_tracks in lookup_res.values()
for track in uri_tracks]
_create_playlist(context, name, tracks)
else:
# Add track to existing playlist
lookup_res = context.core.library.lookup(uris=[track_uri]).get()
new_tracks = [
track
for uri_tracks in lookup_res.values()
for track in uri_tracks]
new_playlist = old_playlist.replace(
tracks=list(old_playlist.tracks) + new_tracks)
saved_playlist = context.core.playlists.save(new_playlist).get()
if saved_playlist is None:
playlist_scheme = urllib.parse.urlparse(old_playlist.uri).scheme
uri_scheme = urllib.parse.urlparse(track_uri).scheme
raise exceptions.MpdInvalidTrackForPlaylist(
playlist_scheme, uri_scheme)
def _create_playlist(context, name, tracks):
"""
Creates new playlist using backend appropriate for the given tracks
"""
uri_schemes = set([urllib.parse.urlparse(t.uri).scheme for t in tracks])
for scheme in uri_schemes:
new_playlist = context.core.playlists.create(name, scheme).get()
if new_playlist is None:
logger.debug(
"Backend for scheme %s can't create playlists", scheme)
continue # Backend can't create playlists at all
new_playlist = new_playlist.replace(tracks=tracks)
saved_playlist = context.core.playlists.save(new_playlist).get()
if saved_playlist is not None:
return # Created and saved
else:
continue # Failed to save using this backend
# Can't use backend appropriate for passed URI schemes, use default one
default_scheme = context.dispatcher.config[
'mpd']['default_playlist_scheme']
new_playlist = context.core.playlists.create(name, default_scheme).get()
if new_playlist is None:
# If even MPD's default backend can't save playlist, everything is lost
logger.warning("MPD's default backend can't create playlists")
raise exceptions.MpdFailedToSavePlaylist(default_scheme)
new_playlist = new_playlist.replace(tracks=tracks)
saved_playlist = context.core.playlists.save(new_playlist).get()
if saved_playlist is None:
uri_scheme = urllib.parse.urlparse(new_playlist.uri).scheme
raise exceptions.MpdFailedToSavePlaylist(uri_scheme)
@protocol.commands.add('playlistclear') @protocol.commands.add('playlistclear')
@ -156,8 +223,20 @@ def playlistclear(context, name):
``playlistclear {NAME}`` ``playlistclear {NAME}``
Clears the playlist ``NAME.m3u``. Clears the playlist ``NAME.m3u``.
The playlist will be created if it does not exist.
""" """
raise exceptions.MpdNotImplemented # TODO _check_playlist_name(name)
uri = context.lookup_playlist_uri_from_name(name)
playlist = uri is not None and context.core.playlists.lookup(uri).get()
if not playlist:
playlist = context.core.playlists.create(name).get()
# Just replace tracks with empty list and save
playlist = playlist.replace(tracks=[])
if context.core.playlists.save(playlist).get() is None:
raise exceptions.MpdFailedToSavePlaylist(
urllib.parse.urlparse(uri).scheme)
@protocol.commands.add('playlistdelete', songpos=protocol.UINT) @protocol.commands.add('playlistdelete', songpos=protocol.UINT)
@ -169,7 +248,25 @@ def playlistdelete(context, name, songpos):
Deletes ``SONGPOS`` from the playlist ``NAME.m3u``. Deletes ``SONGPOS`` from the playlist ``NAME.m3u``.
""" """
raise exceptions.MpdNotImplemented # TODO _check_playlist_name(name)
uri = context.lookup_playlist_uri_from_name(name)
playlist = uri is not None and context.core.playlists.lookup(uri).get()
if not playlist:
raise exceptions.MpdNoExistError('No such playlist')
try:
# Convert tracks to list and remove requested
tracks = list(playlist.tracks)
tracks.pop(songpos)
except IndexError:
raise exceptions.MpdArgError('Bad song index')
# Replace tracks and save playlist
playlist = playlist.replace(tracks=tracks)
saved_playlist = context.core.playlists.save(playlist).get()
if saved_playlist is None:
raise exceptions.MpdFailedToSavePlaylist(
urllib.parse.urlparse(uri).scheme)
@protocol.commands.add( @protocol.commands.add(
@ -189,7 +286,31 @@ def playlistmove(context, name, from_pos, to_pos):
documentation, but just the ``SONGPOS`` to move *from*, i.e. documentation, but just the ``SONGPOS`` to move *from*, i.e.
``playlistmove {NAME} {FROM_SONGPOS} {TO_SONGPOS}``. ``playlistmove {NAME} {FROM_SONGPOS} {TO_SONGPOS}``.
""" """
raise exceptions.MpdNotImplemented # TODO if from_pos == to_pos:
return
_check_playlist_name(name)
uri = context.lookup_playlist_uri_from_name(name)
playlist = uri is not None and context.core.playlists.lookup(uri).get()
if not playlist:
raise exceptions.MpdNoExistError('No such playlist')
if from_pos == to_pos:
return # Nothing to do
try:
# Convert tracks to list and perform move
tracks = list(playlist.tracks)
track = tracks.pop(from_pos)
tracks.insert(to_pos, track)
except IndexError:
raise exceptions.MpdArgError('Bad song index')
# Replace tracks and save playlist
playlist = playlist.replace(tracks=tracks)
saved_playlist = context.core.playlists.save(playlist).get()
if saved_playlist is None:
raise exceptions.MpdFailedToSavePlaylist(
urllib.parse.urlparse(uri).scheme)
@protocol.commands.add('rename') @protocol.commands.add('rename')
@ -201,7 +322,31 @@ def rename(context, old_name, new_name):
Renames the playlist ``NAME.m3u`` to ``NEW_NAME.m3u``. Renames the playlist ``NAME.m3u`` to ``NEW_NAME.m3u``.
""" """
raise exceptions.MpdNotImplemented # TODO _check_playlist_name(old_name)
_check_playlist_name(new_name)
old_uri = context.lookup_playlist_uri_from_name(old_name)
if not old_uri:
raise exceptions.MpdNoExistError('No such playlist')
old_playlist = context.core.playlists.lookup(old_uri).get()
if not old_playlist:
raise exceptions.MpdNoExistError('No such playlist')
new_uri = context.lookup_playlist_uri_from_name(new_name)
if new_uri and context.core.playlists.lookup(new_uri).get():
raise exceptions.MpdExistError('Playlist already exists')
# TODO: should we purge the mapping in an else?
# Create copy of the playlist and remove original
uri_scheme = urllib.parse.urlparse(old_uri).scheme
new_playlist = context.core.playlists.create(new_name, uri_scheme).get()
new_playlist = new_playlist.replace(tracks=old_playlist.tracks)
saved_playlist = context.core.playlists.save(new_playlist).get()
if saved_playlist is None:
raise exceptions.MpdFailedToSavePlaylist(uri_scheme)
context.core.playlists.delete(old_playlist.uri).get()
@protocol.commands.add('rm') @protocol.commands.add('rm')
@ -213,7 +358,11 @@ def rm(context, name):
Removes the playlist ``NAME.m3u`` from the playlist directory. Removes the playlist ``NAME.m3u`` from the playlist directory.
""" """
raise exceptions.MpdNotImplemented # TODO _check_playlist_name(name)
uri = context.lookup_playlist_uri_from_name(name)
if not uri:
raise exceptions.MpdNoExistError('No such playlist')
context.core.playlists.delete(uri).get()
@protocol.commands.add('save') @protocol.commands.add('save')
@ -226,4 +375,17 @@ def save(context, name):
Saves the current playlist to ``NAME.m3u`` in the playlist Saves the current playlist to ``NAME.m3u`` in the playlist
directory. directory.
""" """
raise exceptions.MpdNotImplemented # TODO _check_playlist_name(name)
tracks = context.core.tracklist.get_tracks().get()
uri = context.lookup_playlist_uri_from_name(name)
playlist = uri is not None and context.core.playlists.lookup(uri).get()
if not playlist:
# Create new playlist
_create_playlist(context, name, tracks)
else:
# Overwrite existing playlist
new_playlist = playlist.replace(tracks=tracks)
saved_playlist = context.core.playlists.save(new_playlist).get()
if saved_playlist is None:
raise exceptions.MpdFailedToSavePlaylist(
urllib.parse.urlparse(uri).scheme)

View File

@ -41,7 +41,7 @@ class MpdSession(network.LineProtocol):
self.send_lines(response) self.send_lines(response)
def on_idle(self, subsystem): def on_event(self, subsystem):
self.dispatcher.handle_idle(subsystem) self.dispatcher.handle_idle(subsystem)
def decode(self, line): def decode(self, line):

View File

@ -71,7 +71,7 @@ class MpdUriMapper(object):
""" """
Helper function to retrieve a playlist URI from its unique MPD name. Helper function to retrieve a playlist URI from its unique MPD name.
""" """
if not self._uri_from_name: if name not in self._uri_from_name:
self.refresh_playlists_mapping() self.refresh_playlists_mapping()
return self._uri_from_name.get(name) return self._uri_from_name.get(name)

View File

@ -4,12 +4,12 @@ import fnmatch
import logging import logging
import re import re
import time import time
import urlparse
import pykka import pykka
from mopidy import audio as audio_lib, backend, exceptions, stream from mopidy import audio as audio_lib, backend, exceptions, stream
from mopidy.audio import scan, utils from mopidy.audio import scan, tags
from mopidy.compat import urllib
from mopidy.internal import http, playlists from mopidy.internal import http, playlists
from mopidy.models import Track from mopidy.models import Track
@ -25,10 +25,19 @@ class StreamBackend(pykka.ThreadingActor, backend.Backend):
timeout=config['stream']['timeout'], timeout=config['stream']['timeout'],
proxy_config=config['proxy']) proxy_config=config['proxy'])
self.library = StreamLibraryProvider( self._session = http.get_requests_session(
backend=self, blacklist=config['stream']['metadata_blacklist']) proxy_config=config['proxy'],
self.playback = StreamPlaybackProvider( user_agent='%s/%s' % (
audio=audio, backend=self, config=config) stream.Extension.dist_name, stream.Extension.version))
blacklist = config['stream']['metadata_blacklist']
self._blacklist_re = re.compile(
r'^(%s)$' % '|'.join(fnmatch.translate(u) for u in blacklist))
self._timeout = config['stream']['timeout']
self.library = StreamLibraryProvider(backend=self)
self.playback = StreamPlaybackProvider(audio=audio, backend=self)
self.playlists = None self.playlists = None
self.uri_schemes = audio_lib.supported_uri_schemes( self.uri_schemes = audio_lib.supported_uri_schemes(
@ -43,27 +52,23 @@ class StreamBackend(pykka.ThreadingActor, backend.Backend):
class StreamLibraryProvider(backend.LibraryProvider): class StreamLibraryProvider(backend.LibraryProvider):
def __init__(self, backend, blacklist):
super(StreamLibraryProvider, self).__init__(backend)
self._scanner = backend._scanner
self._blacklist_re = re.compile(
r'^(%s)$' % '|'.join(fnmatch.translate(u) for u in blacklist))
def lookup(self, uri): def lookup(self, uri):
if urlparse.urlsplit(uri).scheme not in self.backend.uri_schemes: if urllib.parse.urlsplit(uri).scheme not in self.backend.uri_schemes:
return [] return []
if self._blacklist_re.match(uri): if self.backend._blacklist_re.match(uri):
logger.debug('URI matched metadata lookup blacklist: %s', uri) logger.debug('URI matched metadata lookup blacklist: %s', uri)
return [Track(uri=uri)] return [Track(uri=uri)]
try: _, scan_result = _unwrap_stream(
result = self._scanner.scan(uri) uri, timeout=self.backend._timeout, scanner=self.backend._scanner,
track = utils.convert_tags_to_track(result.tags).replace( requests_session=self.backend._session)
uri=uri, length=result.duration)
except exceptions.ScannerError as e: if scan_result:
logger.warning('Problem looking up %s: %s', uri, e) track = tags.convert_tags_to_track(scan_result.tags).replace(
uri=uri, length=scan_result.duration)
else:
logger.warning('Problem looking up %s: %s', uri)
track = Track(uri=uri) track = Track(uri=uri)
return [track] return [track]
@ -71,23 +76,21 @@ class StreamLibraryProvider(backend.LibraryProvider):
class StreamPlaybackProvider(backend.PlaybackProvider): class StreamPlaybackProvider(backend.PlaybackProvider):
def __init__(self, audio, backend, config):
super(StreamPlaybackProvider, self).__init__(audio, backend)
self._config = config
self._scanner = backend._scanner
self._session = http.get_requests_session(
proxy_config=config['proxy'],
user_agent='%s/%s' % (
stream.Extension.dist_name, stream.Extension.version))
def translate_uri(self, uri): def translate_uri(self, uri):
return _unwrap_stream( if urllib.parse.urlsplit(uri).scheme not in self.backend.uri_schemes:
uri, return None
timeout=self._config['stream']['timeout'],
scanner=self._scanner, if self.backend._blacklist_re.match(uri):
requests_session=self._session) logger.debug('URI matched metadata lookup blacklist: %s', uri)
return uri
unwrapped_uri, _ = _unwrap_stream(
uri, timeout=self.backend._timeout, scanner=self.backend._scanner,
requests_session=self.backend._session)
return unwrapped_uri
# TODO: cleanup the return value of this.
def _unwrap_stream(uri, timeout, scanner, requests_session): def _unwrap_stream(uri, timeout, scanner, requests_session):
""" """
Get a stream URI from a playlist URI, ``uri``. Get a stream URI from a playlist URI, ``uri``.
@ -105,7 +108,7 @@ def _unwrap_stream(uri, timeout, scanner, requests_session):
logger.info( logger.info(
'Unwrapping stream from URI (%s) failed: ' 'Unwrapping stream from URI (%s) failed: '
'playlist referenced itself', uri) 'playlist referenced itself', uri)
return None return None, None
else: else:
seen_uris.add(uri) seen_uris.add(uri)
@ -117,7 +120,7 @@ def _unwrap_stream(uri, timeout, scanner, requests_session):
logger.info( logger.info(
'Unwrapping stream from URI (%s) failed: ' 'Unwrapping stream from URI (%s) failed: '
'timed out in %sms', uri, timeout) 'timed out in %sms', uri, timeout)
return None return None, None
scan_result = scanner.scan(uri, timeout=scan_timeout) scan_result = scanner.scan(uri, timeout=scan_timeout)
except exceptions.ScannerError as exc: except exceptions.ScannerError as exc:
logger.debug('GStreamer failed scanning URI (%s): %s', uri, exc) logger.debug('GStreamer failed scanning URI (%s): %s', uri, exc)
@ -130,14 +133,14 @@ def _unwrap_stream(uri, timeout, scanner, requests_session):
): ):
logger.debug( logger.debug(
'Unwrapped potential %s stream: %s', scan_result.mime, uri) 'Unwrapped potential %s stream: %s', scan_result.mime, uri)
return uri return uri, scan_result
download_timeout = deadline - time.time() download_timeout = deadline - time.time()
if download_timeout < 0: if download_timeout < 0:
logger.info( logger.info(
'Unwrapping stream from URI (%s) failed: timed out in %sms', 'Unwrapping stream from URI (%s) failed: timed out in %sms',
uri, timeout) uri, timeout)
return None return None, None
content = http.download( content = http.download(
requests_session, uri, timeout=download_timeout) requests_session, uri, timeout=download_timeout)
@ -145,14 +148,14 @@ def _unwrap_stream(uri, timeout, scanner, requests_session):
logger.info( logger.info(
'Unwrapping stream from URI (%s) failed: ' 'Unwrapping stream from URI (%s) failed: '
'error downloading URI %s', original_uri, uri) 'error downloading URI %s', original_uri, uri)
return None return None, None
uris = playlists.parse(content) uris = playlists.parse(content)
if not uris: if not uris:
logger.debug( logger.debug(
'Failed parsing URI (%s) as playlist; found potential stream.', 'Failed parsing URI (%s) as playlist; found potential stream.',
uri) uri)
return uri return uri, None
# TODO Test streams and return first that seems to be playable # TODO Test streams and return first that seems to be playable
logger.debug( logger.debug(

View File

@ -1,7 +1,6 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
import logging import logging
import socket
import string import string
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -37,31 +36,43 @@ class Zeroconf(object):
Currently, this only works on Linux using Avahi via D-Bus. Currently, this only works on Linux using Avahi via D-Bus.
:param str name: human readable name of the service, e.g. 'MPD on neptune' :param str name: human readable name of the service, e.g. 'MPD on neptune'
:param int port: TCP port of the service, e.g. 6600
:param str stype: service type, e.g. '_mpd._tcp' :param str stype: service type, e.g. '_mpd._tcp'
:param int port: TCP port of the service, e.g. 6600
:param str domain: local network domain name, defaults to '' :param str domain: local network domain name, defaults to ''
:param str host: interface to advertise the service on, defaults to all :param str host: interface to advertise the service on, defaults to ''
interfaces
:param text: extra information depending on ``stype``, defaults to empty :param text: extra information depending on ``stype``, defaults to empty
list list
:type text: list of str :type text: list of str
""" """
def __init__(self, name, port, stype=None, domain=None, text=None): def __init__(self, name, stype, port, domain='', host='', text=None):
self.group = None self.stype = stype
self.stype = stype or '_http._tcp'
self.domain = domain or ''
self.port = port self.port = port
self.domain = domain
self.host = host
self.text = text or [] self.text = text or []
template = string.Template(name) self.bus = None
self.name = template.safe_substitute( self.server = None
hostname=socket.getfqdn(), port=self.port) self.group = None
self.host = '%s.local' % socket.getfqdn() self.display_hostname = None
self.name = None
if dbus:
try:
self.bus = dbus.SystemBus()
self.server = dbus.Interface(
self.bus.get_object('org.freedesktop.Avahi', '/'),
'org.freedesktop.Avahi.Server')
self.display_hostname = '%s' % self.server.GetHostName()
self.name = string.Template(name).safe_substitute(
hostname=self.display_hostname, port=port)
except dbus.exceptions.DBusException as e:
logger.debug('%s: Server failed: %s', self, e)
def __str__(self): def __str__(self):
return 'Zeroconf service %s at [%s]:%d' % ( return 'Zeroconf service "%s" (%s at [%s]:%d)' % (
self.stype, self.host, self.port) self.name, self.stype, self.host, self.port)
def publish(self): def publish(self):
"""Publish the service. """Publish the service.
@ -78,26 +89,29 @@ class Zeroconf(object):
logger.debug('%s: dbus not installed; publish failed.', self) logger.debug('%s: dbus not installed; publish failed.', self)
return False return False
try: if not self.bus:
bus = dbus.SystemBus() logger.debug('%s: Bus not available; publish failed.', self)
return False
if not bus.name_has_owner('org.freedesktop.Avahi'): if not self.server:
logger.debug('%s: Server not available; publish failed.', self)
return False
try:
if not self.bus.name_has_owner('org.freedesktop.Avahi'):
logger.debug( logger.debug(
'%s: Avahi service not running; publish failed.', self) '%s: Avahi service not running; publish failed.', self)
return False return False
server = dbus.Interface(
bus.get_object('org.freedesktop.Avahi', '/'),
'org.freedesktop.Avahi.Server')
self.group = dbus.Interface( self.group = dbus.Interface(
bus.get_object( self.bus.get_object(
'org.freedesktop.Avahi', server.EntryGroupNew()), 'org.freedesktop.Avahi', self.server.EntryGroupNew()),
'org.freedesktop.Avahi.EntryGroup') 'org.freedesktop.Avahi.EntryGroup')
self.group.AddService( self.group.AddService(
_AVAHI_IF_UNSPEC, _AVAHI_PROTO_UNSPEC, _AVAHI_IF_UNSPEC, _AVAHI_PROTO_UNSPEC,
dbus.UInt32(_AVAHI_PUBLISHFLAGS_NONE), self.name, self.stype, dbus.UInt32(_AVAHI_PUBLISHFLAGS_NONE),
self.name, self.stype,
self.domain, self.host, dbus.UInt16(self.port), self.domain, self.host, dbus.UInt16(self.port),
_convert_text_list_to_dbus_format(self.text)) _convert_text_list_to_dbus_format(self.text))

View File

@ -32,6 +32,6 @@ class IsA(object):
return str(self.klass) return str(self.klass)
any_int = IsA((int, long)) any_int = IsA(compat.integer_types)
any_str = IsA(str) any_str = IsA(compat.string_types)
any_unicode = IsA(compat.text_type) any_unicode = IsA(compat.text_type)

View File

@ -3,20 +3,14 @@ from __future__ import absolute_import, unicode_literals
import threading import threading
import unittest import unittest
import gobject
gobject.threads_init()
import mock import mock
import pygst
pygst.require('0.10')
import gst # noqa
import pykka import pykka
from mopidy import audio from mopidy import audio
from mopidy.audio.constants import PlaybackState from mopidy.audio.constants import PlaybackState
from mopidy.internal import path from mopidy.internal import path
from mopidy.internal.gi import Gst
from tests import dummy_audio, path_to_data_dir from tests import dummy_audio, path_to_data_dir
@ -28,6 +22,7 @@ from tests import dummy_audio, path_to_data_dir
class BaseTest(unittest.TestCase): class BaseTest(unittest.TestCase):
config = { config = {
'audio': { 'audio': {
'buffer_time': None,
'mixer': 'fakemixer track_max_volume=65536', 'mixer': 'fakemixer track_max_volume=65536',
'mixer_track': None, 'mixer_track': None,
'mixer_volume': None, 'mixer_volume': None,
@ -44,6 +39,7 @@ class BaseTest(unittest.TestCase):
def setUp(self): # noqa: N802 def setUp(self): # noqa: N802
config = { config = {
'audio': { 'audio': {
'buffer_time': None,
'mixer': 'foomixer', 'mixer': 'foomixer',
'mixer_volume': None, 'mixer_volume': None,
'output': 'testoutput', 'output': 'testoutput',
@ -59,7 +55,7 @@ class BaseTest(unittest.TestCase):
def tearDown(self): # noqa def tearDown(self): # noqa
pykka.ActorRegistry.stop_all() pykka.ActorRegistry.stop_all()
def possibly_trigger_fake_playback_error(self): def possibly_trigger_fake_playback_error(self, uri):
pass pass
def possibly_trigger_fake_about_to_finish(self): def possibly_trigger_fake_about_to_finish(self):
@ -69,8 +65,8 @@ class BaseTest(unittest.TestCase):
class DummyMixin(object): class DummyMixin(object):
audio_class = dummy_audio.DummyAudio audio_class = dummy_audio.DummyAudio
def possibly_trigger_fake_playback_error(self): def possibly_trigger_fake_playback_error(self, uri):
self.audio.trigger_fake_playback_failure() self.audio.trigger_fake_playback_failure(uri)
def possibly_trigger_fake_about_to_finish(self): def possibly_trigger_fake_about_to_finish(self):
callback = self.audio.get_about_to_finish_callback().get() callback = self.audio.get_about_to_finish_callback().get()
@ -86,7 +82,7 @@ class AudioTest(BaseTest):
self.assertTrue(self.audio.start_playback().get()) self.assertTrue(self.audio.start_playback().get())
def test_start_playback_non_existing_file(self): def test_start_playback_non_existing_file(self):
self.possibly_trigger_fake_playback_error() self.possibly_trigger_fake_playback_error(self.uris[0] + 'bogus')
self.audio.prepare_change() self.audio.prepare_change()
self.audio.set_uri(self.uris[0] + 'bogus') self.audio.set_uri(self.uris[0] + 'bogus')
@ -133,186 +129,253 @@ class AudioDummyTest(DummyMixin, AudioTest):
pass pass
@mock.patch.object(audio.AudioListener, 'send') class DummyAudioListener(pykka.ThreadingActor, audio.AudioListener):
def __init__(self):
super(DummyAudioListener, self).__init__()
self.events = []
self.waiters = {}
def on_event(self, event, **kwargs):
self.events.append((event, kwargs))
if event in self.waiters:
self.waiters[event].set()
def wait(self, event):
self.waiters[event] = threading.Event()
return self.waiters[event]
def get_events(self):
return self.events
def clear_events(self):
self.events = []
class AudioEventTest(BaseTest): class AudioEventTest(BaseTest):
def setUp(self): # noqa: N802 def setUp(self): # noqa: N802
super(AudioEventTest, self).setUp() super(AudioEventTest, self).setUp()
self.audio.enable_sync_handler().get() self.audio.enable_sync_handler().get()
self.listener = DummyAudioListener.start().proxy()
def tearDown(self): # noqa: N802
super(AudioEventTest, self).tearDown()
def assertEvent(self, event, **kwargs): # noqa: N802
self.assertIn((event, kwargs), self.listener.get_events().get())
def assertNotEvent(self, event, **kwargs): # noqa: N802
self.assertNotIn((event, kwargs), self.listener.get_events().get())
# TODO: test without uri set, with bad uri and gapless... # TODO: test without uri set, with bad uri and gapless...
# TODO: playing->playing triggered by seek should be removed # TODO: playing->playing triggered by seek should be removed
# TODO: codify expected state after EOS # TODO: codify expected state after EOS
# TODO: consider returning a future or a threading event? # TODO: consider returning a future or a threading event?
def test_state_change_stopped_to_playing_event(self, send_mock): def test_state_change_stopped_to_playing_event(self):
self.audio.prepare_change() self.audio.prepare_change()
self.audio.set_uri(self.uris[0]) self.audio.set_uri(self.uris[0])
self.audio.start_playback() self.audio.start_playback()
self.audio.wait_for_state_change().get() self.audio.wait_for_state_change().get()
call = mock.call('state_changed', old_state=PlaybackState.STOPPED, self.assertEvent('state_changed', old_state=PlaybackState.STOPPED,
new_state=PlaybackState.PLAYING, target_state=None) new_state=PlaybackState.PLAYING, target_state=None)
self.assertIn(call, send_mock.call_args_list)
def test_state_change_stopped_to_paused_event(self, send_mock): def test_state_change_stopped_to_paused_event(self):
self.audio.prepare_change() self.audio.prepare_change()
self.audio.set_uri(self.uris[0]) self.audio.set_uri(self.uris[0])
self.audio.pause_playback() self.audio.pause_playback()
self.audio.wait_for_state_change().get() self.audio.wait_for_state_change().get()
call = mock.call('state_changed', old_state=PlaybackState.STOPPED, self.assertEvent('state_changed', old_state=PlaybackState.STOPPED,
new_state=PlaybackState.PAUSED, target_state=None) new_state=PlaybackState.PAUSED, target_state=None)
self.assertIn(call, send_mock.call_args_list)
def test_state_change_paused_to_playing_event(self, send_mock): def test_state_change_paused_to_playing_event(self):
self.audio.prepare_change() self.audio.prepare_change()
self.audio.set_uri(self.uris[0]) self.audio.set_uri(self.uris[0])
self.audio.pause_playback() self.audio.pause_playback()
self.audio.wait_for_state_change() self.audio.wait_for_state_change()
self.listener.clear_events()
self.audio.start_playback() self.audio.start_playback()
self.audio.wait_for_state_change().get() self.audio.wait_for_state_change().get()
call = mock.call('state_changed', old_state=PlaybackState.PAUSED, self.assertEvent('state_changed', old_state=PlaybackState.PAUSED,
new_state=PlaybackState.PLAYING, target_state=None) new_state=PlaybackState.PLAYING, target_state=None)
self.assertIn(call, send_mock.call_args_list)
def test_state_change_paused_to_stopped_event(self, send_mock): def test_state_change_paused_to_stopped_event(self):
self.audio.prepare_change() self.audio.prepare_change()
self.audio.set_uri(self.uris[0]) self.audio.set_uri(self.uris[0])
self.audio.pause_playback() self.audio.pause_playback()
self.audio.wait_for_state_change() self.audio.wait_for_state_change()
self.listener.clear_events()
self.audio.stop_playback() self.audio.stop_playback()
self.audio.wait_for_state_change().get() self.audio.wait_for_state_change().get()
call = mock.call('state_changed', old_state=PlaybackState.PAUSED, self.assertEvent('state_changed', old_state=PlaybackState.PAUSED,
new_state=PlaybackState.STOPPED, target_state=None) new_state=PlaybackState.STOPPED, target_state=None)
self.assertIn(call, send_mock.call_args_list)
def test_state_change_playing_to_paused_event(self, send_mock): def test_state_change_playing_to_paused_event(self):
self.audio.prepare_change() self.audio.prepare_change()
self.audio.set_uri(self.uris[0]) self.audio.set_uri(self.uris[0])
self.audio.start_playback() self.audio.start_playback()
self.audio.wait_for_state_change() self.audio.wait_for_state_change()
self.listener.clear_events()
self.audio.pause_playback() self.audio.pause_playback()
self.audio.wait_for_state_change().get() self.audio.wait_for_state_change().get()
call = mock.call('state_changed', old_state=PlaybackState.PLAYING, self.assertEvent('state_changed', old_state=PlaybackState.PLAYING,
new_state=PlaybackState.PAUSED, target_state=None) new_state=PlaybackState.PAUSED, target_state=None)
self.assertIn(call, send_mock.call_args_list)
def test_state_change_playing_to_stopped_event(self, send_mock): def test_state_change_playing_to_stopped_event(self):
self.audio.prepare_change() self.audio.prepare_change()
self.audio.set_uri(self.uris[0]) self.audio.set_uri(self.uris[0])
self.audio.start_playback() self.audio.start_playback()
self.audio.wait_for_state_change() self.audio.wait_for_state_change()
self.listener.clear_events()
self.audio.stop_playback() self.audio.stop_playback()
self.audio.wait_for_state_change().get() self.audio.wait_for_state_change().get()
call = mock.call('state_changed', old_state=PlaybackState.PLAYING, self.assertEvent('state_changed', old_state=PlaybackState.PLAYING,
new_state=PlaybackState.STOPPED, target_state=None) new_state=PlaybackState.STOPPED, target_state=None)
self.assertIn(call, send_mock.call_args_list)
def test_stream_changed_event_on_playing(self, send_mock): def test_stream_changed_event_on_playing(self):
self.audio.prepare_change() self.audio.prepare_change()
self.audio.set_uri(self.uris[0]) self.audio.set_uri(self.uris[0])
self.listener.clear_events()
self.audio.start_playback() self.audio.start_playback()
# Since we are going from stopped to playing, the state change is # Since we are going from stopped to playing, the state change is
# enough to ensure the stream changed. # enough to ensure the stream changed.
self.audio.wait_for_state_change().get() self.audio.wait_for_state_change().get()
self.assertEvent('stream_changed', uri=self.uris[0])
call = mock.call('stream_changed', uri=self.uris[0]) def test_stream_changed_event_on_multiple_changes(self):
self.assertIn(call, send_mock.call_args_list) self.audio.prepare_change()
self.audio.set_uri(self.uris[0])
self.listener.clear_events()
self.audio.start_playback()
def test_stream_changed_event_on_paused_to_stopped(self, send_mock): self.audio.wait_for_state_change().get()
self.assertEvent('stream_changed', uri=self.uris[0])
self.audio.prepare_change()
self.audio.set_uri(self.uris[1])
self.audio.pause_playback()
self.audio.wait_for_state_change().get()
self.assertEvent('stream_changed', uri=self.uris[1])
def test_stream_changed_event_on_playing_to_paused(self):
self.audio.prepare_change()
self.audio.set_uri(self.uris[0])
self.listener.clear_events()
self.audio.start_playback()
self.audio.wait_for_state_change().get()
self.assertEvent('stream_changed', uri=self.uris[0])
self.listener.clear_events()
self.audio.pause_playback()
self.audio.wait_for_state_change().get()
self.assertNotEvent('stream_changed', uri=self.uris[0])
def test_stream_changed_event_on_paused_to_stopped(self):
self.audio.prepare_change() self.audio.prepare_change()
self.audio.set_uri(self.uris[0]) self.audio.set_uri(self.uris[0])
self.audio.pause_playback() self.audio.pause_playback()
self.audio.wait_for_state_change() self.audio.wait_for_state_change()
self.listener.clear_events()
self.audio.stop_playback() self.audio.stop_playback()
self.audio.wait_for_state_change().get() self.audio.wait_for_state_change().get()
self.assertEvent('stream_changed', uri=None)
call = mock.call('stream_changed', uri=None) def test_position_changed_on_pause(self):
self.assertIn(call, send_mock.call_args_list)
def test_position_changed_on_pause(self, send_mock):
self.audio.prepare_change() self.audio.prepare_change()
self.audio.set_uri(self.uris[0]) self.audio.set_uri(self.uris[0])
self.audio.pause_playback() self.audio.pause_playback()
self.audio.wait_for_state_change() self.audio.wait_for_state_change()
self.audio.wait_for_state_change().get() self.audio.wait_for_state_change().get()
self.assertEvent('position_changed', position=0)
call = mock.call('position_changed', position=0) def test_stream_changed_event_on_paused_to_playing(self):
self.assertIn(call, send_mock.call_args_list) self.audio.prepare_change()
self.audio.set_uri(self.uris[0])
self.listener.clear_events()
self.audio.pause_playback()
def test_position_changed_on_play(self, send_mock): self.audio.wait_for_state_change().get()
self.assertEvent('stream_changed', uri=self.uris[0])
self.listener.clear_events()
self.audio.start_playback()
self.audio.wait_for_state_change().get()
self.assertNotEvent('stream_changed', uri=self.uris[0])
def test_position_changed_on_play(self):
self.audio.prepare_change() self.audio.prepare_change()
self.audio.set_uri(self.uris[0]) self.audio.set_uri(self.uris[0])
self.audio.start_playback() self.audio.start_playback()
self.audio.wait_for_state_change() self.audio.wait_for_state_change()
self.audio.wait_for_state_change().get() self.audio.wait_for_state_change().get()
self.assertEvent('position_changed', position=0)
call = mock.call('position_changed', position=0) def test_position_changed_on_seek_while_stopped(self):
self.assertIn(call, send_mock.call_args_list)
def test_position_changed_on_seek(self, send_mock):
self.audio.prepare_change() self.audio.prepare_change()
self.audio.set_uri(self.uris[0]) self.audio.set_uri(self.uris[0])
self.audio.set_position(2000) self.audio.set_position(2000)
self.audio.wait_for_state_change().get() self.audio.wait_for_state_change().get()
self.assertNotEvent('position_changed', position=0)
call = mock.call('position_changed', position=0) def test_position_changed_on_seek_after_play(self):
self.assertNotIn(call, send_mock.call_args_list)
def test_position_changed_on_seek_after_play(self, send_mock):
self.audio.prepare_change() self.audio.prepare_change()
self.audio.set_uri(self.uris[0]) self.audio.set_uri(self.uris[0])
self.audio.start_playback() self.audio.start_playback()
self.audio.wait_for_state_change() self.audio.wait_for_state_change()
self.listener.clear_events()
self.audio.set_position(2000) self.audio.set_position(2000)
self.audio.wait_for_state_change().get() self.audio.wait_for_state_change().get()
self.assertEvent('position_changed', position=2000)
call = mock.call('position_changed', position=2000) def test_position_changed_on_seek_after_pause(self):
self.assertIn(call, send_mock.call_args_list)
def test_position_changed_on_seek_after_pause(self, send_mock):
self.audio.prepare_change() self.audio.prepare_change()
self.audio.set_uri(self.uris[0]) self.audio.set_uri(self.uris[0])
self.audio.pause_playback() self.audio.pause_playback()
self.audio.wait_for_state_change() self.audio.wait_for_state_change()
self.listener.clear_events()
self.audio.set_position(2000) self.audio.set_position(2000)
self.audio.wait_for_state_change().get() self.audio.wait_for_state_change().get()
self.assertEvent('position_changed', position=2000)
call = mock.call('position_changed', position=2000) def test_tags_changed_on_playback(self):
self.assertIn(call, send_mock.call_args_list)
def test_tags_changed_on_playback(self, send_mock):
self.audio.prepare_change() self.audio.prepare_change()
self.audio.set_uri(self.uris[0]) self.audio.set_uri(self.uris[0])
self.audio.start_playback() self.audio.start_playback()
self.audio.wait_for_state_change().get() self.audio.wait_for_state_change().get()
send_mock.assert_any_call('tags_changed', tags=mock.ANY) self.assertEvent('tags_changed', tags=mock.ANY)
# Unlike the other events, having the state changed done is not # Unlike the other events, having the state changed done is not
# enough to ensure our event is called. So we setup a threading # enough to ensure our event is called. So we setup a threading
# event that we can wait for with a timeout while the track playback # event that we can wait for with a timeout while the track playback
# completes. # completes.
def test_stream_changed_event_on_paused(self, send_mock): def test_stream_changed_event_on_paused(self):
event = threading.Event() event = self.listener.wait('stream_changed').get()
def send(name, **kwargs):
if name == 'stream_changed':
event.set()
send_mock.side_effect = send
self.audio.prepare_change() self.audio.prepare_change()
self.audio.set_uri(self.uris[0]) self.audio.set_uri(self.uris[0])
@ -322,13 +385,10 @@ class AudioEventTest(BaseTest):
if not event.wait(timeout=1.0): if not event.wait(timeout=1.0):
self.fail('Stream changed not reached within deadline') self.fail('Stream changed not reached within deadline')
def test_reached_end_of_stream_event(self, send_mock): self.assertEvent('stream_changed', uri=self.uris[0])
event = threading.Event()
def send(name, **kwargs): def test_reached_end_of_stream_event(self):
if name == 'reached_end_of_stream': event = self.listener.wait('reached_end_of_stream').get()
event.set()
send_mock.side_effect = send
self.audio.prepare_change() self.audio.prepare_change()
self.audio.set_uri(self.uris[0]) self.audio.set_uri(self.uris[0])
@ -341,21 +401,14 @@ class AudioEventTest(BaseTest):
self.assertFalse(self.audio.get_current_tags().get()) self.assertFalse(self.audio.get_current_tags().get())
def test_gapless(self, send_mock): def test_gapless(self):
uris = self.uris[1:] uris = self.uris[1:]
events = [] event = self.listener.wait('reached_end_of_stream').get()
done = threading.Event()
def callback(): def callback():
if uris: if uris:
self.audio.set_uri(uris.pop()).get() self.audio.set_uri(uris.pop()).get()
def send(name, **kwargs):
events.append((name, kwargs))
if name == 'reached_end_of_stream':
done.set()
send_mock.side_effect = send
self.audio.set_about_to_finish_callback(callback).get() self.audio.set_about_to_finish_callback(callback).get()
self.audio.prepare_change() self.audio.prepare_change()
@ -367,15 +420,15 @@ class AudioEventTest(BaseTest):
self.possibly_trigger_fake_about_to_finish() self.possibly_trigger_fake_about_to_finish()
self.audio.wait_for_state_change().get() self.audio.wait_for_state_change().get()
if not done.wait(timeout=1.0): if not event.wait(timeout=1.0):
self.fail('EOS not received') self.fail('EOS not received')
# Check that both uris got played # Check that both uris got played
self.assertIn(('stream_changed', {'uri': self.uris[0]}), events) self.assertEvent('stream_changed', uri=self.uris[0])
self.assertIn(('stream_changed', {'uri': self.uris[1]}), events) self.assertEvent('stream_changed', uri=self.uris[1])
# Check that events counts check out. # Check that events counts check out.
keys = [k for k, v in events] keys = [k for k, v in self.listener.get_events().get()]
self.assertEqual(2, keys.count('stream_changed')) self.assertEqual(2, keys.count('stream_changed'))
self.assertEqual(2, keys.count('position_changed')) self.assertEqual(2, keys.count('position_changed'))
self.assertEqual(1, keys.count('state_changed')) self.assertEqual(1, keys.count('state_changed'))
@ -383,17 +436,12 @@ class AudioEventTest(BaseTest):
# TODO: test tag states within gaples # TODO: test tag states within gaples
def test_current_tags_are_blank_to_begin_with(self, send_mock): # TODO: this does not belong in this testcase
def test_current_tags_are_blank_to_begin_with(self):
self.assertFalse(self.audio.get_current_tags().get()) self.assertFalse(self.audio.get_current_tags().get())
def test_current_tags_blank_after_end_of_stream(self, send_mock): def test_current_tags_blank_after_end_of_stream(self):
done = threading.Event() event = self.listener.wait('reached_end_of_stream').get()
def send(name, **kwargs):
if name == 'reached_end_of_stream':
done.set()
send_mock.side_effect = send
self.audio.prepare_change() self.audio.prepare_change()
self.audio.set_uri(self.uris[0]) self.audio.set_uri(self.uris[0])
@ -402,23 +450,18 @@ class AudioEventTest(BaseTest):
self.possibly_trigger_fake_about_to_finish() self.possibly_trigger_fake_about_to_finish()
self.audio.wait_for_state_change().get() self.audio.wait_for_state_change().get()
if not done.wait(timeout=1.0): if not event.wait(timeout=1.0):
self.fail('EOS not received') self.fail('EOS not received')
self.assertFalse(self.audio.get_current_tags().get()) self.assertFalse(self.audio.get_current_tags().get())
def test_current_tags_stored(self, send_mock): def test_current_tags_stored(self):
done = threading.Event() event = self.listener.wait('reached_end_of_stream').get()
tags = [] tags = []
def callback(): def callback():
tags.append(self.audio.get_current_tags().get()) tags.append(self.audio.get_current_tags().get())
def send(name, **kwargs):
if name == 'reached_end_of_stream':
done.set()
send_mock.side_effect = send
self.audio.set_about_to_finish_callback(callback).get() self.audio.set_about_to_finish_callback(callback).get()
self.audio.prepare_change() self.audio.prepare_change()
@ -428,7 +471,7 @@ class AudioEventTest(BaseTest):
self.possibly_trigger_fake_about_to_finish() self.possibly_trigger_fake_about_to_finish()
self.audio.wait_for_state_change().get() self.audio.wait_for_state_change().get()
if not done.wait(timeout=1.0): if not event.wait(timeout=1.0):
self.fail('EOS not received') self.fail('EOS not received')
self.assertTrue(tags[0]) self.assertTrue(tags[0])
@ -473,17 +516,17 @@ class AudioStateTest(unittest.TestCase):
def test_state_does_not_change_when_in_gst_ready_state(self): def test_state_does_not_change_when_in_gst_ready_state(self):
self.audio._handler.on_playbin_state_changed( self.audio._handler.on_playbin_state_changed(
gst.STATE_NULL, gst.STATE_READY, gst.STATE_VOID_PENDING) Gst.State.NULL, Gst.State.READY, Gst.State.VOID_PENDING)
self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state) self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state)
def test_state_changes_from_stopped_to_playing_on_play(self): def test_state_changes_from_stopped_to_playing_on_play(self):
self.audio._handler.on_playbin_state_changed( self.audio._handler.on_playbin_state_changed(
gst.STATE_NULL, gst.STATE_READY, gst.STATE_PLAYING) Gst.State.NULL, Gst.State.READY, Gst.State.PLAYING)
self.audio._handler.on_playbin_state_changed( self.audio._handler.on_playbin_state_changed(
gst.STATE_READY, gst.STATE_PAUSED, gst.STATE_PLAYING) Gst.State.READY, Gst.State.PAUSED, Gst.State.PLAYING)
self.audio._handler.on_playbin_state_changed( self.audio._handler.on_playbin_state_changed(
gst.STATE_PAUSED, gst.STATE_PLAYING, gst.STATE_VOID_PENDING) Gst.State.PAUSED, Gst.State.PLAYING, Gst.State.VOID_PENDING)
self.assertEqual(audio.PlaybackState.PLAYING, self.audio.state) self.assertEqual(audio.PlaybackState.PLAYING, self.audio.state)
@ -491,7 +534,7 @@ class AudioStateTest(unittest.TestCase):
self.audio.state = audio.PlaybackState.PLAYING self.audio.state = audio.PlaybackState.PLAYING
self.audio._handler.on_playbin_state_changed( self.audio._handler.on_playbin_state_changed(
gst.STATE_PLAYING, gst.STATE_PAUSED, gst.STATE_VOID_PENDING) Gst.State.PLAYING, Gst.State.PAUSED, Gst.State.VOID_PENDING)
self.assertEqual(audio.PlaybackState.PAUSED, self.audio.state) self.assertEqual(audio.PlaybackState.PAUSED, self.audio.state)
@ -499,12 +542,12 @@ class AudioStateTest(unittest.TestCase):
self.audio.state = audio.PlaybackState.PLAYING self.audio.state = audio.PlaybackState.PLAYING
self.audio._handler.on_playbin_state_changed( self.audio._handler.on_playbin_state_changed(
gst.STATE_PLAYING, gst.STATE_PAUSED, gst.STATE_NULL) Gst.State.PLAYING, Gst.State.PAUSED, Gst.State.NULL)
self.audio._handler.on_playbin_state_changed( self.audio._handler.on_playbin_state_changed(
gst.STATE_PAUSED, gst.STATE_READY, gst.STATE_NULL) Gst.State.PAUSED, Gst.State.READY, Gst.State.NULL)
# We never get the following call, so the logic must work without it # We never get the following call, so the logic must work without it
# self.audio._handler.on_playbin_state_changed( # self.audio._handler.on_playbin_state_changed(
# gst.STATE_READY, gst.STATE_NULL, gst.STATE_VOID_PENDING) # Gst.State.READY, Gst.State.NULL, Gst.State.VOID_PENDING)
self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state) self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state)
@ -518,17 +561,17 @@ class AudioBufferingTest(unittest.TestCase):
def test_pause_when_buffer_empty(self): def test_pause_when_buffer_empty(self):
playbin = self.audio._playbin playbin = self.audio._playbin
self.audio.start_playback() self.audio.start_playback()
playbin.set_state.assert_called_with(gst.STATE_PLAYING) playbin.set_state.assert_called_with(Gst.State.PLAYING)
playbin.set_state.reset_mock() playbin.set_state.reset_mock()
self.audio._handler.on_buffering(0) self.audio._handler.on_buffering(0)
playbin.set_state.assert_called_with(gst.STATE_PAUSED) playbin.set_state.assert_called_with(Gst.State.PAUSED)
self.assertTrue(self.audio._buffering) self.assertTrue(self.audio._buffering)
def test_stay_paused_when_buffering_finished(self): def test_stay_paused_when_buffering_finished(self):
playbin = self.audio._playbin playbin = self.audio._playbin
self.audio.pause_playback() self.audio.pause_playback()
playbin.set_state.assert_called_with(gst.STATE_PAUSED) playbin.set_state.assert_called_with(Gst.State.PAUSED)
playbin.set_state.reset_mock() playbin.set_state.reset_mock()
self.audio._handler.on_buffering(100) self.audio._handler.on_buffering(100)
@ -538,11 +581,11 @@ class AudioBufferingTest(unittest.TestCase):
def test_change_to_paused_while_buffering(self): def test_change_to_paused_while_buffering(self):
playbin = self.audio._playbin playbin = self.audio._playbin
self.audio.start_playback() self.audio.start_playback()
playbin.set_state.assert_called_with(gst.STATE_PLAYING) playbin.set_state.assert_called_with(Gst.State.PLAYING)
playbin.set_state.reset_mock() playbin.set_state.reset_mock()
self.audio._handler.on_buffering(0) self.audio._handler.on_buffering(0)
playbin.set_state.assert_called_with(gst.STATE_PAUSED) playbin.set_state.assert_called_with(Gst.State.PAUSED)
self.audio.pause_playback() self.audio.pause_playback()
playbin.set_state.reset_mock() playbin.set_state.reset_mock()
@ -553,13 +596,13 @@ class AudioBufferingTest(unittest.TestCase):
def test_change_to_stopped_while_buffering(self): def test_change_to_stopped_while_buffering(self):
playbin = self.audio._playbin playbin = self.audio._playbin
self.audio.start_playback() self.audio.start_playback()
playbin.set_state.assert_called_with(gst.STATE_PLAYING) playbin.set_state.assert_called_with(Gst.State.PLAYING)
playbin.set_state.reset_mock() playbin.set_state.reset_mock()
self.audio._handler.on_buffering(0) self.audio._handler.on_buffering(0)
playbin.set_state.assert_called_with(gst.STATE_PAUSED) playbin.set_state.assert_called_with(Gst.State.PAUSED)
playbin.set_state.reset_mock() playbin.set_state.reset_mock()
self.audio.stop_playback() self.audio.stop_playback()
playbin.set_state.assert_called_with(gst.STATE_NULL) playbin.set_state.assert_called_with(Gst.State.NULL)
self.assertFalse(self.audio._buffering) self.assertFalse(self.audio._buffering)

View File

@ -3,9 +3,6 @@ from __future__ import absolute_import, unicode_literals
import os import os
import unittest import unittest
import gobject
gobject.threads_init()
from mopidy import exceptions from mopidy import exceptions
from mopidy.audio import scan from mopidy.audio import scan
from mopidy.internal import path as path_lib from mopidy.internal import path as path_lib

333
tests/audio/test_tags.py Normal file
View File

@ -0,0 +1,333 @@
# encoding: utf-8
from __future__ import absolute_import, unicode_literals
import unittest
from mopidy import compat
from mopidy.audio import tags
from mopidy.internal.gi import GLib, GObject, Gst
from mopidy.models import Album, Artist, Track
class TestConvertTaglist(object):
def make_taglist(self, tag, values):
taglist = Gst.TagList.new_empty()
for value in values:
if isinstance(value, (GLib.Date, Gst.DateTime)):
taglist.add_value(Gst.TagMergeMode.APPEND, tag, value)
continue
gobject_value = GObject.Value()
if isinstance(value, bytes):
gobject_value.init(GObject.TYPE_STRING)
gobject_value.set_string(value)
elif isinstance(value, int):
gobject_value.init(GObject.TYPE_UINT)
gobject_value.set_uint(value)
gobject_value.init(GObject.TYPE_VALUE)
gobject_value.set_value(value)
else:
raise TypeError
taglist.add_value(Gst.TagMergeMode.APPEND, tag, gobject_value)
return taglist
def test_date_tag(self):
date = GLib.Date.new_dmy(7, 1, 2014)
taglist = self.make_taglist(Gst.TAG_DATE, [date])
result = tags.convert_taglist(taglist)
assert isinstance(result[Gst.TAG_DATE][0], compat.text_type)
assert result[Gst.TAG_DATE][0] == '2014-01-07'
def test_date_time_tag(self):
taglist = self.make_taglist(Gst.TAG_DATE_TIME, [
Gst.DateTime.new_from_iso8601_string(b'2014-01-07 14:13:12')
])
result = tags.convert_taglist(taglist)
assert isinstance(result[Gst.TAG_DATE_TIME][0], compat.text_type)
assert result[Gst.TAG_DATE_TIME][0] == '2014-01-07T14:13:12Z'
def test_string_tag(self):
taglist = self.make_taglist(Gst.TAG_ARTIST, [b'ABBA', b'ACDC'])
result = tags.convert_taglist(taglist)
assert isinstance(result[Gst.TAG_ARTIST][0], compat.text_type)
assert result[Gst.TAG_ARTIST][0] == 'ABBA'
assert isinstance(result[Gst.TAG_ARTIST][1], compat.text_type)
assert result[Gst.TAG_ARTIST][1] == 'ACDC'
def test_integer_tag(self):
taglist = self.make_taglist(Gst.TAG_BITRATE, [17])
result = tags.convert_taglist(taglist)
assert result[Gst.TAG_BITRATE][0] == 17
# TODO: keep ids without name?
# TODO: current test is trying to test everything at once with a complete tags
# set, instead we might want to try with a minimal one making testing easier.
class TagsToTrackTest(unittest.TestCase):
def setUp(self): # noqa: N802
self.tags = {
'album': ['album'],
'track-number': [1],
'artist': ['artist'],
'composer': ['composer'],
'performer': ['performer'],
'album-artist': ['albumartist'],
'title': ['track'],
'track-count': [2],
'album-disc-number': [2],
'album-disc-count': [3],
'date': ['2006-01-01'],
'container-format': ['ID3 tag'],
'genre': ['genre'],
'comment': ['comment'],
'musicbrainz-trackid': ['trackid'],
'musicbrainz-albumid': ['albumid'],
'musicbrainz-artistid': ['artistid'],
'musicbrainz-sortname': ['sortname'],
'musicbrainz-albumartistid': ['albumartistid'],
'bitrate': [1000],
}
artist = Artist(name='artist', musicbrainz_id='artistid',
sortname='sortname')
composer = Artist(name='composer')
performer = Artist(name='performer')
albumartist = Artist(name='albumartist',
musicbrainz_id='albumartistid')
album = Album(name='album', date='2006-01-01',
num_tracks=2, num_discs=3,
musicbrainz_id='albumid', artists=[albumartist])
self.track = Track(name='track',
genre='genre', track_no=1, disc_no=2,
comment='comment', musicbrainz_id='trackid',
album=album, bitrate=1000, artists=[artist],
composers=[composer], performers=[performer])
def check(self, expected):
actual = tags.convert_tags_to_track(self.tags)
self.assertEqual(expected, actual)
def test_track(self):
self.check(self.track)
def test_missing_track_no(self):
del self.tags['track-number']
self.check(self.track.replace(track_no=None))
def test_multiple_track_no(self):
self.tags['track-number'].append(9)
self.check(self.track)
def test_missing_track_disc_no(self):
del self.tags['album-disc-number']
self.check(self.track.replace(disc_no=None))
def test_multiple_track_disc_no(self):
self.tags['album-disc-number'].append(9)
self.check(self.track)
def test_missing_track_name(self):
del self.tags['title']
self.check(self.track.replace(name=None))
def test_multiple_track_name(self):
self.tags['title'] = ['name1', 'name2']
self.check(self.track.replace(name='name1; name2'))
def test_missing_track_musicbrainz_id(self):
del self.tags['musicbrainz-trackid']
self.check(self.track.replace(musicbrainz_id=None))
def test_multiple_track_musicbrainz_id(self):
self.tags['musicbrainz-trackid'].append('id')
self.check(self.track)
def test_missing_track_bitrate(self):
del self.tags['bitrate']
self.check(self.track.replace(bitrate=None))
def test_multiple_track_bitrate(self):
self.tags['bitrate'].append(1234)
self.check(self.track)
def test_missing_track_genre(self):
del self.tags['genre']
self.check(self.track.replace(genre=None))
def test_multiple_track_genre(self):
self.tags['genre'] = ['genre1', 'genre2']
self.check(self.track.replace(genre='genre1; genre2'))
def test_missing_track_date(self):
del self.tags['date']
self.check(
self.track.replace(album=self.track.album.replace(date=None)))
def test_multiple_track_date(self):
self.tags['date'].append('2030-01-01')
self.check(self.track)
def test_datetime_instead_of_date(self):
del self.tags['date']
self.tags['datetime'] = ['2006-01-01T14:13:12Z']
self.check(self.track)
def test_missing_track_comment(self):
del self.tags['comment']
self.check(self.track.replace(comment=None))
def test_multiple_track_comment(self):
self.tags['comment'] = ['comment1', 'comment2']
self.check(self.track.replace(comment='comment1; comment2'))
def test_missing_track_artist_name(self):
del self.tags['artist']
self.check(self.track.replace(artists=[]))
def test_multiple_track_artist_name(self):
self.tags['artist'] = ['name1', 'name2']
artists = [Artist(name='name1'), Artist(name='name2')]
self.check(self.track.replace(artists=artists))
def test_missing_track_artist_musicbrainz_id(self):
del self.tags['musicbrainz-artistid']
artist = list(self.track.artists)[0].replace(musicbrainz_id=None)
self.check(self.track.replace(artists=[artist]))
def test_multiple_track_artist_musicbrainz_id(self):
self.tags['musicbrainz-artistid'].append('id')
self.check(self.track)
def test_missing_track_composer_name(self):
del self.tags['composer']
self.check(self.track.replace(composers=[]))
def test_multiple_track_composer_name(self):
self.tags['composer'] = ['composer1', 'composer2']
composers = [Artist(name='composer1'), Artist(name='composer2')]
self.check(self.track.replace(composers=composers))
def test_missing_track_performer_name(self):
del self.tags['performer']
self.check(self.track.replace(performers=[]))
def test_multiple_track_performe_name(self):
self.tags['performer'] = ['performer1', 'performer2']
performers = [Artist(name='performer1'), Artist(name='performer2')]
self.check(self.track.replace(performers=performers))
def test_missing_album_name(self):
del self.tags['album']
self.check(self.track.replace(album=None))
def test_multiple_album_name(self):
self.tags['album'].append('album2')
self.check(self.track)
def test_missing_album_musicbrainz_id(self):
del self.tags['musicbrainz-albumid']
album = self.track.album.replace(musicbrainz_id=None,
images=[])
self.check(self.track.replace(album=album))
def test_multiple_album_musicbrainz_id(self):
self.tags['musicbrainz-albumid'].append('id')
self.check(self.track)
def test_missing_album_num_tracks(self):
del self.tags['track-count']
album = self.track.album.replace(num_tracks=None)
self.check(self.track.replace(album=album))
def test_multiple_album_num_tracks(self):
self.tags['track-count'].append(9)
self.check(self.track)
def test_missing_album_num_discs(self):
del self.tags['album-disc-count']
album = self.track.album.replace(num_discs=None)
self.check(self.track.replace(album=album))
def test_multiple_album_num_discs(self):
self.tags['album-disc-count'].append(9)
self.check(self.track)
def test_missing_album_artist_name(self):
del self.tags['album-artist']
album = self.track.album.replace(artists=[])
self.check(self.track.replace(album=album))
def test_multiple_album_artist_name(self):
self.tags['album-artist'] = ['name1', 'name2']
artists = [Artist(name='name1'), Artist(name='name2')]
album = self.track.album.replace(artists=artists)
self.check(self.track.replace(album=album))
def test_missing_album_artist_musicbrainz_id(self):
del self.tags['musicbrainz-albumartistid']
albumartist = list(self.track.album.artists)[0]
albumartist = albumartist.replace(musicbrainz_id=None)
album = self.track.album.replace(artists=[albumartist])
self.check(self.track.replace(album=album))
def test_multiple_album_artist_musicbrainz_id(self):
self.tags['musicbrainz-albumartistid'].append('id')
self.check(self.track)
def test_stream_organization_track_name(self):
del self.tags['title']
self.tags['organization'] = ['organization']
self.check(self.track.replace(name='organization'))
def test_multiple_organization_track_name(self):
del self.tags['title']
self.tags['organization'] = ['organization1', 'organization2']
self.check(self.track.replace(name='organization1; organization2'))
# TODO: combine all comment types?
def test_stream_location_track_comment(self):
del self.tags['comment']
self.tags['location'] = ['location']
self.check(self.track.replace(comment='location'))
def test_multiple_location_track_comment(self):
del self.tags['comment']
self.tags['location'] = ['location1', 'location2']
self.check(self.track.replace(comment='location1; location2'))
def test_stream_copyright_track_comment(self):
del self.tags['comment']
self.tags['copyright'] = ['copyright']
self.check(self.track.replace(comment='copyright'))
def test_multiple_copyright_track_comment(self):
del self.tags['comment']
self.tags['copyright'] = ['copyright1', 'copyright2']
self.check(self.track.replace(comment='copyright1; copyright2'))
def test_sortname(self):
self.tags['musicbrainz-sortname'] = ['another_sortname']
artist = Artist(name='artist', sortname='another_sortname',
musicbrainz_id='artistid')
self.check(self.track.replace(artists=[artist]))
def test_missing_sortname(self):
del self.tags['musicbrainz-sortname']
artist = Artist(name='artist', sortname=None,
musicbrainz_id='artistid')
self.check(self.track.replace(artists=[artist]))

View File

@ -1,261 +1,23 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
import datetime import pytest
import unittest
from mopidy.audio import utils from mopidy.audio import utils
from mopidy.models import Album, Artist, Track from mopidy.internal.gi import Gst
# TODO: keep ids without name? class TestCreateBuffer(object):
# TODO: current test is trying to test everything at once with a complete tags
# set, instead we might want to try with a minimal one making testing easier.
class TagsToTrackTest(unittest.TestCase):
def setUp(self): # noqa: N802 def test_creates_buffer(self):
self.tags = { buf = utils.create_buffer(b'123', timestamp=0, duration=1000000)
'album': ['album'],
'track-number': [1],
'artist': ['artist'],
'composer': ['composer'],
'performer': ['performer'],
'album-artist': ['albumartist'],
'title': ['track'],
'track-count': [2],
'album-disc-number': [2],
'album-disc-count': [3],
'date': [datetime.date(2006, 1, 1,)],
'container-format': ['ID3 tag'],
'genre': ['genre'],
'comment': ['comment'],
'musicbrainz-trackid': ['trackid'],
'musicbrainz-albumid': ['albumid'],
'musicbrainz-artistid': ['artistid'],
'musicbrainz-sortname': ['sortname'],
'musicbrainz-albumartistid': ['albumartistid'],
'bitrate': [1000],
}
artist = Artist(name='artist', musicbrainz_id='artistid', assert isinstance(buf, Gst.Buffer)
sortname='sortname') assert buf.pts == 0
composer = Artist(name='composer') assert buf.duration == 1000000
performer = Artist(name='performer') assert buf.get_size() == len(b'123')
albumartist = Artist(name='albumartist',
musicbrainz_id='albumartistid')
album = Album(name='album', num_tracks=2, num_discs=3, def test_fails_if_data_has_zero_length(self):
musicbrainz_id='albumid', artists=[albumartist]) with pytest.raises(ValueError) as excinfo:
utils.create_buffer(b'', timestamp=0, duration=1000000)
self.track = Track(name='track', date='2006-01-01', assert 'Cannot create buffer without data' in str(excinfo.value)
genre='genre', track_no=1, disc_no=2,
comment='comment', musicbrainz_id='trackid',
album=album, bitrate=1000, artists=[artist],
composers=[composer], performers=[performer])
def check(self, expected):
actual = utils.convert_tags_to_track(self.tags)
self.assertEqual(expected, actual)
def test_track(self):
self.check(self.track)
def test_missing_track_no(self):
del self.tags['track-number']
self.check(self.track.replace(track_no=None))
def test_multiple_track_no(self):
self.tags['track-number'].append(9)
self.check(self.track)
def test_missing_track_disc_no(self):
del self.tags['album-disc-number']
self.check(self.track.replace(disc_no=None))
def test_multiple_track_disc_no(self):
self.tags['album-disc-number'].append(9)
self.check(self.track)
def test_missing_track_name(self):
del self.tags['title']
self.check(self.track.replace(name=None))
def test_multiple_track_name(self):
self.tags['title'] = ['name1', 'name2']
self.check(self.track.replace(name='name1; name2'))
def test_missing_track_musicbrainz_id(self):
del self.tags['musicbrainz-trackid']
self.check(self.track.replace(musicbrainz_id=None))
def test_multiple_track_musicbrainz_id(self):
self.tags['musicbrainz-trackid'].append('id')
self.check(self.track)
def test_missing_track_bitrate(self):
del self.tags['bitrate']
self.check(self.track.replace(bitrate=None))
def test_multiple_track_bitrate(self):
self.tags['bitrate'].append(1234)
self.check(self.track)
def test_missing_track_genre(self):
del self.tags['genre']
self.check(self.track.replace(genre=None))
def test_multiple_track_genre(self):
self.tags['genre'] = ['genre1', 'genre2']
self.check(self.track.replace(genre='genre1; genre2'))
def test_missing_track_date(self):
del self.tags['date']
self.check(self.track.replace(date=None))
def test_multiple_track_date(self):
self.tags['date'].append(datetime.date(2030, 1, 1))
self.check(self.track)
def test_missing_track_comment(self):
del self.tags['comment']
self.check(self.track.replace(comment=None))
def test_multiple_track_comment(self):
self.tags['comment'] = ['comment1', 'comment2']
self.check(self.track.replace(comment='comment1; comment2'))
def test_missing_track_artist_name(self):
del self.tags['artist']
self.check(self.track.replace(artists=[]))
def test_multiple_track_artist_name(self):
self.tags['artist'] = ['name1', 'name2']
artists = [Artist(name='name1'), Artist(name='name2')]
self.check(self.track.replace(artists=artists))
def test_missing_track_artist_musicbrainz_id(self):
del self.tags['musicbrainz-artistid']
artist = list(self.track.artists)[0].replace(musicbrainz_id=None)
self.check(self.track.replace(artists=[artist]))
def test_multiple_track_artist_musicbrainz_id(self):
self.tags['musicbrainz-artistid'].append('id')
self.check(self.track)
def test_missing_track_composer_name(self):
del self.tags['composer']
self.check(self.track.replace(composers=[]))
def test_multiple_track_composer_name(self):
self.tags['composer'] = ['composer1', 'composer2']
composers = [Artist(name='composer1'), Artist(name='composer2')]
self.check(self.track.replace(composers=composers))
def test_missing_track_performer_name(self):
del self.tags['performer']
self.check(self.track.replace(performers=[]))
def test_multiple_track_performe_name(self):
self.tags['performer'] = ['performer1', 'performer2']
performers = [Artist(name='performer1'), Artist(name='performer2')]
self.check(self.track.replace(performers=performers))
def test_missing_album_name(self):
del self.tags['album']
self.check(self.track.replace(album=None))
def test_multiple_album_name(self):
self.tags['album'].append('album2')
self.check(self.track)
def test_missing_album_musicbrainz_id(self):
del self.tags['musicbrainz-albumid']
album = self.track.album.replace(musicbrainz_id=None,
images=[])
self.check(self.track.replace(album=album))
def test_multiple_album_musicbrainz_id(self):
self.tags['musicbrainz-albumid'].append('id')
self.check(self.track)
def test_missing_album_num_tracks(self):
del self.tags['track-count']
album = self.track.album.replace(num_tracks=None)
self.check(self.track.replace(album=album))
def test_multiple_album_num_tracks(self):
self.tags['track-count'].append(9)
self.check(self.track)
def test_missing_album_num_discs(self):
del self.tags['album-disc-count']
album = self.track.album.replace(num_discs=None)
self.check(self.track.replace(album=album))
def test_multiple_album_num_discs(self):
self.tags['album-disc-count'].append(9)
self.check(self.track)
def test_missing_album_artist_name(self):
del self.tags['album-artist']
album = self.track.album.replace(artists=[])
self.check(self.track.replace(album=album))
def test_multiple_album_artist_name(self):
self.tags['album-artist'] = ['name1', 'name2']
artists = [Artist(name='name1'), Artist(name='name2')]
album = self.track.album.replace(artists=artists)
self.check(self.track.replace(album=album))
def test_missing_album_artist_musicbrainz_id(self):
del self.tags['musicbrainz-albumartistid']
albumartist = list(self.track.album.artists)[0]
albumartist = albumartist.replace(musicbrainz_id=None)
album = self.track.album.replace(artists=[albumartist])
self.check(self.track.replace(album=album))
def test_multiple_album_artist_musicbrainz_id(self):
self.tags['musicbrainz-albumartistid'].append('id')
self.check(self.track)
def test_stream_organization_track_name(self):
del self.tags['title']
self.tags['organization'] = ['organization']
self.check(self.track.replace(name='organization'))
def test_multiple_organization_track_name(self):
del self.tags['title']
self.tags['organization'] = ['organization1', 'organization2']
self.check(self.track.replace(name='organization1; organization2'))
# TODO: combine all comment types?
def test_stream_location_track_comment(self):
del self.tags['comment']
self.tags['location'] = ['location']
self.check(self.track.replace(comment='location'))
def test_multiple_location_track_comment(self):
del self.tags['comment']
self.tags['location'] = ['location1', 'location2']
self.check(self.track.replace(comment='location1; location2'))
def test_stream_copyright_track_comment(self):
del self.tags['comment']
self.tags['copyright'] = ['copyright']
self.check(self.track.replace(comment='copyright'))
def test_multiple_copyright_track_comment(self):
del self.tags['comment']
self.tags['copyright'] = ['copyright1', 'copyright2']
self.check(self.track.replace(comment='copyright1; copyright2'))
def test_sortname(self):
self.tags['musicbrainz-sortname'] = ['another_sortname']
artist = Artist(name='artist', sortname='another_sortname',
musicbrainz_id='artistid')
self.check(self.track.replace(artists=[artist]))
def test_missing_sortname(self):
del self.tags['musicbrainz-sortname']
artist = Artist(name='artist', sortname=None,
musicbrainz_id='artistid')
self.check(self.track.replace(artists=[artist]))

View File

@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals
import unittest import unittest
from mopidy import compat
from mopidy.core import HistoryController from mopidy.core import HistoryController
from mopidy.models import Artist, Track from mopidy.models import Artist, Track
@ -40,7 +41,7 @@ class PlaybackHistoryTest(unittest.TestCase):
result = self.history.get_history() result = self.history.get_history()
(timestamp, ref) = result[0] (timestamp, ref) = result[0]
self.assertIsInstance(timestamp, (int, long)) self.assertIsInstance(timestamp, compat.integer_types)
self.assertEqual(track.uri, ref.uri) self.assertEqual(track.uri, ref.uri)
self.assertIn(track.name, ref.name) self.assertIn(track.name, ref.name)
for artist in track.artists: for artist in track.artists:

File diff suppressed because it is too large Load Diff

View File

@ -248,6 +248,10 @@ class PlaylistTest(BasePlaylistsTest):
self.assertFalse(self.sp1.save.called) self.assertFalse(self.sp1.save.called)
self.assertFalse(self.sp2.save.called) self.assertFalse(self.sp2.save.called)
def test_get_uri_schemes(self):
result = self.core.playlists.get_uri_schemes()
self.assertEquals(result, ['dummy1', 'dummy2'])
class DeprecatedFilterPlaylistsTest(BasePlaylistsTest): class DeprecatedFilterPlaylistsTest(BasePlaylistsTest):

View File

@ -1,5 +1,5 @@
#EXTM3U #EXTM3U
# test # test
#EXTINF:-1,song1 #EXTINF:-1,Song #1
# test # test
song1.mp3 song1.mp3

View File

@ -1,3 +1,3 @@
#EXTM3U #EXTM3U
#EXTINF:-1,song1 #EXTINF:-1,Song #1
song1.mp3 song1.mp3

View File

@ -1,5 +1,5 @@
#EXTM3U #EXTM3U
#EXTINF:-1,song1 #EXTINF:-1,Song #1
song1.mp3 song1.mp3
#EXTINF:60,song2 #EXTINF:60,Song #2
song2.mp3 song2.mp3

View File

@ -15,6 +15,7 @@ def create_proxy(config=None, mixer=None):
return DummyAudio.start(config, mixer).proxy() return DummyAudio.start(config, mixer).proxy()
# TODO: reset position on track change?
class DummyAudio(pykka.ThreadingActor): class DummyAudio(pykka.ThreadingActor):
def __init__(self, config=None, mixer=None): def __init__(self, config=None, mixer=None):
@ -24,13 +25,15 @@ class DummyAudio(pykka.ThreadingActor):
self._position = 0 self._position = 0
self._callback = None self._callback = None
self._uri = None self._uri = None
self._state_change_result = True self._stream_changed = False
self._tags = {} self._tags = {}
self._bad_uris = set()
def set_uri(self, uri): def set_uri(self, uri):
assert self._uri is None, 'prepare change not called before set' assert self._uri is None, 'prepare change not called before set'
self._tags = {} self._tags = {}
self._uri = uri self._uri = uri
self._stream_changed = True
def set_appsrc(self, *args, **kwargs): def set_appsrc(self, *args, **kwargs):
pass pass
@ -88,12 +91,15 @@ class DummyAudio(pykka.ThreadingActor):
if not self._uri: if not self._uri:
return False return False
if self.state == audio.PlaybackState.STOPPED and self._uri: if new_state == audio.PlaybackState.STOPPED and self._uri:
audio.AudioListener.send('position_changed', position=0) self._stream_changed = True
audio.AudioListener.send('stream_changed', uri=self._uri)
if new_state == audio.PlaybackState.STOPPED:
self._uri = None self._uri = None
if self._uri is not None:
audio.AudioListener.send('position_changed', position=0)
if self._stream_changed:
self._stream_changed = False
audio.AudioListener.send('stream_changed', uri=self._uri) audio.AudioListener.send('stream_changed', uri=self._uri)
old_state, self.state = self.state, new_state old_state, self.state = self.state, new_state
@ -105,10 +111,10 @@ class DummyAudio(pykka.ThreadingActor):
self._tags['audio-codec'] = [u'fake info...'] self._tags['audio-codec'] = [u'fake info...']
audio.AudioListener.send('tags_changed', tags=['audio-codec']) audio.AudioListener.send('tags_changed', tags=['audio-codec'])
return self._state_change_result return self._uri not in self._bad_uris
def trigger_fake_playback_failure(self): def trigger_fake_playback_failure(self, uri):
self._state_change_result = False self._bad_uris.add(uri)
def trigger_fake_tags_changed(self, tags): def trigger_fake_tags_changed(self, tags):
self._tags.update(tags) self._tags.update(tags)

View File

@ -22,7 +22,10 @@ class DummyBackend(pykka.ThreadingActor, backend.Backend):
super(DummyBackend, self).__init__() super(DummyBackend, self).__init__()
self.library = DummyLibraryProvider(backend=self) self.library = DummyLibraryProvider(backend=self)
self.playback = DummyPlaybackProvider(audio=audio, backend=self) if audio:
self.playback = backend.PlaybackProvider(audio=audio, backend=self)
else:
self.playback = DummyPlaybackProvider(audio=audio, backend=self)
self.playlists = DummyPlaylistsProvider(backend=self) self.playlists = DummyPlaylistsProvider(backend=self)
self.uri_schemes = ['dummy'] self.uri_schemes = ['dummy']

View File

@ -5,13 +5,12 @@ import logging
import socket import socket
import unittest import unittest
import gobject
from mock import Mock, call, patch, sentinel from mock import Mock, call, patch, sentinel
import pykka import pykka
from mopidy.internal import network from mopidy.internal import network
from mopidy.internal.gi import GObject
from tests import any_int, any_unicode from tests import any_int, any_unicode
@ -162,27 +161,27 @@ class ConnectionTest(unittest.TestCase):
network.Connection.stop(self.mock, sentinel.reason) network.Connection.stop(self.mock, sentinel.reason)
network.logger.log(any_int, any_unicode) network.logger.log(any_int, any_unicode)
@patch.object(gobject, 'io_add_watch', new=Mock()) @patch.object(GObject, 'io_add_watch', new=Mock())
def test_enable_recv_registers_with_gobject(self): def test_enable_recv_registers_with_gobject(self):
self.mock.recv_id = None self.mock.recv_id = None
self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock = Mock(spec=socket.SocketType)
self.mock.sock.fileno.return_value = sentinel.fileno self.mock.sock.fileno.return_value = sentinel.fileno
gobject.io_add_watch.return_value = sentinel.tag GObject.io_add_watch.return_value = sentinel.tag
network.Connection.enable_recv(self.mock) network.Connection.enable_recv(self.mock)
gobject.io_add_watch.assert_called_once_with( GObject.io_add_watch.assert_called_once_with(
sentinel.fileno, sentinel.fileno,
gobject.IO_IN | gobject.IO_ERR | gobject.IO_HUP, GObject.IO_IN | GObject.IO_ERR | GObject.IO_HUP,
self.mock.recv_callback) self.mock.recv_callback)
self.assertEqual(sentinel.tag, self.mock.recv_id) self.assertEqual(sentinel.tag, self.mock.recv_id)
@patch.object(gobject, 'io_add_watch', new=Mock()) @patch.object(GObject, 'io_add_watch', new=Mock())
def test_enable_recv_already_registered(self): def test_enable_recv_already_registered(self):
self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock = Mock(spec=socket.SocketType)
self.mock.recv_id = sentinel.tag self.mock.recv_id = sentinel.tag
network.Connection.enable_recv(self.mock) network.Connection.enable_recv(self.mock)
self.assertEqual(0, gobject.io_add_watch.call_count) self.assertEqual(0, GObject.io_add_watch.call_count)
def test_enable_recv_does_not_change_tag(self): def test_enable_recv_does_not_change_tag(self):
self.mock.recv_id = sentinel.tag self.mock.recv_id = sentinel.tag
@ -191,20 +190,20 @@ class ConnectionTest(unittest.TestCase):
network.Connection.enable_recv(self.mock) network.Connection.enable_recv(self.mock)
self.assertEqual(sentinel.tag, self.mock.recv_id) self.assertEqual(sentinel.tag, self.mock.recv_id)
@patch.object(gobject, 'source_remove', new=Mock()) @patch.object(GObject, 'source_remove', new=Mock())
def test_disable_recv_deregisters(self): def test_disable_recv_deregisters(self):
self.mock.recv_id = sentinel.tag self.mock.recv_id = sentinel.tag
network.Connection.disable_recv(self.mock) network.Connection.disable_recv(self.mock)
gobject.source_remove.assert_called_once_with(sentinel.tag) GObject.source_remove.assert_called_once_with(sentinel.tag)
self.assertEqual(None, self.mock.recv_id) self.assertEqual(None, self.mock.recv_id)
@patch.object(gobject, 'source_remove', new=Mock()) @patch.object(GObject, 'source_remove', new=Mock())
def test_disable_recv_already_deregistered(self): def test_disable_recv_already_deregistered(self):
self.mock.recv_id = None self.mock.recv_id = None
network.Connection.disable_recv(self.mock) network.Connection.disable_recv(self.mock)
self.assertEqual(0, gobject.source_remove.call_count) self.assertEqual(0, GObject.source_remove.call_count)
self.assertEqual(None, self.mock.recv_id) self.assertEqual(None, self.mock.recv_id)
def test_enable_recv_on_closed_socket(self): def test_enable_recv_on_closed_socket(self):
@ -216,27 +215,27 @@ class ConnectionTest(unittest.TestCase):
self.mock.stop.assert_called_once_with(any_unicode) self.mock.stop.assert_called_once_with(any_unicode)
self.assertEqual(None, self.mock.recv_id) self.assertEqual(None, self.mock.recv_id)
@patch.object(gobject, 'io_add_watch', new=Mock()) @patch.object(GObject, 'io_add_watch', new=Mock())
def test_enable_send_registers_with_gobject(self): def test_enable_send_registers_with_gobject(self):
self.mock.send_id = None self.mock.send_id = None
self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock = Mock(spec=socket.SocketType)
self.mock.sock.fileno.return_value = sentinel.fileno self.mock.sock.fileno.return_value = sentinel.fileno
gobject.io_add_watch.return_value = sentinel.tag GObject.io_add_watch.return_value = sentinel.tag
network.Connection.enable_send(self.mock) network.Connection.enable_send(self.mock)
gobject.io_add_watch.assert_called_once_with( GObject.io_add_watch.assert_called_once_with(
sentinel.fileno, sentinel.fileno,
gobject.IO_OUT | gobject.IO_ERR | gobject.IO_HUP, GObject.IO_OUT | GObject.IO_ERR | GObject.IO_HUP,
self.mock.send_callback) self.mock.send_callback)
self.assertEqual(sentinel.tag, self.mock.send_id) self.assertEqual(sentinel.tag, self.mock.send_id)
@patch.object(gobject, 'io_add_watch', new=Mock()) @patch.object(GObject, 'io_add_watch', new=Mock())
def test_enable_send_already_registered(self): def test_enable_send_already_registered(self):
self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock = Mock(spec=socket.SocketType)
self.mock.send_id = sentinel.tag self.mock.send_id = sentinel.tag
network.Connection.enable_send(self.mock) network.Connection.enable_send(self.mock)
self.assertEqual(0, gobject.io_add_watch.call_count) self.assertEqual(0, GObject.io_add_watch.call_count)
def test_enable_send_does_not_change_tag(self): def test_enable_send_does_not_change_tag(self):
self.mock.send_id = sentinel.tag self.mock.send_id = sentinel.tag
@ -245,20 +244,20 @@ class ConnectionTest(unittest.TestCase):
network.Connection.enable_send(self.mock) network.Connection.enable_send(self.mock)
self.assertEqual(sentinel.tag, self.mock.send_id) self.assertEqual(sentinel.tag, self.mock.send_id)
@patch.object(gobject, 'source_remove', new=Mock()) @patch.object(GObject, 'source_remove', new=Mock())
def test_disable_send_deregisters(self): def test_disable_send_deregisters(self):
self.mock.send_id = sentinel.tag self.mock.send_id = sentinel.tag
network.Connection.disable_send(self.mock) network.Connection.disable_send(self.mock)
gobject.source_remove.assert_called_once_with(sentinel.tag) GObject.source_remove.assert_called_once_with(sentinel.tag)
self.assertEqual(None, self.mock.send_id) self.assertEqual(None, self.mock.send_id)
@patch.object(gobject, 'source_remove', new=Mock()) @patch.object(GObject, 'source_remove', new=Mock())
def test_disable_send_already_deregistered(self): def test_disable_send_already_deregistered(self):
self.mock.send_id = None self.mock.send_id = None
network.Connection.disable_send(self.mock) network.Connection.disable_send(self.mock)
self.assertEqual(0, gobject.source_remove.call_count) self.assertEqual(0, GObject.source_remove.call_count)
self.assertEqual(None, self.mock.send_id) self.assertEqual(None, self.mock.send_id)
def test_enable_send_on_closed_socket(self): def test_enable_send_on_closed_socket(self):
@ -269,36 +268,36 @@ class ConnectionTest(unittest.TestCase):
network.Connection.enable_send(self.mock) network.Connection.enable_send(self.mock)
self.assertEqual(None, self.mock.send_id) self.assertEqual(None, self.mock.send_id)
@patch.object(gobject, 'timeout_add_seconds', new=Mock()) @patch.object(GObject, 'timeout_add_seconds', new=Mock())
def test_enable_timeout_clears_existing_timeouts(self): def test_enable_timeout_clears_existing_timeouts(self):
self.mock.timeout = 10 self.mock.timeout = 10
network.Connection.enable_timeout(self.mock) network.Connection.enable_timeout(self.mock)
self.mock.disable_timeout.assert_called_once_with() self.mock.disable_timeout.assert_called_once_with()
@patch.object(gobject, 'timeout_add_seconds', new=Mock()) @patch.object(GObject, 'timeout_add_seconds', new=Mock())
def test_enable_timeout_add_gobject_timeout(self): def test_enable_timeout_add_gobject_timeout(self):
self.mock.timeout = 10 self.mock.timeout = 10
gobject.timeout_add_seconds.return_value = sentinel.tag GObject.timeout_add_seconds.return_value = sentinel.tag
network.Connection.enable_timeout(self.mock) network.Connection.enable_timeout(self.mock)
gobject.timeout_add_seconds.assert_called_once_with( GObject.timeout_add_seconds.assert_called_once_with(
10, self.mock.timeout_callback) 10, self.mock.timeout_callback)
self.assertEqual(sentinel.tag, self.mock.timeout_id) self.assertEqual(sentinel.tag, self.mock.timeout_id)
@patch.object(gobject, 'timeout_add_seconds', new=Mock()) @patch.object(GObject, 'timeout_add_seconds', new=Mock())
def test_enable_timeout_does_not_add_timeout(self): def test_enable_timeout_does_not_add_timeout(self):
self.mock.timeout = 0 self.mock.timeout = 0
network.Connection.enable_timeout(self.mock) network.Connection.enable_timeout(self.mock)
self.assertEqual(0, gobject.timeout_add_seconds.call_count) self.assertEqual(0, GObject.timeout_add_seconds.call_count)
self.mock.timeout = -1 self.mock.timeout = -1
network.Connection.enable_timeout(self.mock) network.Connection.enable_timeout(self.mock)
self.assertEqual(0, gobject.timeout_add_seconds.call_count) self.assertEqual(0, GObject.timeout_add_seconds.call_count)
self.mock.timeout = None self.mock.timeout = None
network.Connection.enable_timeout(self.mock) network.Connection.enable_timeout(self.mock)
self.assertEqual(0, gobject.timeout_add_seconds.call_count) self.assertEqual(0, GObject.timeout_add_seconds.call_count)
def test_enable_timeout_does_not_call_disable_for_invalid_timeout(self): def test_enable_timeout_does_not_call_disable_for_invalid_timeout(self):
self.mock.timeout = 0 self.mock.timeout = 0
@ -313,20 +312,20 @@ class ConnectionTest(unittest.TestCase):
network.Connection.enable_timeout(self.mock) network.Connection.enable_timeout(self.mock)
self.assertEqual(0, self.mock.disable_timeout.call_count) self.assertEqual(0, self.mock.disable_timeout.call_count)
@patch.object(gobject, 'source_remove', new=Mock()) @patch.object(GObject, 'source_remove', new=Mock())
def test_disable_timeout_deregisters(self): def test_disable_timeout_deregisters(self):
self.mock.timeout_id = sentinel.tag self.mock.timeout_id = sentinel.tag
network.Connection.disable_timeout(self.mock) network.Connection.disable_timeout(self.mock)
gobject.source_remove.assert_called_once_with(sentinel.tag) GObject.source_remove.assert_called_once_with(sentinel.tag)
self.assertEqual(None, self.mock.timeout_id) self.assertEqual(None, self.mock.timeout_id)
@patch.object(gobject, 'source_remove', new=Mock()) @patch.object(GObject, 'source_remove', new=Mock())
def test_disable_timeout_already_deregistered(self): def test_disable_timeout_already_deregistered(self):
self.mock.timeout_id = None self.mock.timeout_id = None
network.Connection.disable_timeout(self.mock) network.Connection.disable_timeout(self.mock)
self.assertEqual(0, gobject.source_remove.call_count) self.assertEqual(0, GObject.source_remove.call_count)
self.assertEqual(None, self.mock.timeout_id) self.assertEqual(None, self.mock.timeout_id)
def test_queue_send_acquires_and_releases_lock(self): def test_queue_send_acquires_and_releases_lock(self):
@ -372,7 +371,7 @@ class ConnectionTest(unittest.TestCase):
self.mock.actor_ref = Mock() self.mock.actor_ref = Mock()
self.assertTrue(network.Connection.recv_callback( self.assertTrue(network.Connection.recv_callback(
self.mock, sentinel.fd, gobject.IO_IN | gobject.IO_ERR)) self.mock, sentinel.fd, GObject.IO_IN | GObject.IO_ERR))
self.mock.stop.assert_called_once_with(any_unicode) self.mock.stop.assert_called_once_with(any_unicode)
def test_recv_callback_respects_io_hup(self): def test_recv_callback_respects_io_hup(self):
@ -380,7 +379,7 @@ class ConnectionTest(unittest.TestCase):
self.mock.actor_ref = Mock() self.mock.actor_ref = Mock()
self.assertTrue(network.Connection.recv_callback( self.assertTrue(network.Connection.recv_callback(
self.mock, sentinel.fd, gobject.IO_IN | gobject.IO_HUP)) self.mock, sentinel.fd, GObject.IO_IN | GObject.IO_HUP))
self.mock.stop.assert_called_once_with(any_unicode) self.mock.stop.assert_called_once_with(any_unicode)
def test_recv_callback_respects_io_hup_and_io_err(self): def test_recv_callback_respects_io_hup_and_io_err(self):
@ -389,7 +388,7 @@ class ConnectionTest(unittest.TestCase):
self.assertTrue(network.Connection.recv_callback( self.assertTrue(network.Connection.recv_callback(
self.mock, sentinel.fd, self.mock, sentinel.fd,
gobject.IO_IN | gobject.IO_HUP | gobject.IO_ERR)) GObject.IO_IN | GObject.IO_HUP | GObject.IO_ERR))
self.mock.stop.assert_called_once_with(any_unicode) self.mock.stop.assert_called_once_with(any_unicode)
def test_recv_callback_sends_data_to_actor(self): def test_recv_callback_sends_data_to_actor(self):
@ -398,7 +397,7 @@ class ConnectionTest(unittest.TestCase):
self.mock.actor_ref = Mock() self.mock.actor_ref = Mock()
self.assertTrue(network.Connection.recv_callback( self.assertTrue(network.Connection.recv_callback(
self.mock, sentinel.fd, gobject.IO_IN)) self.mock, sentinel.fd, GObject.IO_IN))
self.mock.actor_ref.tell.assert_called_once_with( self.mock.actor_ref.tell.assert_called_once_with(
{'received': 'data'}) {'received': 'data'})
@ -409,7 +408,7 @@ class ConnectionTest(unittest.TestCase):
self.mock.actor_ref.tell.side_effect = pykka.ActorDeadError() self.mock.actor_ref.tell.side_effect = pykka.ActorDeadError()
self.assertTrue(network.Connection.recv_callback( self.assertTrue(network.Connection.recv_callback(
self.mock, sentinel.fd, gobject.IO_IN)) self.mock, sentinel.fd, GObject.IO_IN))
self.mock.stop.assert_called_once_with(any_unicode) self.mock.stop.assert_called_once_with(any_unicode)
def test_recv_callback_gets_no_data(self): def test_recv_callback_gets_no_data(self):
@ -418,7 +417,7 @@ class ConnectionTest(unittest.TestCase):
self.mock.actor_ref = Mock() self.mock.actor_ref = Mock()
self.assertTrue(network.Connection.recv_callback( self.assertTrue(network.Connection.recv_callback(
self.mock, sentinel.fd, gobject.IO_IN)) self.mock, sentinel.fd, GObject.IO_IN))
self.assertEqual(self.mock.mock_calls, [ self.assertEqual(self.mock.mock_calls, [
call.sock.recv(any_int), call.sock.recv(any_int),
call.disable_recv(), call.disable_recv(),
@ -431,7 +430,7 @@ class ConnectionTest(unittest.TestCase):
for error in (errno.EWOULDBLOCK, errno.EINTR): for error in (errno.EWOULDBLOCK, errno.EINTR):
self.mock.sock.recv.side_effect = socket.error(error, '') self.mock.sock.recv.side_effect = socket.error(error, '')
self.assertTrue(network.Connection.recv_callback( self.assertTrue(network.Connection.recv_callback(
self.mock, sentinel.fd, gobject.IO_IN)) self.mock, sentinel.fd, GObject.IO_IN))
self.assertEqual(0, self.mock.stop.call_count) self.assertEqual(0, self.mock.stop.call_count)
def test_recv_callback_unrecoverable_error(self): def test_recv_callback_unrecoverable_error(self):
@ -439,7 +438,7 @@ class ConnectionTest(unittest.TestCase):
self.mock.sock.recv.side_effect = socket.error self.mock.sock.recv.side_effect = socket.error
self.assertTrue(network.Connection.recv_callback( self.assertTrue(network.Connection.recv_callback(
self.mock, sentinel.fd, gobject.IO_IN)) self.mock, sentinel.fd, GObject.IO_IN))
self.mock.stop.assert_called_once_with(any_unicode) self.mock.stop.assert_called_once_with(any_unicode)
def test_send_callback_respects_io_err(self): def test_send_callback_respects_io_err(self):
@ -450,7 +449,7 @@ class ConnectionTest(unittest.TestCase):
self.mock.send_buffer = '' self.mock.send_buffer = ''
self.assertTrue(network.Connection.send_callback( self.assertTrue(network.Connection.send_callback(
self.mock, sentinel.fd, gobject.IO_IN | gobject.IO_ERR)) self.mock, sentinel.fd, GObject.IO_IN | GObject.IO_ERR))
self.mock.stop.assert_called_once_with(any_unicode) self.mock.stop.assert_called_once_with(any_unicode)
def test_send_callback_respects_io_hup(self): def test_send_callback_respects_io_hup(self):
@ -461,7 +460,7 @@ class ConnectionTest(unittest.TestCase):
self.mock.send_buffer = '' self.mock.send_buffer = ''
self.assertTrue(network.Connection.send_callback( self.assertTrue(network.Connection.send_callback(
self.mock, sentinel.fd, gobject.IO_IN | gobject.IO_HUP)) self.mock, sentinel.fd, GObject.IO_IN | GObject.IO_HUP))
self.mock.stop.assert_called_once_with(any_unicode) self.mock.stop.assert_called_once_with(any_unicode)
def test_send_callback_respects_io_hup_and_io_err(self): def test_send_callback_respects_io_hup_and_io_err(self):
@ -473,7 +472,7 @@ class ConnectionTest(unittest.TestCase):
self.assertTrue(network.Connection.send_callback( self.assertTrue(network.Connection.send_callback(
self.mock, sentinel.fd, self.mock, sentinel.fd,
gobject.IO_IN | gobject.IO_HUP | gobject.IO_ERR)) GObject.IO_IN | GObject.IO_HUP | GObject.IO_ERR))
self.mock.stop.assert_called_once_with(any_unicode) self.mock.stop.assert_called_once_with(any_unicode)
def test_send_callback_acquires_and_releases_lock(self): def test_send_callback_acquires_and_releases_lock(self):
@ -484,7 +483,7 @@ class ConnectionTest(unittest.TestCase):
self.mock.sock.send.return_value = 0 self.mock.sock.send.return_value = 0
self.assertTrue(network.Connection.send_callback( self.assertTrue(network.Connection.send_callback(
self.mock, sentinel.fd, gobject.IO_IN)) self.mock, sentinel.fd, GObject.IO_IN))
self.mock.send_lock.acquire.assert_called_once_with(False) self.mock.send_lock.acquire.assert_called_once_with(False)
self.mock.send_lock.release.assert_called_once_with() self.mock.send_lock.release.assert_called_once_with()
@ -496,7 +495,7 @@ class ConnectionTest(unittest.TestCase):
self.mock.sock.send.return_value = 0 self.mock.sock.send.return_value = 0
self.assertTrue(network.Connection.send_callback( self.assertTrue(network.Connection.send_callback(
self.mock, sentinel.fd, gobject.IO_IN)) self.mock, sentinel.fd, GObject.IO_IN))
self.mock.send_lock.acquire.assert_called_once_with(False) self.mock.send_lock.acquire.assert_called_once_with(False)
self.assertEqual(0, self.mock.sock.send.call_count) self.assertEqual(0, self.mock.sock.send.call_count)
@ -507,7 +506,7 @@ class ConnectionTest(unittest.TestCase):
self.mock.send.return_value = '' self.mock.send.return_value = ''
self.assertTrue(network.Connection.send_callback( self.assertTrue(network.Connection.send_callback(
self.mock, sentinel.fd, gobject.IO_IN)) self.mock, sentinel.fd, GObject.IO_IN))
self.mock.disable_send.assert_called_once_with() self.mock.disable_send.assert_called_once_with()
self.mock.send.assert_called_once_with('data') self.mock.send.assert_called_once_with('data')
self.assertEqual('', self.mock.send_buffer) self.assertEqual('', self.mock.send_buffer)
@ -519,7 +518,7 @@ class ConnectionTest(unittest.TestCase):
self.mock.send.return_value = 'ta' self.mock.send.return_value = 'ta'
self.assertTrue(network.Connection.send_callback( self.assertTrue(network.Connection.send_callback(
self.mock, sentinel.fd, gobject.IO_IN)) self.mock, sentinel.fd, GObject.IO_IN))
self.mock.send.assert_called_once_with('data') self.mock.send.assert_called_once_with('data')
self.assertEqual('ta', self.mock.send_buffer) self.assertEqual('ta', self.mock.send_buffer)

View File

@ -4,11 +4,10 @@ import errno
import socket import socket
import unittest import unittest
import gobject
from mock import Mock, patch, sentinel from mock import Mock, patch, sentinel
from mopidy.internal import network from mopidy.internal import network
from mopidy.internal.gi import GObject
from tests import any_int from tests import any_int
@ -91,11 +90,11 @@ class ServerTest(unittest.TestCase):
network.Server.create_server_socket( network.Server.create_server_socket(
self.mock, sentinel.host, sentinel.port) self.mock, sentinel.host, sentinel.port)
@patch.object(gobject, 'io_add_watch', new=Mock()) @patch.object(GObject, 'io_add_watch', new=Mock())
def test_register_server_socket_sets_up_io_watch(self): def test_register_server_socket_sets_up_io_watch(self):
network.Server.register_server_socket(self.mock, sentinel.fileno) network.Server.register_server_socket(self.mock, sentinel.fileno)
gobject.io_add_watch.assert_called_once_with( GObject.io_add_watch.assert_called_once_with(
sentinel.fileno, gobject.IO_IN, self.mock.handle_connection) sentinel.fileno, GObject.IO_IN, self.mock.handle_connection)
def test_handle_connection(self): def test_handle_connection(self):
self.mock.accept_connection.return_value = ( self.mock.accept_connection.return_value = (
@ -103,7 +102,7 @@ class ServerTest(unittest.TestCase):
self.mock.maximum_connections_exceeded.return_value = False self.mock.maximum_connections_exceeded.return_value = False
self.assertTrue(network.Server.handle_connection( self.assertTrue(network.Server.handle_connection(
self.mock, sentinel.fileno, gobject.IO_IN)) self.mock, sentinel.fileno, GObject.IO_IN))
self.mock.accept_connection.assert_called_once_with() self.mock.accept_connection.assert_called_once_with()
self.mock.maximum_connections_exceeded.assert_called_once_with() self.mock.maximum_connections_exceeded.assert_called_once_with()
self.mock.init_connection.assert_called_once_with( self.mock.init_connection.assert_called_once_with(
@ -116,7 +115,7 @@ class ServerTest(unittest.TestCase):
self.mock.maximum_connections_exceeded.return_value = True self.mock.maximum_connections_exceeded.return_value = True
self.assertTrue(network.Server.handle_connection( self.assertTrue(network.Server.handle_connection(
self.mock, sentinel.fileno, gobject.IO_IN)) self.mock, sentinel.fileno, GObject.IO_IN))
self.mock.accept_connection.assert_called_once_with() self.mock.accept_connection.assert_called_once_with()
self.mock.maximum_connections_exceeded.assert_called_once_with() self.mock.maximum_connections_exceeded.assert_called_once_with()
self.mock.reject_connection.assert_called_once_with( self.mock.reject_connection.assert_called_once_with(

View File

@ -8,11 +8,8 @@ import mock
import pkg_resources import pkg_resources
import pygst
pygst.require('0.10')
import gst # noqa
from mopidy.internal import deps from mopidy.internal import deps
from mopidy.internal.gi import Gst, gi
class DepsTest(unittest.TestCase): class DepsTest(unittest.TestCase):
@ -74,12 +71,11 @@ class DepsTest(unittest.TestCase):
self.assertEqual('GStreamer', result['name']) self.assertEqual('GStreamer', result['name'])
self.assertEqual( self.assertEqual(
'.'.join(map(str, gst.get_gst_version())), result['version']) '.'.join(map(str, Gst.version())), result['version'])
self.assertIn('gst', result['path']) self.assertIn('gi', result['path'])
self.assertNotIn('__init__.py', result['path']) self.assertNotIn('__init__.py', result['path'])
self.assertIn('Python wrapper: gst-python', result['other']) self.assertIn('Python wrapper: python-gi', result['other'])
self.assertIn( self.assertIn(gi.__version__, result['other'])
'.'.join(map(str, gst.get_pygst_version())), result['other'])
self.assertIn('Relevant elements:', result['other']) self.assertIn('Relevant elements:', result['other'])
@mock.patch('pkg_resources.get_distribution') @mock.patch('pkg_resources.get_distribution')

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