Release v2.0.0
This commit is contained in:
commit
842076a978
1
.mailmap
1
.mailmap
@ -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>
|
||||||
|
|||||||
18
.travis.yml
18
.travis.yml
@ -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
10
AUTHORS
@ -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>
|
||||||
|
|||||||
@ -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/>`_
|
||||||
|
|||||||
@ -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
130
docs/audio.rst
Normal 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.
|
||||||
@ -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**
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
--------
|
--------
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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>`_.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
49
docs/conf.py
49
docs/conf.py
@ -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
|
||||||
|
|||||||
177
docs/config.rst
177
docs/config.rst
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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``.
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
@ -82,6 +82,7 @@ announcements related to Mopidy and Mopidy extensions.
|
|||||||
config
|
config
|
||||||
running
|
running
|
||||||
service
|
service
|
||||||
|
audio
|
||||||
troubleshooting
|
troubleshooting
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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>`.
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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)
|
|
||||||
@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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
140
mopidy/audio/tags.py
Normal 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]]
|
||||||
@ -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
|
|
||||||
|
|||||||
@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -15,6 +15,7 @@ config_file =
|
|||||||
mixer = software
|
mixer = software
|
||||||
mixer_volume =
|
mixer_volume =
|
||||||
output = autoaudiosink
|
output = autoaudiosink
|
||||||
|
buffer_time =
|
||||||
|
|
||||||
[proxy]
|
[proxy]
|
||||||
scheme =
|
scheme =
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
43
mopidy/internal/gi.py
Normal 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',
|
||||||
|
]
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
15
mopidy/m3u/backend.py
Normal 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)
|
||||||
@ -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
|
||||||
|
|||||||
@ -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 []
|
|
||||||
@ -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')
|
||||||
|
|||||||
@ -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)
|
||||||
|
)
|
||||||
|
|||||||
@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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')
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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))
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
333
tests/audio/test_tags.py
Normal 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]))
|
||||||
@ -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]))
|
|
||||||
|
|||||||
@ -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
@ -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):
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
#EXTM3U
|
#EXTM3U
|
||||||
# test
|
# test
|
||||||
#EXTINF:-1,song1
|
#EXTINF:-1,Song #1
|
||||||
# test
|
# test
|
||||||
song1.mp3
|
song1.mp3
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
#EXTM3U
|
#EXTM3U
|
||||||
#EXTINF:-1,song1
|
#EXTINF:-1,Song #1
|
||||||
song1.mp3
|
song1.mp3
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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']
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
Loading…
Reference in New Issue
Block a user