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>
|
||||
Tom Roth <rawdlite@googlemail.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
|
||||
|
||||
python:
|
||||
- "2.7_with_system_site_packages"
|
||||
|
||||
addons:
|
||||
apt:
|
||||
sources:
|
||||
- mopidy-stable
|
||||
packages:
|
||||
- graphviz-dev
|
||||
- mopidy
|
||||
|
||||
env:
|
||||
- TOX_ENV=py27
|
||||
- TOX_ENV=py27-tornado23
|
||||
@ -20,6 +13,11 @@ env:
|
||||
- TOX_ENV=docs
|
||||
- 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:
|
||||
- "pip install tox"
|
||||
|
||||
@ -27,7 +25,7 @@ script:
|
||||
- "tox -e $TOX_ENV"
|
||||
|
||||
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:
|
||||
except:
|
||||
|
||||
10
AUTHORS
10
AUTHORS
@ -67,3 +67,13 @@
|
||||
- Danilo Bargen <mail@dbrgn.ch>
|
||||
- Bjørnar Snoksrud <bjornar@snoksrud.no>
|
||||
- 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/>`_
|
||||
- `Source code <https://github.com/mopidy/mopidy>`_
|
||||
- `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/>`_
|
||||
- Announcement list: `mopidy@googlegroups.com <https://groups.google.com/forum/?fromgroups=#!forum/mopidy>`_
|
||||
- Twitter: `@mopidy <https://twitter.com/mopidy/>`_
|
||||
|
||||
@ -161,6 +161,8 @@ Playlists controller
|
||||
|
||||
.. class:: mopidy.core.PlaylistsController
|
||||
|
||||
.. automethod:: mopidy.core.PlaylistsController.get_uri_schemes
|
||||
|
||||
Fetching
|
||||
--------
|
||||
|
||||
@ -226,8 +228,8 @@ TracklistController
|
||||
.. autoattribute:: mopidy.core.TracklistController.repeat
|
||||
.. autoattribute:: mopidy.core.TracklistController.single
|
||||
|
||||
PlaylistsController
|
||||
-------------------
|
||||
PlaybackController
|
||||
------------------
|
||||
|
||||
.. automethod:: mopidy.core.PlaybackController.get_mute
|
||||
.. automethod:: mopidy.core.PlaybackController.get_volume
|
||||
@ -244,8 +246,8 @@ LibraryController
|
||||
|
||||
.. automethod:: mopidy.core.LibraryController.find_exact
|
||||
|
||||
PlaybackController
|
||||
------------------
|
||||
PlaylistsController
|
||||
-------------------
|
||||
|
||||
.. automethod:: mopidy.core.PlaylistsController.filter
|
||||
.. 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.
|
||||
|
||||
|
||||
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)
|
||||
===================
|
||||
|
||||
@ -2064,7 +2299,7 @@ already have.
|
||||
|
||||
- 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
|
||||
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.
|
||||
|
||||
- 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`)
|
||||
|
||||
- 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
|
||||
Menu <https://wiki.ubuntu.com/SoundMenu>`_.
|
||||
Menu <https://wiki.ubuntu.com/Sound#menu>`_.
|
||||
|
||||
**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
|
||||
is not a problem.
|
||||
|
||||
The library view is very slow when used together with Mopidy-Spotify. A
|
||||
workaround is to edit the ncmpcpp configuration file
|
||||
With ncmpcpp <= 0.5, the library view is very slow when used together with
|
||||
Mopidy-Spotify. A workaround is to edit the ncmpcpp configuration file
|
||||
(:file:`~/.ncmpcpp/config`) and set::
|
||||
|
||||
media_library_display_date = "no"
|
||||
|
||||
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
|
||||
-----
|
||||
@ -59,7 +62,7 @@ MPD graphical clients
|
||||
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.
|
||||
|
||||
.. image:: mpd-client-gmpc.png
|
||||
@ -76,7 +79,7 @@ before it will catch up.
|
||||
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.
|
||||
|
||||
.. 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
|
||||
side. Since Spotify has a collection of millions of tracks and they only return
|
||||
the first 100 hits for any search query, searching for two-letter combinations
|
||||
seldom returns any useful results. See :issue:`1` and the closed `Sonata bug`_
|
||||
for details.
|
||||
|
||||
.. _Sonata bug: http://developer.berlios.de/feature/?func=detailfeature&feature_id=5038&group_id=7323
|
||||
|
||||
seldom returns any useful results. See :issue:`1` for details.
|
||||
|
||||
Theremin
|
||||
--------
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
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
|
||||
media players available to other applications on the same system.
|
||||
|
||||
@ -19,7 +19,7 @@ implement the optional tracklist interface.
|
||||
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
|
||||
Rhytmbox music player, but many other players can integrate with the sound
|
||||
menu, including the official Spotify player and Mopidy.
|
||||
|
||||
@ -4,11 +4,11 @@
|
||||
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
|
||||
network. The specs are supported by a lot of consumer devices (like
|
||||
smartphones, TVs, Xbox, and PlayStation) that are often labeled as being `DLNA
|
||||
<http://en.wikipedia.org/wiki/DLNA>`_ compatible or certified.
|
||||
<https://en.wikipedia.org/wiki/DLNA>`_ compatible or certified.
|
||||
|
||||
The DLNA guidelines and UPnP specifications defines several device roles, of
|
||||
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
|
||||
`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?'
|
||||
|
||||
- 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.
|
||||
|
||||
- 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):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
@ -27,39 +26,21 @@ class Mock(object):
|
||||
|
||||
@classmethod
|
||||
def __getattr__(self, name):
|
||||
if name in ('__file__', '__path__'):
|
||||
return '/dev/null'
|
||||
elif name == 'get_system_config_dirs':
|
||||
# glib.get_system_config_dirs()
|
||||
return tuple
|
||||
elif name == 'get_user_config_dir':
|
||||
# glib.get_user_config_dir()
|
||||
if name == 'get_system_config_dirs': # GLib.get_system_config_dirs()
|
||||
return list
|
||||
elif name == 'get_user_config_dir': # GLib.get_user_config_dir()
|
||||
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:
|
||||
return Mock()
|
||||
|
||||
|
||||
MOCK_MODULES = [
|
||||
'dbus',
|
||||
'dbus.mainloop',
|
||||
'dbus.mainloop.glib',
|
||||
'dbus.service',
|
||||
'glib',
|
||||
'gobject',
|
||||
'gst',
|
||||
'gst.pbutils',
|
||||
'pygst',
|
||||
'mopidy.internal.gi',
|
||||
'pykka',
|
||||
'pykka.actor',
|
||||
'pykka.future',
|
||||
'pykka.registry',
|
||||
]
|
||||
for mod_name in MOCK_MODULES:
|
||||
sys.modules[mod_name] = Mock()
|
||||
@ -111,11 +92,7 @@ modindex_common_prefix = ['mopidy.']
|
||||
|
||||
# -- Options for HTML output --------------------------------------------------
|
||||
|
||||
# 'sphinx_rtd_theme' is bundled with Sphinx 1.3, which we don't have when
|
||||
# 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_theme = 'sphinx_rtd_theme'
|
||||
html_static_path = ['_static']
|
||||
|
||||
html_use_modindex = True
|
||||
@ -167,7 +144,17 @@ extlinks = {
|
||||
# -- Options for intersphinx extension ----------------------------------------
|
||||
|
||||
intersphinx_mapping = {
|
||||
'python': ('http://docs.python.org/2', None),
|
||||
'pykka': ('http://www.pykka.org/en/latest/', None),
|
||||
'python': ('https://docs.python.org/2', None),
|
||||
'pykka': ('https://www.pykka.org/en/latest/', 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
|
||||
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
|
||||
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
|
||||
@ -45,21 +49,18 @@ below, together with their default values. In addition, all :ref:`extensions
|
||||
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
|
||||
:language: ini
|
||||
|
||||
|
||||
Core configuration values
|
||||
=========================
|
||||
|
||||
Mopidy's core has the following configuration values that you can change.
|
||||
|
||||
|
||||
Core configuration
|
||||
------------------
|
||||
Core config section
|
||||
===================
|
||||
|
||||
.. confval:: core/cache_dir
|
||||
|
||||
@ -111,8 +112,13 @@ Core configuration
|
||||
MPD clients will crash if this limit is exceeded.
|
||||
|
||||
|
||||
.. _audio-config:
|
||||
|
||||
Audio configuration
|
||||
-------------------
|
||||
===================
|
||||
|
||||
These are the available audio configurations. For specific use cases, see
|
||||
:ref:`audio`.
|
||||
|
||||
.. confval:: audio/mixer
|
||||
|
||||
@ -146,11 +152,23 @@ Audio configuration
|
||||
Expects a GStreamer sink. Typical values are ``autoaudiosink``,
|
||||
``alsasink``, ``osssink``, ``oss4sink``, ``pulsesink``, and ``shout2send``,
|
||||
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.
|
||||
For example: ``gst-inspect-0.10 shout2send``
|
||||
``gst-inspect-1.0`` to see what output properties can be set on the sink.
|
||||
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
|
||||
---------------------
|
||||
=====================
|
||||
|
||||
.. confval:: logging/color
|
||||
|
||||
@ -195,16 +213,16 @@ Logging configuration
|
||||
to use for that logger, one of ``black``, ``red``, ``green``, ``yellow``,
|
||||
``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 configuration
|
||||
-------------------
|
||||
===================
|
||||
|
||||
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,
|
||||
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
|
||||
@ -235,9 +253,10 @@ these configurations to help users on locked down networks.
|
||||
Extension configuration
|
||||
=======================
|
||||
|
||||
Mopidy's extensions have their own config values that you may want to tweak.
|
||||
For the available config values, please refer to the docs for each extension.
|
||||
Most, if not all, can be found at :ref:`ext`.
|
||||
Each installed Mopidy extension adds its own configuration section with one or
|
||||
more config values that you may want to tweak. For the available config
|
||||
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
|
||||
to disable an extension without uninstalling it, all extensions support the
|
||||
@ -250,118 +269,14 @@ following to your ``mopidy.conf``::
|
||||
enabled = false
|
||||
|
||||
|
||||
Advanced configurations
|
||||
=======================
|
||||
Adding new configuration values
|
||||
===============================
|
||||
|
||||
Custom audio sink
|
||||
-----------------
|
||||
|
||||
If you have successfully installed GStreamer, and then run the ``gst-inspect``
|
||||
or ``gst-inspect-0.10`` command, you should see a long listing of installed
|
||||
plugins, ending in a summary line::
|
||||
|
||||
$ gst-inspect-0.10
|
||||
... long list of installed plugins ...
|
||||
Total count: 254 plugins (1 blacklist entry not shown), 1156 features
|
||||
|
||||
Next, you should be able to produce a audible tone by running::
|
||||
|
||||
gst-launch-0.10 audiotestsrc ! audioresample ! autoaudiosink
|
||||
|
||||
If you cannot hear any sound when running this command, you won't hear any
|
||||
sound from Mopidy either, as Mopidy by default uses GStreamer's
|
||||
``autoaudiosink`` to play audio. Thus, make this work before you file a bug
|
||||
against Mopidy.
|
||||
|
||||
If you for some reason want to use some other GStreamer audio sink than
|
||||
``autoaudiosink``, you can set the :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.
|
||||
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.
|
||||
This may sound obnoxious, but it helps us detect typos in your config, and to
|
||||
warn about deprecated config values that should be removed or updated.
|
||||
|
||||
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
|
||||
|
||||
@ -300,7 +300,7 @@ the given module, ``mopidy`` in this example, are covered by the test suite::
|
||||
.. note::
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@ -392,7 +392,7 @@ OS::
|
||||
open _build/html/index.html # OS X
|
||||
|
||||
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.
|
||||
|
||||
|
||||
|
||||
@ -54,7 +54,7 @@ Mopidy-Dirble
|
||||
https://github.com/mopidy/mopidy-dirble
|
||||
|
||||
Provides a backend for browsing the Internet radio channels from the `Dirble
|
||||
<http://dirble.com/>`_ directory.
|
||||
<https://dirble.com/>`_ directory.
|
||||
|
||||
|
||||
Mopidy-dLeyna
|
||||
@ -63,7 +63,7 @@ Mopidy-dLeyna
|
||||
https://github.com/tkem/mopidy-dleyna
|
||||
|
||||
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
|
||||
===========
|
||||
@ -76,13 +76,13 @@ Mopidy-Grooveshark
|
||||
https://github.com/camilonova/mopidy-grooveshark
|
||||
|
||||
Provides a backend for playing music from `Grooveshark
|
||||
<http://grooveshark.com/>`_.
|
||||
<http://grooveshark.im/>`_.
|
||||
|
||||
|
||||
Mopidy-GMusic
|
||||
=============
|
||||
|
||||
https://github.com/hechtus/mopidy-gmusic
|
||||
https://github.com/mopidy/mopidy-gmusic
|
||||
|
||||
Provides a backend for playing music from `Google Play Music
|
||||
<https://play.google.com/music/>`_.
|
||||
@ -115,7 +115,7 @@ Bundled with Mopidy. See :ref:`ext-local`.
|
||||
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
|
||||
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
|
||||
===================
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
|
||||
Mopidy-Podcast-gpodder.net
|
||||
==========================
|
||||
Mopidy-Podcast-gpodder
|
||||
======================
|
||||
|
||||
https://github.com/tkem/mopidy-podcast-gpodder
|
||||
|
||||
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
|
||||
@ -177,7 +177,7 @@ Mopidy-radio-de
|
||||
https://github.com/hechtus/mopidy-radio-de
|
||||
|
||||
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/>`_.
|
||||
|
||||
|
||||
@ -196,7 +196,7 @@ Mopidy-SoundCloud
|
||||
https://github.com/mopidy/mopidy-soundcloud
|
||||
|
||||
Provides a backend for playing music from the `SoundCloud
|
||||
<http://www.soundcloud.com/>`_ service.
|
||||
<https://soundcloud.com/>`_ service.
|
||||
|
||||
|
||||
Mopidy-Spotify
|
||||
@ -204,7 +204,7 @@ 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.
|
||||
|
||||
|
||||
@ -214,7 +214,7 @@ Mopidy-Spotify-Tunigo
|
||||
https://github.com/trygveaa/mopidy-spotify-tunigo
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@ -239,7 +239,7 @@ Mopidy-TuneIn
|
||||
https://github.com/kingosticks/mopidy-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
|
||||
@ -254,7 +254,7 @@ Provides a backend for playing music from the `VKontakte social network
|
||||
Mopidy-YouTube
|
||||
==============
|
||||
|
||||
https://github.com/dz0ny/mopidy-youtube
|
||||
https://github.com/mopidy/mopidy-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.
|
||||
|
||||
.. 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
|
||||
|
||||
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
|
||||
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
|
||||
near future:
|
||||
|
||||
- Modifying stored playlists is not supported
|
||||
- ``tagtypes`` is not supported
|
||||
- Live update of the music database is not supported
|
||||
|
||||
|
||||
@ -118,7 +118,7 @@ To install, run::
|
||||
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.
|
||||
Also the web client used for Wouter's popular `Pi Musicbox
|
||||
@ -183,7 +183,7 @@ To install, run::
|
||||
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
|
||||
for Raspberry Pi, but also usable for other projects.
|
||||
|
||||
@ -214,7 +214,7 @@ file::
|
||||
include mopidy_soundspot/ext.conf
|
||||
|
||||
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
|
||||
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
|
||||
<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
|
||||
running
|
||||
service
|
||||
audio
|
||||
troubleshooting
|
||||
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
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
|
||||
powerful as Raspberry Pi 1 and Raspberry Pi Zero, so Mopidy will be more joyful
|
||||
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
|
||||
following steps.
|
||||
|
||||
#. Then you'll need to install GStreamer 0.10 (>= 0.10.31, < 0.11), with Python
|
||||
bindings. GStreamer is packaged for most popular Linux distributions. Search
|
||||
for GStreamer in your package manager, and make sure to install the Python
|
||||
#. Then you'll need to install GStreamer >= 1.2.3, with Python bindings.
|
||||
GStreamer is packaged for most popular Linux distributions. Search for
|
||||
GStreamer in your package manager, and make sure to install the Python
|
||||
bindings, and the "good" and "ugly" plugin sets.
|
||||
|
||||
If you use Debian/Ubuntu you can install GStreamer like this::
|
||||
|
||||
sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \
|
||||
gstreamer0.10-plugins-ugly gstreamer0.10-tools
|
||||
sudo apt-get install python-gst-1.0 \
|
||||
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
|
||||
repository::
|
||||
|
||||
sudo pacman -S gstreamer0.10-python gstreamer0.10-good-plugins \
|
||||
gstreamer0.10-ugly-plugins
|
||||
sudo pacman -S gst-python2 gst-plugins-good gst-plugins-ugly
|
||||
|
||||
If you use Fedora you can install GStreamer like this::
|
||||
|
||||
sudo yum install -y python-gst0.10 gstreamer0.10-plugins-good \
|
||||
gstreamer0.10-plugins-ugly gstreamer0.10-tools
|
||||
sudo yum install -y python-gstreamer1 gstreamer1-plugins-good \
|
||||
gstreamer1-plugins-ugly
|
||||
|
||||
If you use Gentoo you need to be careful because GStreamer 0.10 is in a
|
||||
different lower slot than 1.0, the default. Your emerge commands will need
|
||||
to include the slot::
|
||||
If you use Gentoo you can install GStreamer like this::
|
||||
|
||||
emerge -av gst-python gst-plugins-bad:0.10 gst-plugins-good:0.10 \
|
||||
gst-plugins-ugly:0.10 gst-plugins-meta:0.10
|
||||
emerge -av gst-python gst-plugins-meta
|
||||
|
||||
``gst-plugins-meta:0.10`` is the one that actually pulls in the plugins you
|
||||
want, so pay attention to the use flags, e.g. ``alsa``, ``mp3``, etc.
|
||||
``gst-plugins-meta`` is the one that actually pulls in the plugins you want,
|
||||
so pay attention to the USE flags, e.g. ``alsa``, ``mp3``, etc.
|
||||
|
||||
#. 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
|
||||
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
|
||||
then you're ready to :doc:`run Mopidy </running>`.
|
||||
|
||||
|
||||
@ -14,20 +14,16 @@ the same way on their distribution.
|
||||
Configuration
|
||||
=============
|
||||
|
||||
All configuration is in :file:`/etc/mopidy`, 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.
|
||||
All configuration is in :file:`/etc/mopidy/mopidy.conf`, not in your user's
|
||||
home directory.
|
||||
|
||||
|
||||
mopidy user
|
||||
===========
|
||||
|
||||
The init script runs Mopidy as the ``mopidy`` user, which is automatically
|
||||
created when you install the Mopidy package. The ``mopidy`` user will need read
|
||||
access to any local music you want Mopidy to play.
|
||||
The Mopidy service runs as the ``mopidy`` user, which is automatically created
|
||||
when you install the Mopidy package. The ``mopidy`` user will need read access
|
||||
to any local music you want Mopidy to play.
|
||||
|
||||
|
||||
Subcommands
|
||||
@ -96,3 +92,46 @@ Service on OS X
|
||||
===============
|
||||
|
||||
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')
|
||||
|
||||
|
||||
__version__ = '1.1.2'
|
||||
__version__ = '2.0.0'
|
||||
|
||||
@ -4,24 +4,8 @@ import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
try:
|
||||
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()
|
||||
from mopidy.internal.gi import Gst # noqa: Import to initialize
|
||||
|
||||
try:
|
||||
# Make GObject's mainloop the event loop for python-dbus
|
||||
@ -33,13 +17,6 @@ except ImportError:
|
||||
|
||||
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.internal import encoding, log, path, process, versioning
|
||||
|
||||
@ -50,7 +27,7 @@ def main():
|
||||
log.bootstrap_delayed_logging()
|
||||
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
|
||||
if hasattr(signal, 'SIGUSR1'):
|
||||
signal.signal(signal.SIGUSR1, pykka.debug.log_thread_tracebacks)
|
||||
@ -73,7 +50,7 @@ def main():
|
||||
data.command.set(extension=data.extension)
|
||||
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(
|
||||
args.config_files,
|
||||
@ -83,7 +60,6 @@ def main():
|
||||
|
||||
create_core_dirs(config)
|
||||
create_initial_config_file(args, extensions_data)
|
||||
check_old_locations()
|
||||
|
||||
verbosity_level = args.base_verbosity_level
|
||||
if args.verbosity_level:
|
||||
@ -191,22 +167,6 @@ def create_initial_config_file(args, extensions_data):
|
||||
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):
|
||||
# TODO: distinguish disabled vs blocked by env?
|
||||
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 os
|
||||
|
||||
import gobject
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst # noqa
|
||||
import gst.pbutils # noqa
|
||||
import threading
|
||||
|
||||
import pykka
|
||||
|
||||
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.listener import AudioListener
|
||||
from mopidy.internal import deprecation, process
|
||||
from mopidy.internal.gi import GObject, Gst, GstPbutils
|
||||
|
||||
|
||||
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
|
||||
# set_state on a pipeline.
|
||||
# set_state() on a pipeline.
|
||||
gst_logger = logging.getLogger('mopidy.audio.gst')
|
||||
|
||||
icy.register()
|
||||
|
||||
_GST_STATE_MAPPING = {
|
||||
gst.STATE_PLAYING: PlaybackState.PLAYING,
|
||||
gst.STATE_PAUSED: PlaybackState.PAUSED,
|
||||
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)))
|
||||
Gst.State.PLAYING: PlaybackState.PLAYING,
|
||||
Gst.State.PAUSED: PlaybackState.PAUSED,
|
||||
Gst.State.NULL: PlaybackState.STOPPED,
|
||||
}
|
||||
|
||||
|
||||
# TODO: expose this as a property on audio?
|
||||
@ -70,7 +34,7 @@ class _Appsrc(object):
|
||||
"""Helper class for dealing with appsrc based playback."""
|
||||
|
||||
def __init__(self):
|
||||
self._signals = _Signals()
|
||||
self._signals = utils.Signals()
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
@ -119,9 +83,11 @@ class _Appsrc(object):
|
||||
|
||||
if buffer_ is None:
|
||||
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:
|
||||
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):
|
||||
# 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.
|
||||
class _Outputs(gst.Bin):
|
||||
class _Outputs(Gst.Bin):
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
# Add an always connected fakesink which respects the clock so the tee
|
||||
# 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)
|
||||
self._add(fakesink)
|
||||
|
||||
def add_output(self, description):
|
||||
# XXX This only works for pipelines not in use until #790 gets done.
|
||||
try:
|
||||
output = gst.parse_bin_from_description(
|
||||
description, ghost_unconnected_pads=True)
|
||||
except gobject.GError as ex:
|
||||
output = Gst.parse_bin_from_description(
|
||||
description, ghost_unlinked_pads=True)
|
||||
except GObject.GError as ex:
|
||||
logger.error(
|
||||
'Failed to create audio output "%s": %s', description, ex)
|
||||
raise exceptions.AudioException(bytes(ex))
|
||||
@ -165,7 +132,7 @@ class _Outputs(gst.Bin):
|
||||
logger.info('Audio output set to "%s"', description)
|
||||
|
||||
def _add(self, element):
|
||||
queue = gst.element_factory_make('queue')
|
||||
queue = Gst.ElementFactory.make('queue')
|
||||
self.add(element)
|
||||
self.add(queue)
|
||||
queue.link(element)
|
||||
@ -180,7 +147,7 @@ class SoftwareMixer(object):
|
||||
self._element = None
|
||||
self._last_volume = None
|
||||
self._last_mute = None
|
||||
self._signals = _Signals()
|
||||
self._signals = utils.Signals()
|
||||
|
||||
def setup(self, element, mixer_ref):
|
||||
self._element = element
|
||||
@ -222,7 +189,8 @@ class _Handler(object):
|
||||
|
||||
def setup_event_handling(self, 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):
|
||||
bus = self._element.get_bus()
|
||||
@ -231,61 +199,69 @@ class _Handler(object):
|
||||
self._message_handler_id = None
|
||||
|
||||
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
|
||||
|
||||
def on_message(self, bus, msg):
|
||||
if msg.type == gst.MESSAGE_STATE_CHANGED and msg.src == self._element:
|
||||
self.on_playbin_state_changed(*msg.parse_state_changed())
|
||||
elif msg.type == gst.MESSAGE_BUFFERING:
|
||||
self.on_buffering(msg.parse_buffering(), msg.structure)
|
||||
elif msg.type == gst.MESSAGE_EOS:
|
||||
if msg.type == Gst.MessageType.STATE_CHANGED:
|
||||
if msg.src != self._element:
|
||||
return
|
||||
old_state, new_state, pending_state = msg.parse_state_changed()
|
||||
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()
|
||||
elif msg.type == gst.MESSAGE_ERROR:
|
||||
self.on_error(*msg.parse_error())
|
||||
elif msg.type == gst.MESSAGE_WARNING:
|
||||
self.on_warning(*msg.parse_warning())
|
||||
elif msg.type == gst.MESSAGE_ASYNC_DONE:
|
||||
elif msg.type == Gst.MessageType.ERROR:
|
||||
error, debug = msg.parse_error()
|
||||
self.on_error(error, debug)
|
||||
elif msg.type == Gst.MessageType.WARNING:
|
||||
error, debug = msg.parse_warning()
|
||||
self.on_warning(error, debug)
|
||||
elif msg.type == Gst.MessageType.ASYNC_DONE:
|
||||
self.on_async_done()
|
||||
elif msg.type == gst.MESSAGE_TAG:
|
||||
self.on_tag(msg.parse_tag())
|
||||
elif msg.type == gst.MESSAGE_ELEMENT:
|
||||
if gst.pbutils.is_missing_plugin_message(msg):
|
||||
elif msg.type == Gst.MessageType.TAG:
|
||||
taglist = msg.parse_tag()
|
||||
self.on_tag(taglist)
|
||||
elif msg.type == Gst.MessageType.ELEMENT:
|
||||
if GstPbutils.is_missing_plugin_message(msg):
|
||||
self.on_missing_plugin(msg)
|
||||
elif msg.type == Gst.MessageType.STREAM_START:
|
||||
self.on_stream_start()
|
||||
|
||||
def on_event(self, pad, event):
|
||||
if event.type == gst.EVENT_NEWSEGMENT:
|
||||
self.on_new_segment(*event.parse_new_segment())
|
||||
elif event.type == gst.EVENT_SINK_MESSAGE:
|
||||
# Handle stream changed messages when they reach our output bin.
|
||||
# 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_pad_event(self, pad, pad_probe_info):
|
||||
event = pad_probe_info.get_event()
|
||||
if event.type == Gst.EventType.SEGMENT:
|
||||
self.on_segment(event.parse_segment())
|
||||
return Gst.PadProbeReturn.OK
|
||||
|
||||
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',
|
||||
old_state.value_name, new_state.value_name,
|
||||
pending_state.value_name)
|
||||
gst_logger.debug(
|
||||
'Got STATE_CHANGED bus message: old=%s new=%s pending=%s',
|
||||
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
|
||||
# NULL, so we rewrite the second to last call to get the expected
|
||||
# behavior.
|
||||
new_state = gst.STATE_NULL
|
||||
pending_state = gst.STATE_VOID_PENDING
|
||||
new_state = Gst.State.NULL
|
||||
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
|
||||
|
||||
if new_state == gst.STATE_READY:
|
||||
if new_state == Gst.State.READY:
|
||||
return # Ignore READY state as it's GStreamer specific
|
||||
|
||||
new_state = _GST_STATE_MAPPING[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:
|
||||
target_state = None
|
||||
|
||||
@ -298,80 +274,119 @@ class _Handler(object):
|
||||
AudioListener.send('stream_changed', uri=None)
|
||||
|
||||
if 'GST_DEBUG_DUMP_DOT_DIR' in os.environ:
|
||||
gst.DEBUG_BIN_TO_DOT_FILE(
|
||||
self._audio._playbin, gst.DEBUG_GRAPH_SHOW_ALL, 'mopidy')
|
||||
Gst.debug_bin_to_dot_file(
|
||||
self._audio._playbin, Gst.DebugGraphDetails.ALL, 'mopidy')
|
||||
|
||||
def on_buffering(self, percent, structure=None):
|
||||
if structure and structure.has_field('buffering-mode'):
|
||||
if structure['buffering-mode'] == gst.BUFFERING_LIVE:
|
||||
if structure is not None and structure.has_field('buffering-mode'):
|
||||
buffering_mode = structure.get_enum(
|
||||
'buffering-mode', Gst.BufferingMode)
|
||||
if buffering_mode == Gst.BufferingMode.LIVE:
|
||||
return # Live sources stall in paused.
|
||||
|
||||
level = logging.getLevelName('TRACE')
|
||||
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
|
||||
level = logging.DEBUG
|
||||
if percent == 100:
|
||||
self._audio._buffering = False
|
||||
if self._audio._target_state == gst.STATE_PLAYING:
|
||||
self._audio._playbin.set_state(gst.STATE_PLAYING)
|
||||
if self._audio._target_state == Gst.State.PLAYING:
|
||||
self._audio._playbin.set_state(Gst.State.PLAYING)
|
||||
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):
|
||||
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()')
|
||||
self._audio._tags = {}
|
||||
AudioListener.send('reached_end_of_stream')
|
||||
|
||||
def on_error(self, error, debug):
|
||||
gst_logger.error(str(error).decode('utf-8'))
|
||||
if debug:
|
||||
gst_logger.debug(debug.decode('utf-8'))
|
||||
error_msg = str(error).decode('utf-8')
|
||||
debug_msg = 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?
|
||||
self._audio.stop_playback()
|
||||
|
||||
def on_warning(self, error, debug):
|
||||
gst_logger.warning(str(error).decode('utf-8'))
|
||||
if debug:
|
||||
gst_logger.debug(debug.decode('utf-8'))
|
||||
error_msg = str(error).decode('utf-8')
|
||||
debug_msg = 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):
|
||||
gst_logger.debug('Got async-done.')
|
||||
gst_logger.debug('Got ASYNC_DONE bus message.')
|
||||
|
||||
def on_tag(self, taglist):
|
||||
tags = utils.convert_taglist(taglist)
|
||||
self._audio._tags.update(tags)
|
||||
logger.debug('Audio event: tags_changed(tags=%r)', tags.keys())
|
||||
AudioListener.send('tags_changed', tags=tags.keys())
|
||||
tags = tags_lib.convert_taglist(taglist)
|
||||
gst_logger.debug('Got TAG bus message: tags=%r', dict(tags))
|
||||
|
||||
# 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):
|
||||
desc = gst.pbutils.missing_plugin_message_get_description(msg)
|
||||
debug = gst.pbutils.missing_plugin_message_get_installer_detail(msg)
|
||||
|
||||
gst_logger.debug('Got missing-plugin message: description:%s', desc)
|
||||
desc = GstPbutils.missing_plugin_message_get_description(msg)
|
||||
debug = GstPbutils.missing_plugin_message_get_installer_detail(msg)
|
||||
gst_logger.debug(
|
||||
'Got missing-plugin bus message: description=%r', 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: '
|
||||
'gst-installer "%s"', debug)
|
||||
# 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
|
||||
# required helper installed?
|
||||
|
||||
def on_new_segment(self, update, rate, format_, start, stop, position):
|
||||
gst_logger.debug('Got new-segment event: update=%s rate=%s format=%s '
|
||||
'start=%s stop=%s position=%s', update, rate,
|
||||
format_.value_name, start, stop, position)
|
||||
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)
|
||||
def on_stream_start(self):
|
||||
gst_logger.debug('Got STREAM_START bus message')
|
||||
uri = self._audio._pending_uri
|
||||
logger.debug('Audio event: stream_changed(uri=%r)', 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
|
||||
class Audio(pykka.ThreadingActor):
|
||||
@ -390,28 +405,32 @@ class Audio(pykka.ThreadingActor):
|
||||
super(Audio, self).__init__()
|
||||
|
||||
self._config = config
|
||||
self._target_state = gst.STATE_NULL
|
||||
self._target_state = Gst.State.NULL
|
||||
self._buffering = False
|
||||
self._tags = {}
|
||||
self._pending_uri = None
|
||||
self._pending_tags = None
|
||||
|
||||
self._playbin = None
|
||||
self._outputs = None
|
||||
self._queue = None
|
||||
self._about_to_finish_callback = None
|
||||
|
||||
self._handler = _Handler(self)
|
||||
self._appsrc = _Appsrc()
|
||||
self._signals = _Signals()
|
||||
self._signals = utils.Signals()
|
||||
|
||||
if mixer and self._config['audio']['mixer'] == 'software':
|
||||
self.mixer = SoftwareMixer(mixer)
|
||||
|
||||
def on_start(self):
|
||||
self._thread = threading.current_thread()
|
||||
try:
|
||||
self._setup_preferences()
|
||||
self._setup_playbin()
|
||||
self._setup_outputs()
|
||||
self._setup_audio_sink()
|
||||
except gobject.GError as ex:
|
||||
except GObject.GError as ex:
|
||||
logger.exception(ex)
|
||||
process.exit_process()
|
||||
|
||||
@ -422,19 +441,18 @@ class Audio(pykka.ThreadingActor):
|
||||
def _setup_preferences(self):
|
||||
# TODO: move out of audio actor?
|
||||
# Fix for https://github.com/mopidy/mopidy/issues/604
|
||||
registry = gst.registry_get_default()
|
||||
jacksink = registry.find_feature(
|
||||
'jackaudiosink', gst.TYPE_ELEMENT_FACTORY)
|
||||
registry = Gst.Registry.get()
|
||||
jacksink = registry.find_feature('jackaudiosink', Gst.ElementFactory)
|
||||
if jacksink:
|
||||
jacksink.set_rank(gst.RANK_SECONDARY)
|
||||
jacksink.set_rank(Gst.Rank.SECONDARY)
|
||||
|
||||
def _setup_playbin(self):
|
||||
playbin = gst.element_factory_make('playbin2')
|
||||
playbin = Gst.ElementFactory.make('playbin')
|
||||
playbin.set_property('flags', 2) # GST_PLAY_FLAG_AUDIO
|
||||
|
||||
# TODO: turn into config values...
|
||||
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, 'about-to-finish',
|
||||
@ -448,13 +466,13 @@ class Audio(pykka.ThreadingActor):
|
||||
self._handler.teardown_event_handling()
|
||||
self._signals.disconnect(self._playbin, 'about-to-finish')
|
||||
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):
|
||||
# We don't want to use outputs for regular testing, so just install
|
||||
# an unsynced fakesink when someone asks for a 'testoutput'.
|
||||
if self._config['audio']['output'] == 'testoutput':
|
||||
self._outputs = gst.element_factory_make('fakesink')
|
||||
self._outputs = Gst.ElementFactory.make('fakesink')
|
||||
else:
|
||||
self._outputs = _Outputs()
|
||||
try:
|
||||
@ -462,26 +480,30 @@ class Audio(pykka.ThreadingActor):
|
||||
except exceptions.AudioException:
|
||||
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):
|
||||
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
|
||||
# to this queue.
|
||||
# TODO: make the min-max values a setting?
|
||||
queue = gst.element_factory_make('queue')
|
||||
queue.set_property('max-size-buffers', 0)
|
||||
queue.set_property('max-size-bytes', 0)
|
||||
queue.set_property('max-size-time', 3 * gst.SECOND)
|
||||
queue.set_property('min-threshold-time', 1 * gst.SECOND)
|
||||
# TODO: See if settings should be set to minimize latency. Previous
|
||||
# setting breaks appsrc, and settings before that broke on a few
|
||||
# systems. So leave the default to play it safe.
|
||||
queue = Gst.ElementFactory.make('queue')
|
||||
|
||||
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(self._outputs)
|
||||
|
||||
if self.mixer:
|
||||
volume = gst.element_factory_make('volume')
|
||||
volume = Gst.ElementFactory.make('volume')
|
||||
audio_sink.add(volume)
|
||||
queue.link(volume)
|
||||
volume.link(self._outputs)
|
||||
@ -489,23 +511,30 @@ class Audio(pykka.ThreadingActor):
|
||||
else:
|
||||
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)
|
||||
|
||||
self._playbin.set_property('audio-sink', audio_sink)
|
||||
self._queue = queue
|
||||
|
||||
def _teardown_mixer(self):
|
||||
if self.mixer:
|
||||
self.mixer.teardown()
|
||||
|
||||
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.')
|
||||
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()
|
||||
|
||||
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':
|
||||
self._appsrc.configure(source)
|
||||
@ -531,7 +560,8 @@ class Audio(pykka.ThreadingActor):
|
||||
else:
|
||||
current_volume = None
|
||||
|
||||
self._tags = {} # TODO: add test for this somehow
|
||||
self._pending_uri = uri
|
||||
self._pending_tags = {}
|
||||
self._playbin.set_property('uri', uri)
|
||||
|
||||
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
|
||||
"""
|
||||
self._appsrc.prepare(
|
||||
gst.Caps(bytes(caps)), need_data, enough_data, seek_data)
|
||||
self._playbin.set_property('uri', 'appsrc://')
|
||||
Gst.Caps.from_string(caps), need_data, enough_data, seek_data)
|
||||
uri = 'appsrc://'
|
||||
self._pending_uri = uri
|
||||
self._playbin.set_property('uri', uri)
|
||||
|
||||
def emit_data(self, buffer_):
|
||||
"""
|
||||
@ -572,7 +604,7 @@ class Audio(pykka.ThreadingActor):
|
||||
Returns :class:`True` if data was delivered.
|
||||
|
||||
: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
|
||||
"""
|
||||
return self._appsrc.push(buffer_)
|
||||
@ -610,15 +642,16 @@ class Audio(pykka.ThreadingActor):
|
||||
|
||||
:rtype: int
|
||||
"""
|
||||
try:
|
||||
gst_position = self._playbin.query_position(gst.FORMAT_TIME)[0]
|
||||
return utils.clocktime_to_millisecond(gst_position)
|
||||
except gst.QueryError:
|
||||
success, position = self._playbin.query_position(Gst.Format.TIME)
|
||||
|
||||
if not success:
|
||||
# TODO: take state into account for this and possibly also return
|
||||
# None as the unknown value instead of zero?
|
||||
logger.debug('Position query failed')
|
||||
return 0
|
||||
|
||||
return utils.clocktime_to_millisecond(position)
|
||||
|
||||
def set_position(self, position):
|
||||
"""
|
||||
Set position in milliseconds.
|
||||
@ -629,9 +662,14 @@ class Audio(pykka.ThreadingActor):
|
||||
"""
|
||||
# TODO: double check seek flags in use.
|
||||
gst_position = utils.millisecond_to_clocktime(position)
|
||||
result = self._playbin.seek_simple(
|
||||
gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, gst_position)
|
||||
gst_logger.debug('Sent flushing seek: position=%s', gst_position)
|
||||
gst_logger.debug('Sending flushing seek: position=%r', gst_position)
|
||||
# Send seek event to the queue not the playbin. The default behavior
|
||||
# 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
|
||||
|
||||
def start_playback(self):
|
||||
@ -640,7 +678,7 @@ class Audio(pykka.ThreadingActor):
|
||||
|
||||
: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):
|
||||
"""
|
||||
@ -648,7 +686,7 @@ class Audio(pykka.ThreadingActor):
|
||||
|
||||
: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):
|
||||
"""
|
||||
@ -657,9 +695,9 @@ class Audio(pykka.ThreadingActor):
|
||||
This function *MUST* be called before changing URIs or doing
|
||||
changes like updating data that is being pushed. The reason for this
|
||||
is that GStreamer will reset all its state when it changes to
|
||||
:attr:`gst.STATE_READY`.
|
||||
:attr:`Gst.State.READY`.
|
||||
"""
|
||||
return self._set_state(gst.STATE_READY)
|
||||
return self._set_state(Gst.State.READY)
|
||||
|
||||
def stop_playback(self):
|
||||
"""
|
||||
@ -668,14 +706,14 @@ class Audio(pykka.ThreadingActor):
|
||||
:rtype: :class:`True` if successfull, else :class:`False`
|
||||
"""
|
||||
self._buffering = False
|
||||
return self._set_state(gst.STATE_NULL)
|
||||
return self._set_state(Gst.State.NULL)
|
||||
|
||||
def wait_for_state_change(self):
|
||||
"""Block until any pending state changes are complete.
|
||||
|
||||
Should only be used by tests.
|
||||
"""
|
||||
self._playbin.get_state()
|
||||
self._playbin.get_state(timeout=Gst.CLOCK_TIME_NONE)
|
||||
|
||||
def enable_sync_handler(self):
|
||||
"""Enable manual processing of messages from bus.
|
||||
@ -684,7 +722,7 @@ class Audio(pykka.ThreadingActor):
|
||||
"""
|
||||
def sync_handler(bus, message):
|
||||
self._handler.on_message(bus, message)
|
||||
return gst.BUS_DROP
|
||||
return Gst.BusSyncReply.DROP
|
||||
|
||||
bus = self._playbin.get_bus()
|
||||
bus.set_sync_handler(sync_handler)
|
||||
@ -705,17 +743,18 @@ class Audio(pykka.ThreadingActor):
|
||||
"READY" -> "NULL"
|
||||
"READY" -> "PAUSED"
|
||||
|
||||
:param state: State to set playbin to. One of: `gst.STATE_NULL`,
|
||||
`gst.STATE_READY`, `gst.STATE_PAUSED` and `gst.STATE_PLAYING`.
|
||||
:type state: :class:`gst.State`
|
||||
:param state: State to set playbin to. One of: `Gst.State.NULL`,
|
||||
`Gst.State.READY`, `Gst.State.PAUSED` and `Gst.State.PLAYING`.
|
||||
:type state: :class:`Gst.State`
|
||||
:rtype: :class:`True` if successfull, else :class:`False`
|
||||
"""
|
||||
self._target_state = state
|
||||
result = self._playbin.set_state(state)
|
||||
gst_logger.debug('State change to %s: result=%s', state.value_name,
|
||||
result.value_name)
|
||||
gst_logger.debug(
|
||||
'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(
|
||||
'Setting GStreamer state to %s failed', state.value_name)
|
||||
return False
|
||||
@ -728,35 +767,44 @@ class Audio(pykka.ThreadingActor):
|
||||
"""
|
||||
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
|
||||
deliver raw audio data to GStreamer.
|
||||
|
||||
:param track: the current 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]
|
||||
|
||||
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
|
||||
# values it might have.
|
||||
taglist[gst.TAG_ARTIST] = ' '
|
||||
taglist[gst.TAG_TITLE] = ' '
|
||||
taglist[gst.TAG_ALBUM] = ' '
|
||||
# TODO: Verify if this works at all, likely it doesn't.
|
||||
set_value(Gst.TAG_ARTIST, ' ')
|
||||
set_value(Gst.TAG_TITLE, ' ')
|
||||
set_value(Gst.TAG_ALBUM, ' ')
|
||||
|
||||
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:
|
||||
taglist[gst.TAG_TITLE] = track.name
|
||||
set_value(Gst.TAG_TITLE, track.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?
|
||||
self._playbin.send_event(event)
|
||||
gst_logger.debug('Sent tag event: track=%s', track.uri)
|
||||
|
||||
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
|
||||
def send(event, **kwargs):
|
||||
"""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):
|
||||
"""
|
||||
|
||||
@ -2,21 +2,27 @@ from __future__ import (
|
||||
absolute_import, division, print_function, unicode_literals)
|
||||
|
||||
import collections
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst # noqa
|
||||
import gst.pbutils # noqa
|
||||
import time
|
||||
|
||||
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.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', ('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)?
|
||||
class Scanner(object):
|
||||
@ -51,7 +57,7 @@ class Scanner(object):
|
||||
"""
|
||||
timeout = int(timeout or self._timeout_ms)
|
||||
tags, duration, seekable, mime = None, None, None, None
|
||||
pipeline = _setup_pipeline(uri, self._proxy_config)
|
||||
pipeline, signals = _setup_pipeline(uri, self._proxy_config)
|
||||
|
||||
try:
|
||||
_start_pipeline(pipeline)
|
||||
@ -59,7 +65,8 @@ class Scanner(object):
|
||||
duration = _query_duration(pipeline)
|
||||
seekable = _query_seekable(pipeline)
|
||||
finally:
|
||||
pipeline.set_state(gst.STATE_NULL)
|
||||
signals.clear()
|
||||
pipeline.set_state(Gst.State.NULL)
|
||||
del pipeline
|
||||
|
||||
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
|
||||
# decodebins and other elements don't seem to take well to being reused.
|
||||
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:
|
||||
raise exceptions.ScannerError('GStreamer can not open: %s' % uri)
|
||||
|
||||
typefind = gst.element_factory_make('typefind')
|
||||
decodebin = gst.element_factory_make('decodebin2')
|
||||
typefind = Gst.ElementFactory.make('typefind')
|
||||
decodebin = Gst.ElementFactory.make('decodebin')
|
||||
|
||||
pipeline = gst.element_factory_make('pipeline')
|
||||
pipeline = Gst.ElementFactory.make('pipeline')
|
||||
for e in (src, typefind, decodebin):
|
||||
pipeline.add(e)
|
||||
gst.element_link_many(src, typefind, decodebin)
|
||||
src.link(typefind)
|
||||
typefind.link(decodebin)
|
||||
|
||||
if proxy_config:
|
||||
utils.setup_proxy(src, proxy_config)
|
||||
|
||||
typefind.connect('have-type', _have_type, decodebin)
|
||||
decodebin.connect('pad-added', _pad_added, pipeline)
|
||||
signals = utils.Signals()
|
||||
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):
|
||||
decodebin.set_property('sink-caps', caps)
|
||||
struct = gst.Structure('have-type')
|
||||
struct['caps'] = caps.get_structure(0)
|
||||
element.get_bus().post(gst.message_new_application(element, struct))
|
||||
struct = Gst.Structure.new_empty('have-type')
|
||||
struct.set_value('caps', caps.get_structure(0))
|
||||
element.get_bus().post(Gst.Message.new_application(element, struct))
|
||||
|
||||
|
||||
def _pad_added(element, pad, pipeline):
|
||||
sink = gst.element_factory_make('fakesink')
|
||||
sink = Gst.ElementFactory.make('fakesink')
|
||||
sink.set_property('sync', False)
|
||||
|
||||
pipeline.add(sink)
|
||||
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):
|
||||
struct = gst.Structure('have-audio')
|
||||
element.get_bus().post(gst.message_new_application(element, struct))
|
||||
if pad.query_caps().is_subset(Gst.Caps.from_string('audio/x-raw')):
|
||||
# Probably won't happen due to autoplug-select fix, but lets play it
|
||||
# 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):
|
||||
if pipeline.set_state(gst.STATE_PAUSED) == gst.STATE_CHANGE_NO_PREROLL:
|
||||
pipeline.set_state(gst.STATE_PLAYING)
|
||||
result = pipeline.set_state(Gst.State.PAUSED)
|
||||
if result == Gst.StateChangeReturn.NO_PREROLL:
|
||||
pipeline.set_state(Gst.State.PLAYING)
|
||||
|
||||
|
||||
def _query_duration(pipeline):
|
||||
try:
|
||||
duration = pipeline.query_duration(gst.FORMAT_TIME, None)[0]
|
||||
except gst.QueryError:
|
||||
def _query_duration(pipeline, timeout=100):
|
||||
# 1. Try and get a duration, return if success.
|
||||
# 2. Some formats need to play some buffers before duration is found.
|
||||
# 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
|
||||
|
||||
if duration < 0:
|
||||
return None
|
||||
else:
|
||||
return duration // gst.MSECOND
|
||||
gst_timeout = timeout * Gst.MSECOND
|
||||
bus = pipeline.get_bus()
|
||||
bus.timed_pop_filtered(gst_timeout, Gst.MessageType.DURATION_CHANGED)
|
||||
|
||||
success, duration = pipeline.query_duration(Gst.Format.TIME)
|
||||
if success and duration >= 0:
|
||||
return duration // Gst.MSECOND
|
||||
return None
|
||||
|
||||
|
||||
def _query_seekable(pipeline):
|
||||
query = gst.query_new_seeking(gst.FORMAT_TIME)
|
||||
query = Gst.Query.new_seeking(Gst.Format.TIME)
|
||||
pipeline.query(query)
|
||||
return query.parse_seeking()[1]
|
||||
|
||||
|
||||
def _process(pipeline, timeout_ms):
|
||||
clock = pipeline.get_clock()
|
||||
bus = pipeline.get_bus()
|
||||
timeout = timeout_ms * gst.MSECOND
|
||||
tags = {}
|
||||
mime = None
|
||||
have_audio = False
|
||||
missing_message = None
|
||||
|
||||
types = (
|
||||
gst.MESSAGE_ELEMENT | gst.MESSAGE_APPLICATION | gst.MESSAGE_ERROR |
|
||||
gst.MESSAGE_EOS | gst.MESSAGE_ASYNC_DONE | gst.MESSAGE_TAG)
|
||||
Gst.MessageType.ELEMENT |
|
||||
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:
|
||||
message = bus.timed_pop_filtered(timeout, types)
|
||||
message = bus.timed_pop_filtered(timeout * Gst.MSECOND, types)
|
||||
|
||||
if message is None:
|
||||
break
|
||||
elif message.type == gst.MESSAGE_ELEMENT:
|
||||
if gst.pbutils.is_missing_plugin_message(message):
|
||||
elif message.type == Gst.MessageType.ELEMENT:
|
||||
if GstPbutils.is_missing_plugin_message(message):
|
||||
missing_message = message
|
||||
elif message.type == gst.MESSAGE_APPLICATION:
|
||||
if message.structure.get_name() == 'have-type':
|
||||
mime = message.structure['caps'].get_name()
|
||||
if mime.startswith('text/') or mime == 'application/xml':
|
||||
elif message.type == Gst.MessageType.APPLICATION:
|
||||
if message.get_structure().get_name() == 'have-type':
|
||||
mime = message.get_structure().get_value('caps').get_name()
|
||||
if mime and (
|
||||
mime.startswith('text/') or mime == 'application/xml'):
|
||||
return tags, mime, have_audio
|
||||
elif message.structure.get_name() == 'have-audio':
|
||||
elif message.get_structure().get_name() == 'have-audio':
|
||||
have_audio = True
|
||||
elif message.type == gst.MESSAGE_ERROR:
|
||||
elif message.type == Gst.MessageType.ERROR:
|
||||
error = encoding.locale_decode(message.parse_error()[0])
|
||||
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()
|
||||
return tags, mime, have_audio
|
||||
raise exceptions.ScannerError(error)
|
||||
elif message.type == gst.MESSAGE_EOS:
|
||||
elif message.type == Gst.MessageType.EOS:
|
||||
return tags, mime, have_audio
|
||||
elif message.type == gst.MESSAGE_ASYNC_DONE:
|
||||
elif message.type == Gst.MessageType.ASYNC_DONE:
|
||||
if message.src == pipeline:
|
||||
return tags, mime, have_audio
|
||||
elif message.type == gst.MESSAGE_TAG:
|
||||
elif message.type == Gst.MessageType.TAG:
|
||||
taglist = message.parse_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
|
||||
previous = now
|
||||
|
||||
@ -189,15 +228,11 @@ if __name__ == '__main__':
|
||||
import os
|
||||
import sys
|
||||
|
||||
import gobject
|
||||
|
||||
from mopidy.internal import path
|
||||
|
||||
gobject.threads_init()
|
||||
|
||||
scanner = Scanner(5000)
|
||||
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))
|
||||
try:
|
||||
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
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
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__)
|
||||
from mopidy import httpclient
|
||||
from mopidy.internal.gi import Gst
|
||||
|
||||
|
||||
def calculate_duration(num_samples, sample_rate):
|
||||
"""Determine duration of samples using GStreamer helper for precise
|
||||
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.
|
||||
|
||||
Mainly intended to keep gst imports out of non-audio modules.
|
||||
|
||||
.. versionchanged:: 2.0
|
||||
``capabilites`` argument was removed.
|
||||
"""
|
||||
buffer_ = gst.Buffer(data)
|
||||
if capabilites:
|
||||
if isinstance(capabilites, compat.string_types):
|
||||
capabilites = gst.caps_from_string(capabilites)
|
||||
buffer_.set_caps(capabilites)
|
||||
if timestamp:
|
||||
buffer_.timestamp = timestamp
|
||||
if duration:
|
||||
if not data:
|
||||
raise ValueError('Cannot create buffer without data')
|
||||
buffer_ = Gst.Buffer.new_wrapped(data)
|
||||
if timestamp is not None:
|
||||
buffer_.pts = timestamp
|
||||
if duration is not None:
|
||||
buffer_.duration = duration
|
||||
return buffer_
|
||||
|
||||
|
||||
def millisecond_to_clocktime(value):
|
||||
"""Convert a millisecond time to internal GStreamer time."""
|
||||
return value * gst.MSECOND
|
||||
return value * Gst.MSECOND
|
||||
|
||||
|
||||
def clocktime_to_millisecond(value):
|
||||
"""Convert an internal GStreamer time to millisecond time."""
|
||||
return value // gst.MSECOND
|
||||
return value // Gst.MSECOND
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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():
|
||||
if uri in uri_schemes:
|
||||
supported_schemes.add(uri)
|
||||
@ -65,84 +56,11 @@ def supported_uri_schemes(uri_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):
|
||||
"""Configure a GStreamer element with proxy settings.
|
||||
|
||||
:param element: element to setup proxy in.
|
||||
:type element: :class:`gst.GstElement`
|
||||
:type element: :class:`Gst.GstElement`
|
||||
:param config: proxy settings to use.
|
||||
:type config: :class:`dict`
|
||||
"""
|
||||
@ -154,50 +72,31 @@ def setup_proxy(element, config):
|
||||
element.set_property('proxy-pw', config.get('password'))
|
||||
|
||||
|
||||
def convert_taglist(taglist):
|
||||
"""Convert a :class:`gst.Taglist` to plain Python types.
|
||||
class Signals(object):
|
||||
|
||||
Knows how to convert:
|
||||
"""Helper for tracking gobject signal registrations"""
|
||||
|
||||
- Dates
|
||||
- Buffers
|
||||
- Numbers
|
||||
- Strings
|
||||
- Booleans
|
||||
def __init__(self):
|
||||
self._ids = {}
|
||||
|
||||
Unknown types will be ignored and debug logged. Tag keys are all strings
|
||||
defined as part GStreamer under GstTagList_.
|
||||
def connect(self, element, event, func, *args):
|
||||
"""Connect a function + args to signal event on an element.
|
||||
|
||||
.. _GstTagList: http://gstreamer.freedesktop.org/data/doc/gstreamer/\
|
||||
0.10.36/gstreamer/html/gstreamer-GstTagList.html
|
||||
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)
|
||||
|
||||
:param taglist: A GStreamer taglist to be converted.
|
||||
:type taglist: :class:`gst.Taglist`
|
||||
:rtype: dictionary of tag keys with a list of values.
|
||||
"""
|
||||
result = {}
|
||||
def disconnect(self, element, event):
|
||||
"""Disconnect whatever handler we have for an element+event pair.
|
||||
|
||||
# Taglists are not really dicts, hence the lack of .items() and
|
||||
# explicit use of .keys()
|
||||
for key in taglist.keys():
|
||||
result.setdefault(key, [])
|
||||
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)
|
||||
|
||||
values = taglist[key]
|
||||
if not isinstance(values, list):
|
||||
values = [values]
|
||||
|
||||
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
|
||||
def clear(self):
|
||||
"""Clear all registered signal handlers."""
|
||||
for element, event in self._ids.keys():
|
||||
element.disconnect(self._ids.pop((element, event)))
|
||||
|
||||
@ -347,13 +347,14 @@ class PlaylistsProvider(object):
|
||||
"""
|
||||
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.*
|
||||
|
||||
:param name: name of the new playlist
|
||||
:type name: string
|
||||
:rtype: :class:`mopidy.models.Playlist`
|
||||
:rtype: :class:`mopidy.models.Playlist` or :class:`None`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@ -426,7 +427,7 @@ class BackendListener(listener.Listener):
|
||||
@staticmethod
|
||||
def send(event, **kwargs):
|
||||
"""Helper to allow calling of backend listener events"""
|
||||
listener.send_async(BackendListener, event, **kwargs)
|
||||
listener.send(BackendListener, event, **kwargs)
|
||||
|
||||
def playlists_loaded(self):
|
||||
"""
|
||||
|
||||
@ -5,23 +5,21 @@ import collections
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
|
||||
import glib
|
||||
|
||||
import gobject
|
||||
|
||||
import pykka
|
||||
|
||||
from mopidy import config as config_lib, exceptions
|
||||
from mopidy.audio import Audio
|
||||
from mopidy.core import Core
|
||||
from mopidy.internal import deps, process, timer, versioning
|
||||
from mopidy.internal.gi import GLib
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_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 = b':'.join(_default_config)
|
||||
|
||||
@ -286,7 +284,13 @@ class RootCommand(Command):
|
||||
help='`section/key=value` values to override config options')
|
||||
|
||||
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'])
|
||||
backend_classes = args.registry['backend']
|
||||
@ -303,6 +307,7 @@ class RootCommand(Command):
|
||||
backends = self.start_backends(config, backend_classes, audio)
|
||||
core = self.start_core(config, mixer, backends, audio)
|
||||
self.start_frontends(config, frontend_classes, core)
|
||||
logger.info('Starting GLib mainloop')
|
||||
loop.run()
|
||||
except (exceptions.BackendError,
|
||||
exceptions.FrontendError,
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import sys
|
||||
|
||||
PY2 = sys.version_info[0] == 2
|
||||
@ -8,10 +10,31 @@ if PY2:
|
||||
import Queue as queue # noqa
|
||||
import thread # noqa
|
||||
|
||||
string_types = basestring
|
||||
text_type = unicode
|
||||
def fake_python3_urllib_module():
|
||||
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):
|
||||
return iter(dct.itervalues(**kwargs))
|
||||
@ -20,11 +43,14 @@ else:
|
||||
import configparser # noqa
|
||||
import queue # noqa
|
||||
import _thread as thread # noqa
|
||||
import urllib # noqa
|
||||
|
||||
integer_types = (int,)
|
||||
string_types = (str,)
|
||||
text_type = str
|
||||
|
||||
input = input
|
||||
intern = sys.intern
|
||||
|
||||
def itervalues(dct, **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['output'] = String()
|
||||
_audio_schema['visualizer'] = Deprecated()
|
||||
_audio_schema['buffer_time'] = Integer(optional=True, minimum=1)
|
||||
|
||||
_proxy_schema = ConfigSchema('proxy')
|
||||
_proxy_schema['scheme'] = String(optional=True,
|
||||
|
||||
@ -15,6 +15,7 @@ config_file =
|
||||
mixer = software
|
||||
mixer_volume =
|
||||
output = autoaudiosink
|
||||
buffer_time =
|
||||
|
||||
[proxy]
|
||||
scheme =
|
||||
|
||||
@ -54,7 +54,8 @@ class Core(
|
||||
self.library = LibraryController(backends=self.backends, core=self)
|
||||
self.history = HistoryController()
|
||||
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.tracklist = TracklistController(core=self)
|
||||
|
||||
@ -84,11 +85,14 @@ class Core(
|
||||
"""
|
||||
|
||||
def reached_end_of_stream(self):
|
||||
self.playback._on_end_of_track()
|
||||
self.playback._on_end_of_stream()
|
||||
|
||||
def stream_changed(self, 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):
|
||||
# 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
|
||||
|
||||
@ -4,9 +4,9 @@ import collections
|
||||
import contextlib
|
||||
import logging
|
||||
import operator
|
||||
import urlparse
|
||||
|
||||
from mopidy import compat, exceptions, models
|
||||
from mopidy.compat import urllib
|
||||
from mopidy.internal import deprecation, validation
|
||||
|
||||
|
||||
@ -35,7 +35,7 @@ class LibraryController(object):
|
||||
self.core = core
|
||||
|
||||
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)
|
||||
|
||||
def _get_backends_to_uris(self, uris):
|
||||
@ -102,7 +102,7 @@ class LibraryController(object):
|
||||
return sorted(directories, key=operator.attrgetter('name'))
|
||||
|
||||
def _browse(self, uri):
|
||||
scheme = urlparse.urlparse(uri).scheme
|
||||
scheme = urllib.parse.urlparse(uri).scheme
|
||||
backend = self.backends.with_library_browse.get(scheme)
|
||||
|
||||
if not backend:
|
||||
@ -149,7 +149,7 @@ class LibraryController(object):
|
||||
"""Lookup the images for the given URIs
|
||||
|
||||
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.
|
||||
|
||||
Unknown URIs or URIs the corresponding backend couldn't find anything
|
||||
@ -255,7 +255,7 @@ class LibraryController(object):
|
||||
|
||||
futures = {}
|
||||
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():
|
||||
backends.setdefault(backend, set()).add(backend_scheme)
|
||||
@ -271,6 +271,9 @@ class LibraryController(object):
|
||||
def search(self, query=None, uris=None, exact=False, **kwargs):
|
||||
"""
|
||||
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
|
||||
URI roots. For example passing ``uris=['file:']`` will limit the search
|
||||
@ -358,7 +361,7 @@ def _normalize_query(query):
|
||||
broken_client = False
|
||||
# TODO: this breaks if query is not a dictionary like object...
|
||||
for (field, values) in query.items():
|
||||
if isinstance(values, basestring):
|
||||
if isinstance(values, compat.string_types):
|
||||
broken_client = True
|
||||
query[field] = [values]
|
||||
if broken_client:
|
||||
|
||||
@ -18,7 +18,7 @@ class CoreListener(listener.Listener):
|
||||
@staticmethod
|
||||
def send(event, **kwargs):
|
||||
"""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):
|
||||
"""
|
||||
@ -182,5 +182,8 @@ class CoreListener(listener.Listener):
|
||||
Called whenever the currently playing stream title changes.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
|
||||
:param title: the new stream title
|
||||
:type title: string
|
||||
"""
|
||||
pass
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import logging
|
||||
import urlparse
|
||||
|
||||
from mopidy import models
|
||||
from mopidy.audio import PlaybackState
|
||||
from mopidy.compat import urllib
|
||||
from mopidy.core import listener
|
||||
from mopidy.internal import deprecation, validation
|
||||
|
||||
@ -14,21 +14,30 @@ logger = logging.getLogger(__name__)
|
||||
class PlaybackController(object):
|
||||
pykka_traversable = True
|
||||
|
||||
def __init__(self, backends, core):
|
||||
def __init__(self, audio, backends, core):
|
||||
# TODO: these should be internal
|
||||
self.backends = backends
|
||||
self.core = core
|
||||
self._audio = audio
|
||||
|
||||
self._current_tl_track = None
|
||||
self._stream_title = None
|
||||
self._state = PlaybackState.STOPPED
|
||||
|
||||
def _get_backend(self):
|
||||
# TODO: take in track instead
|
||||
track = self.get_current_track()
|
||||
if track is None:
|
||||
self._current_tl_track = None
|
||||
self._pending_tl_track = 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
|
||||
uri = track.uri
|
||||
uri_scheme = urlparse.urlparse(uri).scheme
|
||||
uri_scheme = urllib.parse.urlparse(tl_track.track.uri).scheme
|
||||
return self.backends.with_playback.get(uri_scheme, None)
|
||||
|
||||
# Properties
|
||||
@ -122,8 +131,11 @@ class PlaybackController(object):
|
||||
|
||||
def get_time_position(self):
|
||||
"""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:
|
||||
# TODO: Wrap backend call in error handling.
|
||||
return backend.playback.get_time_position().get()
|
||||
else:
|
||||
return 0
|
||||
@ -190,62 +202,72 @@ class PlaybackController(object):
|
||||
|
||||
# Methods
|
||||
|
||||
# TODO: remove this.
|
||||
def _change_track(self, tl_track, on_error_step=1):
|
||||
"""
|
||||
Change to the given track, keeping the current playback state.
|
||||
def _on_end_of_stream(self):
|
||||
self.set_state(PlaybackState.STOPPED)
|
||||
if self._current_tl_track:
|
||||
self._trigger_track_playback_ended(self.get_time_position())
|
||||
self._set_current_tl_track(None)
|
||||
|
||||
:param tl_track: track to change to
|
||||
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
:param on_error_step: direction to step at play error, 1 for next
|
||||
track (default), -1 for previous track. **INTERNAL**
|
||||
:type on_error_step: int, -1 or 1
|
||||
"""
|
||||
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()
|
||||
def _on_stream_changed(self, uri):
|
||||
if self._last_position is None:
|
||||
position = self.get_time_position()
|
||||
else:
|
||||
# This code path handles the stop() case, uri should be none.
|
||||
position, self._last_position = self._last_position, None
|
||||
|
||||
# TODO: this is not really end of track, this is on_need_next_track
|
||||
def _on_end_of_track(self):
|
||||
"""
|
||||
Tell the playback controller that end of track is reached.
|
||||
if self._pending_position is None:
|
||||
self._trigger_track_playback_ended(position)
|
||||
|
||||
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
|
||||
|
||||
original_tl_track = self.get_current_tl_track()
|
||||
next_tl_track = self.core.tracklist.eot_track(original_tl_track)
|
||||
pending = self.core.tracklist.eot_track(self._current_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:
|
||||
self._change_track(next_tl_track)
|
||||
else:
|
||||
self.stop()
|
||||
self._set_current_tl_track(None)
|
||||
try:
|
||||
if backend.playback.change_track(pending.track).get():
|
||||
self._pending_tl_track = pending
|
||||
break
|
||||
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):
|
||||
"""
|
||||
@ -253,13 +275,11 @@ class PlaybackController(object):
|
||||
|
||||
Used by :class:`mopidy.core.TracklistController`.
|
||||
"""
|
||||
tracklist = self.core.tracklist.get_tl_tracks()
|
||||
if self.get_current_tl_track() not in tracklist:
|
||||
if not self.core.tracklist.tl_tracks:
|
||||
self.stop()
|
||||
self._set_current_tl_track(None)
|
||||
|
||||
def _on_stream_changed(self, uri):
|
||||
self._stream_title = None
|
||||
elif self.get_current_tl_track() not in self.core.tracklist.tl_tracks:
|
||||
self._set_current_tl_track(None)
|
||||
|
||||
def next(self):
|
||||
"""
|
||||
@ -268,23 +288,26 @@ class PlaybackController(object):
|
||||
The current playback state will be kept. If it was playing, playing
|
||||
will continue. If it was paused, it will still be paused, etc.
|
||||
"""
|
||||
original_tl_track = self.get_current_tl_track()
|
||||
next_tl_track = self.core.tracklist.next_track(original_tl_track)
|
||||
state = self.get_state()
|
||||
current = self._pending_tl_track or self._current_tl_track
|
||||
|
||||
if next_tl_track:
|
||||
# TODO: switch to:
|
||||
# backend.play(track)
|
||||
# wait for state change?
|
||||
self._change_track(next_tl_track)
|
||||
else:
|
||||
self.stop()
|
||||
self._set_current_tl_track(None)
|
||||
while current:
|
||||
pending = self.core.tracklist.next_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
|
||||
|
||||
self.core.tracklist._mark_played(original_tl_track)
|
||||
# TODO return result?
|
||||
|
||||
def pause(self):
|
||||
"""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():
|
||||
# TODO: switch to:
|
||||
# backend.track(pause)
|
||||
@ -308,14 +331,11 @@ class PlaybackController(object):
|
||||
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)
|
||||
tlid is None or validation.check_integer(tlid, min=0)
|
||||
tlid is None or validation.check_integer(tlid, min=1)
|
||||
|
||||
if tl_track:
|
||||
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:
|
||||
for tl_track in self.core.tracklist.get_tl_tracks():
|
||||
if tl_track.tlid == tlid:
|
||||
@ -323,60 +343,68 @@ class PlaybackController(object):
|
||||
else:
|
||||
tl_track = None
|
||||
|
||||
if tl_track is None:
|
||||
if self.get_state() == PlaybackState.PAUSED:
|
||||
return self.resume()
|
||||
if tl_track is not None:
|
||||
# TODO: allow from outside tracklist, would make sense given refs?
|
||||
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:
|
||||
tl_track = self.get_current_tl_track()
|
||||
current = self._pending_tl_track or self._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:
|
||||
if on_error_step == 1:
|
||||
tl_track = self.core.tracklist.next_track(tl_track)
|
||||
elif on_error_step == -1:
|
||||
tl_track = self.core.tracklist.previous_track(tl_track)
|
||||
self.core.tracklist._mark_unplayable(pending)
|
||||
current = pending
|
||||
pending = self.core.tracklist.next_track(current)
|
||||
|
||||
if tl_track is None:
|
||||
return
|
||||
# TODO return result?
|
||||
|
||||
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:
|
||||
# backend.play(track)
|
||||
# wait for state change?
|
||||
|
||||
if self.get_state() == PlaybackState.PLAYING:
|
||||
if not pending_tl_track:
|
||||
self.stop()
|
||||
self._on_end_of_stream() # pretend an EOS happened for cleanup
|
||||
return True
|
||||
|
||||
self._set_current_tl_track(tl_track)
|
||||
self.set_state(PlaybackState.PLAYING)
|
||||
backend = self._get_backend()
|
||||
success = False
|
||||
backend = self._get_backend(pending_tl_track)
|
||||
if not backend:
|
||||
return False
|
||||
|
||||
if backend:
|
||||
backend.playback.prepare_change()
|
||||
# TODO: Wrap backend call in error handling.
|
||||
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:
|
||||
success = (
|
||||
backend.playback.change_track(tl_track.track).get() and
|
||||
backend.playback.play().get())
|
||||
return backend.playback.play().get()
|
||||
except TypeError:
|
||||
logger.error(
|
||||
'%s needs to be updated to work with this '
|
||||
'version of Mopidy.',
|
||||
backend.actor_ref.actor_class.__name__)
|
||||
logger.debug('Backend exception', exc_info=True)
|
||||
# TODO: check by binding against underlying play method using
|
||||
# inspect and otherwise re-raise?
|
||||
logger.error('%s needs to be updated to work with this '
|
||||
'version of Mopidy.', backend)
|
||||
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:
|
||||
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()
|
||||
raise Exception('Unknown state: %s' % state)
|
||||
|
||||
def previous(self):
|
||||
"""
|
||||
@ -385,18 +413,29 @@ class PlaybackController(object):
|
||||
The current playback state will be kept. If it was playing, playing
|
||||
will continue. If it was paused, it will still be paused, etc.
|
||||
"""
|
||||
tl_track = self.get_current_tl_track()
|
||||
# TODO: switch to:
|
||||
# self.play(....)
|
||||
# wait for state change?
|
||||
self._change_track(
|
||||
self.core.tracklist.previous_track(tl_track), on_error_step=-1)
|
||||
self._previous = True
|
||||
state = self.get_state()
|
||||
current = self._pending_tl_track or self._current_tl_track
|
||||
|
||||
while current:
|
||||
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):
|
||||
"""If paused, resume playing the current track."""
|
||||
if self.get_state() != PlaybackState.PAUSED:
|
||||
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():
|
||||
self.set_state(PlaybackState.PLAYING)
|
||||
# TODO: trigger via gst messages
|
||||
@ -413,6 +452,7 @@ class PlaybackController(object):
|
||||
:type time_position: int
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
# TODO: seek needs to take pending tracks into account :(
|
||||
validation.check_integer(time_position)
|
||||
|
||||
if time_position < 0:
|
||||
@ -423,35 +463,47 @@ class PlaybackController(object):
|
||||
if not self.core.tracklist.tracks:
|
||||
return False
|
||||
|
||||
if self.current_track and self.current_track.length is None:
|
||||
return False
|
||||
|
||||
if self.get_state() == PlaybackState.STOPPED:
|
||||
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:
|
||||
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()
|
||||
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:
|
||||
return False
|
||||
|
||||
success = backend.playback.seek(time_position).get()
|
||||
if success:
|
||||
self._trigger_seeked(time_position)
|
||||
return success
|
||||
# TODO: Wrap backend call in error handling.
|
||||
return backend.playback.seek(time_position).get()
|
||||
|
||||
def stop(self):
|
||||
"""Stop playing."""
|
||||
if self.get_state() != PlaybackState.STOPPED:
|
||||
backend = self._get_backend()
|
||||
time_position_before_stop = self.get_time_position()
|
||||
self._last_position = 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():
|
||||
self.set_state(PlaybackState.STOPPED)
|
||||
self._trigger_track_playback_ended(time_position_before_stop)
|
||||
|
||||
def _trigger_track_playback_paused(self):
|
||||
logger.debug('Triggering track playback paused event')
|
||||
@ -472,20 +524,30 @@ class PlaybackController(object):
|
||||
time_position=self.get_time_position())
|
||||
|
||||
def _trigger_track_playback_started(self):
|
||||
logger.debug('Triggering track playback started event')
|
||||
if self.get_current_tl_track() is None:
|
||||
return
|
||||
listener.CoreListener.send(
|
||||
'track_playback_started',
|
||||
tl_track=self.get_current_tl_track())
|
||||
|
||||
logger.debug('Triggering track playback started event')
|
||||
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):
|
||||
logger.debug('Triggering track playback ended event')
|
||||
if self.get_current_tl_track() is None:
|
||||
tl_track = self.get_current_tl_track()
|
||||
if tl_track is None:
|
||||
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(
|
||||
'track_playback_ended',
|
||||
tl_track=self.get_current_tl_track(),
|
||||
tl_track=tl_track,
|
||||
time_position=time_position_before_stop)
|
||||
|
||||
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)
|
||||
|
||||
def _trigger_seeked(self, time_position):
|
||||
# TODO: Trigger this from audio events?
|
||||
logger.debug('Triggering seeked event')
|
||||
listener.CoreListener.send('seeked', time_position=time_position)
|
||||
|
||||
@ -2,9 +2,9 @@ from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import urlparse
|
||||
|
||||
from mopidy import exceptions
|
||||
from mopidy.compat import urllib
|
||||
from mopidy.core import listener
|
||||
from mopidy.internal import deprecation, validation
|
||||
from mopidy.models import Playlist, Ref
|
||||
@ -33,6 +33,16 @@ class PlaylistsController(object):
|
||||
self.backends = backends
|
||||
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):
|
||||
"""
|
||||
Get a list of the currently available playlists.
|
||||
@ -81,7 +91,7 @@ class PlaylistsController(object):
|
||||
"""
|
||||
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)
|
||||
|
||||
if not backend:
|
||||
@ -175,7 +185,7 @@ class PlaylistsController(object):
|
||||
"""
|
||||
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)
|
||||
if not backend:
|
||||
return None # TODO: error reporting to user
|
||||
@ -229,7 +239,7 @@ class PlaylistsController(object):
|
||||
:type uri: string
|
||||
: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)
|
||||
if not backend:
|
||||
return None
|
||||
@ -303,7 +313,7 @@ class PlaylistsController(object):
|
||||
if playlist.uri is None:
|
||||
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)
|
||||
if not backend:
|
||||
return None
|
||||
|
||||
@ -16,7 +16,7 @@ class TracklistController(object):
|
||||
|
||||
def __init__(self, core):
|
||||
self.core = core
|
||||
self._next_tlid = 0
|
||||
self._next_tlid = 1
|
||||
self._tl_tracks = []
|
||||
self._version = 0
|
||||
|
||||
@ -218,7 +218,7 @@ class TracklistController(object):
|
||||
The *tlid* parameter
|
||||
"""
|
||||
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:
|
||||
tl_track = self.core.playback.get_current_tl_track()
|
||||
@ -318,10 +318,11 @@ class TracklistController(object):
|
||||
return self._shuffled[0]
|
||||
return None
|
||||
|
||||
if tl_track is None:
|
||||
next_index = self.index(tl_track)
|
||||
if next_index is None:
|
||||
next_index = 0
|
||||
else:
|
||||
next_index = self.index(tl_track) + 1
|
||||
next_index += 1
|
||||
|
||||
if self.get_repeat():
|
||||
next_index %= len(self._tl_tracks)
|
||||
@ -620,12 +621,14 @@ class TracklistController(object):
|
||||
def _mark_unplayable(self, tl_track):
|
||||
"""Internal method for :class:`mopidy.core.PlaybackController`."""
|
||||
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:
|
||||
self._shuffled.remove(tl_track)
|
||||
|
||||
def _mark_played(self, tl_track):
|
||||
"""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]})
|
||||
return True
|
||||
return False
|
||||
|
||||
@ -198,7 +198,12 @@ def load_extensions():
|
||||
|
||||
for entry_point in pkg_resources.iter_entry_points('mopidy.ext'):
|
||||
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:
|
||||
if not issubclass(extension_class, Extension):
|
||||
|
||||
@ -7,7 +7,7 @@ import sys
|
||||
import urllib2
|
||||
|
||||
from mopidy import backend, exceptions, models
|
||||
from mopidy.audio import scan, utils
|
||||
from mopidy.audio import scan, tags
|
||||
from mopidy.internal import path
|
||||
|
||||
|
||||
@ -83,7 +83,7 @@ class FileLibraryProvider(backend.LibraryProvider):
|
||||
|
||||
try:
|
||||
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)
|
||||
except exceptions.ScannerError as e:
|
||||
logger.warning('Failed looking up %s: %s', uri, e)
|
||||
|
||||
@ -57,10 +57,12 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener):
|
||||
|
||||
if self.zeroconf_name:
|
||||
self.zeroconf_http = zeroconf.Zeroconf(
|
||||
stype='_http._tcp', name=self.zeroconf_name,
|
||||
name=self.zeroconf_name,
|
||||
stype='_http._tcp',
|
||||
port=self.port)
|
||||
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)
|
||||
self.zeroconf_http.publish()
|
||||
self.zeroconf_mopidy_http.publish()
|
||||
|
||||
@ -4,6 +4,9 @@ import contextlib
|
||||
import re
|
||||
import warnings
|
||||
|
||||
from mopidy import compat
|
||||
|
||||
|
||||
# Messages used in deprecation warnings are collected here so we can target
|
||||
# them easily when ignoring warnings.
|
||||
_MESSAGES = {
|
||||
@ -74,7 +77,7 @@ def warn(msg_id, pending=False):
|
||||
@contextlib.contextmanager
|
||||
def ignore(ids=None):
|
||||
with warnings.catch_warnings():
|
||||
if isinstance(ids, basestring):
|
||||
if isinstance(ids, compat.string_types):
|
||||
ids = [ids]
|
||||
|
||||
if ids:
|
||||
|
||||
@ -7,11 +7,8 @@ import sys
|
||||
|
||||
import pkg_resources
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst # noqa
|
||||
|
||||
from mopidy.internal import formatting
|
||||
from mopidy.internal.gi import Gst, gi
|
||||
|
||||
|
||||
def format_dependency_list(adapters=None):
|
||||
@ -110,8 +107,7 @@ def pkg_info(project_name=None, include_extras=False):
|
||||
|
||||
def gstreamer_info():
|
||||
other = []
|
||||
other.append('Python wrapper: gst-python %s' % (
|
||||
'.'.join(map(str, gst.get_pygst_version()))))
|
||||
other.append('Python wrapper: python-gi %s' % gi.__version__)
|
||||
|
||||
found_elements = []
|
||||
missing_elements = []
|
||||
@ -135,8 +131,8 @@ def gstreamer_info():
|
||||
|
||||
return {
|
||||
'name': 'GStreamer',
|
||||
'version': '.'.join(map(str, gst.get_gst_version())),
|
||||
'path': os.path.dirname(gst.__file__),
|
||||
'version': '.'.join(map(str, Gst.version())),
|
||||
'path': os.path.dirname(gi.__file__),
|
||||
'other': '\n'.join(other),
|
||||
}
|
||||
|
||||
@ -165,10 +161,10 @@ def _gstreamer_check_elements():
|
||||
'flump3dec',
|
||||
'id3demux',
|
||||
'id3v2mux',
|
||||
'lame',
|
||||
'lamemp3enc',
|
||||
'mad',
|
||||
'mp3parse',
|
||||
# 'mpg123audiodec', # Only available in GStreamer 1.x
|
||||
'mpegaudioparse',
|
||||
'mpg123audiodec',
|
||||
|
||||
# Ogg Vorbis encoding and decoding
|
||||
'vorbisdec',
|
||||
@ -187,6 +183,6 @@ def _gstreamer_check_elements():
|
||||
]
|
||||
known_elements = [
|
||||
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 [
|
||||
(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 threading
|
||||
|
||||
import gobject
|
||||
|
||||
import pykka
|
||||
|
||||
from mopidy.internal import encoding
|
||||
from mopidy.internal.gi import GObject
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -67,7 +66,7 @@ def format_hostname(hostname):
|
||||
|
||||
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,
|
||||
max_connections=5, timeout=30):
|
||||
@ -87,7 +86,7 @@ class Server(object):
|
||||
return sock
|
||||
|
||||
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):
|
||||
try:
|
||||
@ -132,7 +131,7 @@ class Server(object):
|
||||
class Connection(object):
|
||||
# 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
|
||||
# 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
|
||||
# false return value would only tell us that what we thought was registered
|
||||
@ -211,14 +210,14 @@ class Connection(object):
|
||||
return
|
||||
|
||||
self.disable_timeout()
|
||||
self.timeout_id = gobject.timeout_add_seconds(
|
||||
self.timeout_id = GObject.timeout_add_seconds(
|
||||
self.timeout, self.timeout_callback)
|
||||
|
||||
def disable_timeout(self):
|
||||
"""Deactivate timeout mechanism."""
|
||||
if self.timeout_id is None:
|
||||
return
|
||||
gobject.source_remove(self.timeout_id)
|
||||
GObject.source_remove(self.timeout_id)
|
||||
self.timeout_id = None
|
||||
|
||||
def enable_recv(self):
|
||||
@ -226,9 +225,9 @@ class Connection(object):
|
||||
return
|
||||
|
||||
try:
|
||||
self.recv_id = gobject.io_add_watch(
|
||||
self.recv_id = GObject.io_add_watch(
|
||||
self.sock.fileno(),
|
||||
gobject.IO_IN | gobject.IO_ERR | gobject.IO_HUP,
|
||||
GObject.IO_IN | GObject.IO_ERR | GObject.IO_HUP,
|
||||
self.recv_callback)
|
||||
except socket.error as e:
|
||||
self.stop('Problem with connection: %s' % e)
|
||||
@ -236,7 +235,7 @@ class Connection(object):
|
||||
def disable_recv(self):
|
||||
if self.recv_id is None:
|
||||
return
|
||||
gobject.source_remove(self.recv_id)
|
||||
GObject.source_remove(self.recv_id)
|
||||
self.recv_id = None
|
||||
|
||||
def enable_send(self):
|
||||
@ -244,9 +243,9 @@ class Connection(object):
|
||||
return
|
||||
|
||||
try:
|
||||
self.send_id = gobject.io_add_watch(
|
||||
self.send_id = GObject.io_add_watch(
|
||||
self.sock.fileno(),
|
||||
gobject.IO_OUT | gobject.IO_ERR | gobject.IO_HUP,
|
||||
GObject.IO_OUT | GObject.IO_ERR | GObject.IO_HUP,
|
||||
self.send_callback)
|
||||
except socket.error as e:
|
||||
self.stop('Problem with connection: %s' % e)
|
||||
@ -255,11 +254,11 @@ class Connection(object):
|
||||
if self.send_id is None:
|
||||
return
|
||||
|
||||
gobject.source_remove(self.send_id)
|
||||
GObject.source_remove(self.send_id)
|
||||
self.send_id = None
|
||||
|
||||
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)
|
||||
return True
|
||||
|
||||
@ -283,7 +282,7 @@ class Connection(object):
|
||||
return True
|
||||
|
||||
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)
|
||||
return True
|
||||
|
||||
|
||||
@ -5,11 +5,9 @@ import os
|
||||
import stat
|
||||
import string
|
||||
import threading
|
||||
import urllib
|
||||
import urlparse
|
||||
|
||||
from mopidy import compat, exceptions
|
||||
from mopidy.compat import queue
|
||||
from mopidy.compat import queue, urllib
|
||||
from mopidy.internal import encoding, xdg
|
||||
|
||||
|
||||
@ -61,8 +59,8 @@ def path_to_uri(path):
|
||||
"""
|
||||
if isinstance(path, compat.text_type):
|
||||
path = path.encode('utf-8')
|
||||
path = urllib.quote(path)
|
||||
return urlparse.urlunsplit((b'file', b'', path, b'', b''))
|
||||
path = urllib.parse.quote(path)
|
||||
return urllib.parse.urlunsplit((b'file', b'', path, b'', b''))
|
||||
|
||||
|
||||
def uri_to_path(uri):
|
||||
@ -78,7 +76,7 @@ def uri_to_path(uri):
|
||||
"""
|
||||
if isinstance(uri, compat.text_type):
|
||||
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):
|
||||
|
||||
@ -2,10 +2,6 @@ from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import io
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst # noqa
|
||||
|
||||
from mopidy.compat import configparser
|
||||
from mopidy.internal import validation
|
||||
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import logging
|
||||
import signal
|
||||
import threading
|
||||
|
||||
from pykka import ActorDeadError
|
||||
from pykka.registry import ActorRegistry
|
||||
import pykka
|
||||
|
||||
from mopidy.compat import thread
|
||||
|
||||
@ -13,32 +11,35 @@ from mopidy.compat import thread
|
||||
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():
|
||||
logger.debug('Interrupting main...')
|
||||
thread.interrupt_main()
|
||||
logger.debug('Interrupted main')
|
||||
|
||||
|
||||
def exit_handler(signum, frame):
|
||||
"""A :mod:`signal` handler which will exit the program on signal."""
|
||||
logger.info('Got %s signal', SIGNALS[signum])
|
||||
def sigterm_handler(signum, frame):
|
||||
"""A :mod:`signal` handler which will exit the program on signal.
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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__)
|
||||
for actor in actors:
|
||||
actor.stop()
|
||||
|
||||
|
||||
def stop_remaining_actors():
|
||||
num_actors = len(ActorRegistry.get_all())
|
||||
num_actors = len(pykka.ActorRegistry.get_all())
|
||||
while num_actors:
|
||||
logger.error(
|
||||
'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,
|
||||
', '.join([t.name for t in threading.enumerate()]))
|
||||
logger.debug('Stopping %d actor(s)...', num_actors)
|
||||
ActorRegistry.stop_all()
|
||||
num_actors = len(ActorRegistry.get_all())
|
||||
pykka.ActorRegistry.stop_all()
|
||||
num_actors = len(pykka.ActorRegistry.get_all())
|
||||
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 time
|
||||
|
||||
from mopidy.internal import log
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
TRACE = logging.getLevelName('TRACE')
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def time_logger(name, level=TRACE):
|
||||
def time_logger(name, level=log.TRACE_LOG_LEVEL):
|
||||
start = time.time()
|
||||
yield
|
||||
logger.log(level, '%s took %dms', name, (time.time() - start) * 1000)
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import collections
|
||||
import urlparse
|
||||
|
||||
from mopidy import compat, exceptions
|
||||
from mopidy.compat import urllib
|
||||
|
||||
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):
|
||||
if not isinstance(arg, (int, long)):
|
||||
if not isinstance(arg, compat.integer_types):
|
||||
raise exceptions.ValidationError('Expected an integer, not %r' % arg)
|
||||
elif min is not None and arg < min:
|
||||
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}'):
|
||||
if not isinstance(arg, compat.string_types):
|
||||
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))
|
||||
|
||||
|
||||
|
||||
@ -22,6 +22,6 @@ def get_git_version():
|
||||
if process.wait() != 0:
|
||||
raise EnvironmentError('Execution of "git describe" failed')
|
||||
version = process.stdout.read().strip()
|
||||
if version.startswith('v'):
|
||||
if version.startswith(b'v'):
|
||||
version = version[1:]
|
||||
return version
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import ConfigParser as configparser
|
||||
import io
|
||||
import os
|
||||
|
||||
from mopidy.compat import configparser
|
||||
|
||||
|
||||
def get_dirs():
|
||||
"""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.
|
||||
"""
|
||||
|
||||
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):
|
||||
return {}
|
||||
|
||||
with open(dirs_file, 'rb') as fh:
|
||||
data = fh.read()
|
||||
data = fh.read().decode('utf-8')
|
||||
|
||||
data = b'[XDG_USER_DIRS]\n' + data
|
||||
data = data.replace(b'$HOME', os.path.expanduser(b'~'))
|
||||
data = data.replace(b'"', b'')
|
||||
data = '[XDG_USER_DIRS]\n' + data
|
||||
data = data.replace('$HOME', os.path.expanduser('~'))
|
||||
data = data.replace('"', '')
|
||||
|
||||
config = configparser.RawConfigParser()
|
||||
config.readfp(io.BytesIO(data))
|
||||
config.readfp(io.StringIO(data))
|
||||
|
||||
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}
|
||||
|
||||
@ -7,16 +7,6 @@ import pykka
|
||||
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):
|
||||
listeners = pykka.ActorRegistry.get_by_class(cls)
|
||||
logger.debug('Sending %s to %s: %s', event, cls.__name__, kwargs)
|
||||
@ -55,4 +45,5 @@ class Listener(object):
|
||||
getattr(self, event)(**kwargs)
|
||||
except Exception:
|
||||
# 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['library'] = config.String()
|
||||
schema['media_dir'] = config.Path()
|
||||
schema['data_dir'] = config.Path(optional=True)
|
||||
schema['data_dir'] = config.Deprecated()
|
||||
schema['playlists_dir'] = config.Deprecated()
|
||||
schema['tag_cache_file'] = config.Deprecated()
|
||||
schema['scan_timeout'] = config.Integer(
|
||||
|
||||
@ -6,7 +6,7 @@ import os
|
||||
import time
|
||||
|
||||
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.local import translator
|
||||
|
||||
@ -140,18 +140,18 @@ class ScanCommand(commands.Command):
|
||||
relpath = translator.local_track_uri_to_path(uri, media_dir)
|
||||
file_uri = path.path_to_uri(os.path.join(media_dir, relpath))
|
||||
result = scanner.scan(file_uri)
|
||||
tags, duration = result.tags, result.duration
|
||||
if not result.playable:
|
||||
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',
|
||||
uri, MIN_DURATION_MS)
|
||||
else:
|
||||
mtime = file_mtimes.get(os.path.join(media_dir, relpath))
|
||||
track = utils.convert_tags_to_track(tags).replace(
|
||||
uri=uri, length=duration, last_modified=mtime)
|
||||
track = tags.convert_tags_to_track(result.tags).replace(
|
||||
uri=uri, length=result.duration, last_modified=mtime)
|
||||
if library.add_supports_tags_and_duration:
|
||||
library.add(track, tags=tags, duration=duration)
|
||||
library.add(
|
||||
track, tags=result.tags, duration=result.duration)
|
||||
else:
|
||||
library.add(track)
|
||||
logger.debug('Added %s', track.uri)
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
enabled = true
|
||||
library = json
|
||||
media_dir = $XDG_MUSIC_DIR
|
||||
data_dir = $XDG_DATA_DIR/mopidy/local
|
||||
scan_timeout = 1000
|
||||
scan_flush_threshold = 100
|
||||
scan_follow_symlinks = false
|
||||
|
||||
@ -42,11 +42,11 @@ def path_to_local_track_uri(relpath):
|
||||
URI."""
|
||||
if isinstance(relpath, compat.text_type):
|
||||
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):
|
||||
"""Convert path relative to :confval:`local/media_dir` directory URI."""
|
||||
if isinstance(relpath, compat.text_type):
|
||||
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):
|
||||
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)
|
||||
return schema
|
||||
|
||||
def setup(self, registry):
|
||||
from .actor import M3UBackend
|
||||
|
||||
from .backend import 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]
|
||||
enabled = true
|
||||
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 operator
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from mopidy import backend
|
||||
from mopidy.m3u import translator
|
||||
from mopidy.models import Playlist, Ref
|
||||
|
||||
from . import Extension, translator
|
||||
|
||||
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):
|
||||
|
||||
# TODO: currently this only handles UNIX file systems
|
||||
_invalid_filename_chars = re.compile(r'[/]')
|
||||
def __init__(self, backend, config):
|
||||
super(M3UPlaylistsProvider, self).__init__(backend)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(M3UPlaylistsProvider, self).__init__(*args, **kwargs)
|
||||
|
||||
self._playlists_dir = self.backend._playlists_dir
|
||||
self._playlists = {}
|
||||
self.refresh()
|
||||
ext_config = config[Extension.ext_name]
|
||||
if ext_config['playlists_dir'] is None:
|
||||
self._playlists_dir = Extension.get_data_dir(config)
|
||||
else:
|
||||
self._playlists_dir = ext_config['playlists_dir']
|
||||
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):
|
||||
refs = [
|
||||
Ref.playlist(uri=pl.uri, name=pl.name)
|
||||
for pl in self._playlists.values()]
|
||||
return sorted(refs, key=operator.attrgetter('name'))
|
||||
|
||||
def get_items(self, uri):
|
||||
playlist = self._playlists.get(uri)
|
||||
if playlist is None:
|
||||
return None
|
||||
return [Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks]
|
||||
result = []
|
||||
for entry in os.listdir(self._playlists_dir):
|
||||
if not entry.endswith((b'.m3u', b'.m3u8')):
|
||||
continue
|
||||
elif not os.path.isfile(self._abspath(entry)):
|
||||
continue
|
||||
else:
|
||||
result.append(translator.path_to_ref(entry))
|
||||
result.sort(key=operator.attrgetter('name'))
|
||||
return result
|
||||
|
||||
def create(self, name):
|
||||
playlist = self._save_m3u(Playlist(name=name))
|
||||
self._playlists[playlist.uri] = playlist
|
||||
logger.info('Created playlist %s', playlist.uri)
|
||||
return playlist
|
||||
path = translator.path_from_name(name.strip(), self._default_extension)
|
||||
try:
|
||||
with self._open(path, 'w'):
|
||||
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):
|
||||
if uri in self._playlists:
|
||||
path = translator.playlist_uri_to_path(uri, self._playlists_dir)
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
else:
|
||||
logger.warning(
|
||||
'Trying to delete missing playlist file %s', path)
|
||||
del self._playlists[uri]
|
||||
logger.info('Deleted playlist %s', uri)
|
||||
path = translator.uri_to_path(uri)
|
||||
try:
|
||||
os.remove(self._abspath(path))
|
||||
except EnvironmentError as e:
|
||||
log_environment_error('Error deleting playlist %s' % uri, e)
|
||||
|
||||
def get_items(self, uri):
|
||||
path = translator.uri_to_path(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:
|
||||
logger.warning('Trying to delete unknown playlist %s', uri)
|
||||
return items
|
||||
|
||||
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):
|
||||
playlists = {}
|
||||
|
||||
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?
|
||||
pass # nothing to do
|
||||
|
||||
def save(self, playlist):
|
||||
assert playlist.uri, 'Cannot save playlist without URI'
|
||||
assert playlist.uri in self._playlists, \
|
||||
'Cannot save playlist with unknown URI: %s' % playlist.uri
|
||||
|
||||
original_uri = playlist.uri
|
||||
playlist = self._save_m3u(playlist)
|
||||
if playlist.uri != original_uri and original_uri in self._playlists:
|
||||
self.delete(original_uri)
|
||||
self._playlists[playlist.uri] = playlist
|
||||
return playlist
|
||||
|
||||
def _sanitize_m3u_name(self, name, encoding=sys.getfilesystemencoding()):
|
||||
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))
|
||||
path = translator.uri_to_path(playlist.uri)
|
||||
name = translator.name_from_path(path)
|
||||
try:
|
||||
with self._open(path, 'w') as fp:
|
||||
translator.dump_items(playlist.tracks, fp)
|
||||
if playlist.name and playlist.name != name:
|
||||
opath, ext = os.path.splitext(path)
|
||||
path = translator.path_from_name(playlist.name.strip()) + ext
|
||||
os.rename(self._abspath(opath + ext), self._abspath(path))
|
||||
mtime = os.path.getmtime(self._abspath(path))
|
||||
except EnvironmentError as e:
|
||||
log_environment_error('Error saving playlist %s' % playlist.uri, e)
|
||||
else:
|
||||
raise ValueError('M3U playlist needs name or URI')
|
||||
translator.save_m3u(path, playlist.tracks, 'latin1')
|
||||
# assert playlist name matches file name/uri
|
||||
return playlist.replace(uri=uri, name=name)
|
||||
return translator.playlist(path, playlist.tracks, mtime)
|
||||
|
||||
def _abspath(self, path):
|
||||
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 re
|
||||
import urllib
|
||||
import urlparse
|
||||
|
||||
from mopidy import compat
|
||||
from mopidy.internal import encoding, path
|
||||
from mopidy.models import Track
|
||||
from mopidy import models
|
||||
|
||||
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):
|
||||
if not uri.startswith('m3u:'):
|
||||
raise ValueError('Invalid URI %s' % uri)
|
||||
file_path = path.uri_to_path(uri)
|
||||
return os.path.join(playlists_dir, file_path)
|
||||
def path_to_uri(path, scheme=Extension.ext_name):
|
||||
"""Convert file path to URI."""
|
||||
assert isinstance(path, bytes), 'Mopidy paths should be bytes'
|
||||
uripath = quote_from_bytes(os.path.normpath(path))
|
||||
return urlunsplit((scheme, None, uripath, None, None))
|
||||
|
||||
|
||||
def path_to_playlist_uri(relpath):
|
||||
"""Convert path relative to playlists_dir to M3U URI."""
|
||||
if isinstance(relpath, compat.text_type):
|
||||
relpath = relpath.encode('utf-8')
|
||||
return b'm3u:%s' % urllib.quote(relpath)
|
||||
def uri_to_path(uri):
|
||||
"""Convert URI to file path."""
|
||||
# TODO: decide on Unicode vs. bytes for URIs
|
||||
return unquote_to_bytes(urlsplit(uri).path)
|
||||
|
||||
|
||||
def m3u_extinf_to_track(line):
|
||||
"""Convert extended M3U directive to track template."""
|
||||
m = M3U_EXTINF_RE.match(line)
|
||||
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 = []
|
||||
def name_from_path(path):
|
||||
"""Extract name from file path."""
|
||||
name, _ = os.path.splitext(os.path.basename(path))
|
||||
try:
|
||||
with codecs.open(file_path, 'rb', file_encoding, 'replace') as m3u:
|
||||
contents = m3u.readlines()
|
||||
except IOError as error:
|
||||
logger.warning('Couldn\'t open m3u: %s', encoding.locale_decode(error))
|
||||
return tracks
|
||||
return fsdecode(name)
|
||||
except UnicodeError:
|
||||
return None
|
||||
|
||||
if not contents:
|
||||
return tracks
|
||||
|
||||
# Strip newlines left by codecs
|
||||
contents = [line.strip() for line in contents]
|
||||
def path_from_name(name, ext=None, sep='|'):
|
||||
"""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()
|
||||
for line in contents:
|
||||
def path_to_ref(path):
|
||||
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 extended and line.startswith('#EXTINF'):
|
||||
track = m3u_extinf_to_track(line)
|
||||
if line.startswith('#EXTINF:'):
|
||||
name = line.partition(',')[2]
|
||||
continue
|
||||
|
||||
if urlparse.urlsplit(line).scheme:
|
||||
tracks.append(track.replace(uri=line))
|
||||
elif os.path.normpath(line) == os.path.abspath(line):
|
||||
uri = path.path_to_uri(line)
|
||||
tracks.append(track.replace(uri=uri))
|
||||
elif media_dir is not None:
|
||||
uri = path.path_to_uri(os.path.join(media_dir, line))
|
||||
tracks.append(track.replace(uri=uri))
|
||||
|
||||
track = Track()
|
||||
return tracks
|
||||
elif not urlsplit(line).scheme:
|
||||
path = os.path.join(basedir, fsencode(line))
|
||||
if not name:
|
||||
name = name_from_path(path)
|
||||
uri = path_to_uri(path, scheme='file')
|
||||
else:
|
||||
uri = line # do *not* extract name from (stream?) URI path
|
||||
refs.append(models.Ref.track(uri=uri, name=name))
|
||||
name = None
|
||||
return refs
|
||||
|
||||
|
||||
def save_m3u(filename, tracks, encoding='latin1', errors='replace'):
|
||||
extended = any(track.name for track in tracks)
|
||||
# codecs.open() always uses binary mode, just being explicit here
|
||||
with codecs.open(filename, 'wb', encoding, errors) as m3u:
|
||||
if extended:
|
||||
m3u.write('#EXTM3U' + os.linesep)
|
||||
for track in tracks:
|
||||
if extended and track.name:
|
||||
m3u.write('#EXTINF:%d,%s%s' % (
|
||||
track.length // 1000 if track.length else -1,
|
||||
track.name,
|
||||
os.linesep))
|
||||
m3u.write(track.uri + os.linesep)
|
||||
def dump_items(items, fp):
|
||||
if any(item.name for item in items):
|
||||
print('#EXTM3U', file=fp)
|
||||
for item in items:
|
||||
if item.name:
|
||||
print('#EXTINF:-1,%s' % item.name, file=fp)
|
||||
# TODO: convert file URIs to (relative) paths?
|
||||
if isinstance(item.uri, bytes):
|
||||
print(item.uri.decode('utf-8'), file=fp)
|
||||
else:
|
||||
print(item.uri, file=fp)
|
||||
|
||||
|
||||
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
|
||||
def send(event, **kwargs):
|
||||
"""Helper to allow calling of mixer listener events"""
|
||||
listener.send_async(MixerListener, event, **kwargs)
|
||||
listener.send(MixerListener, event, **kwargs)
|
||||
|
||||
def volume_changed(self, volume):
|
||||
"""
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from mopidy import compat
|
||||
from mopidy.models import fields
|
||||
from mopidy.models.immutable import ImmutableObject, ValidatedImmutableObject
|
||||
from mopidy.models.serialize import ModelJSONEncoder, model_json_decoder
|
||||
@ -145,6 +146,10 @@ class Album(ValidatedImmutableObject):
|
||||
:type musicbrainz_id: string
|
||||
:param images: album image URIs
|
||||
: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.
|
||||
@ -169,10 +174,10 @@ class Album(ValidatedImmutableObject):
|
||||
musicbrainz_id = fields.Identifier()
|
||||
|
||||
#: 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()
|
||||
# as it doesn't preserve order. I'm deferring this issue until we got
|
||||
# actual usage of this field with more than one image.
|
||||
#:
|
||||
#: .. deprecated:: 1.2
|
||||
#: Use :meth:`mopidy.core.LibraryController.get_images` instead.
|
||||
images = fields.Collection(type=compat.string_types, container=frozenset)
|
||||
|
||||
|
||||
class Track(ValidatedImmutableObject):
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from mopidy import compat
|
||||
|
||||
|
||||
class Field(object):
|
||||
|
||||
@ -69,7 +71,7 @@ class String(Field):
|
||||
# TODO: normalize to unicode?
|
||||
# TODO: only allow unicode?
|
||||
# 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):
|
||||
@ -93,7 +95,7 @@ class Identifier(String):
|
||||
:param default: default value for field
|
||||
"""
|
||||
def validate(self, value):
|
||||
return intern(str(super(Identifier, self).validate(value)))
|
||||
return compat.intern(str(super(Identifier, self).validate(value)))
|
||||
|
||||
|
||||
class URI(Identifier):
|
||||
@ -119,7 +121,8 @@ class Integer(Field):
|
||||
def __init__(self, default=None, min=None, max=None):
|
||||
self._min = min
|
||||
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):
|
||||
value = super(Integer, self).validate(value)
|
||||
@ -144,7 +147,7 @@ class Collection(Field):
|
||||
super(Collection, self).__init__(type=type, default=container())
|
||||
|
||||
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'
|
||||
% (self._name, self._type.__name__, value))
|
||||
for v in value:
|
||||
|
||||
@ -112,7 +112,7 @@ class ImmutableObject(object):
|
||||
for key, value in kwargs.items():
|
||||
if not self._is_valid_field(key):
|
||||
raise TypeError(
|
||||
'copy() got an unexpected keyword argument "%s"' % key)
|
||||
'replace() got an unexpected keyword argument "%s"' % key)
|
||||
other._set_field(key, value)
|
||||
return other
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@ class Extension(ext.Extension):
|
||||
schema['connection_timeout'] = config.Integer(minimum=1)
|
||||
schema['zeroconf'] = config.String(optional=True)
|
||||
schema['command_blacklist'] = config.List(optional=True)
|
||||
schema['default_playlist_scheme'] = config.String()
|
||||
return schema
|
||||
|
||||
def validate_environment(self):
|
||||
|
||||
@ -4,13 +4,30 @@ import logging
|
||||
|
||||
import pykka
|
||||
|
||||
from mopidy import exceptions, zeroconf
|
||||
from mopidy import exceptions, listener, zeroconf
|
||||
from mopidy.core import CoreListener
|
||||
from mopidy.internal import encoding, network, process
|
||||
from mopidy.mpd import session, uri_mapper
|
||||
|
||||
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):
|
||||
|
||||
@ -24,6 +41,9 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
|
||||
self.zeroconf_name = config['mpd']['zeroconf']
|
||||
self.zeroconf_service = None
|
||||
|
||||
self._setup_server(config, core)
|
||||
|
||||
def _setup_server(self, config, core):
|
||||
try:
|
||||
network.Server(
|
||||
self.hostname, self.port,
|
||||
@ -45,7 +65,8 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
|
||||
def on_start(self):
|
||||
if self.zeroconf_name:
|
||||
self.zeroconf_service = zeroconf.Zeroconf(
|
||||
stype='_mpd._tcp', name=self.zeroconf_name,
|
||||
name=self.zeroconf_name,
|
||||
stype='_mpd._tcp',
|
||||
port=self.port)
|
||||
self.zeroconf_service.publish()
|
||||
|
||||
@ -55,28 +76,13 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
|
||||
|
||||
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):
|
||||
listeners = pykka.ActorRegistry.get_by_class(session.MpdSession)
|
||||
for listener in listeners:
|
||||
getattr(listener.proxy(), 'on_idle')(subsystem)
|
||||
|
||||
def playback_state_changed(self, old_state, new_state):
|
||||
self.send_idle('player')
|
||||
|
||||
def tracklist_changed(self):
|
||||
self.send_idle('playlist')
|
||||
|
||||
def options_changed(self):
|
||||
self.send_idle('options')
|
||||
|
||||
def volume_changed(self, 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')
|
||||
if subsystem:
|
||||
listener.send(session.MpdSession, subsystem)
|
||||
|
||||
@ -80,10 +80,23 @@ class MpdNoExistError(MpdAckError):
|
||||
error_code = MpdAckError.ACK_ERROR_NO_EXIST
|
||||
|
||||
|
||||
class MpdExistError(MpdAckError):
|
||||
error_code = MpdAckError.ACK_ERROR_EXIST
|
||||
|
||||
|
||||
class MpdSystemError(MpdAckError):
|
||||
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):
|
||||
error_code = 0
|
||||
|
||||
@ -92,6 +105,27 @@ class MpdNotImplemented(MpdAckError):
|
||||
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):
|
||||
# NOTE: This is a custom error for Mopidy that does not exist in MPD.
|
||||
error_code = 0
|
||||
|
||||
@ -7,3 +7,4 @@ max_connections = 20
|
||||
connection_timeout = 60
|
||||
zeroconf = Mopidy MPD server on $hostname
|
||||
command_blacklist = listall,listallinfo
|
||||
default_playlist_scheme = m3u
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import urlparse
|
||||
|
||||
from mopidy.compat import urllib
|
||||
from mopidy.internal import deprecation
|
||||
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
|
||||
# jumping through browse...
|
||||
if urlparse.urlparse(uri).scheme != '':
|
||||
if urllib.parse.urlparse(uri).scheme != '':
|
||||
if context.core.tracklist.add(uris=[uri]).get():
|
||||
return
|
||||
|
||||
|
||||
@ -137,13 +137,13 @@ def pause(context, state=None):
|
||||
|
||||
playback_state = context.core.playback.get_state().get()
|
||||
if (playback_state == PlaybackState.PLAYING):
|
||||
context.core.playback.pause()
|
||||
context.core.playback.pause().get()
|
||||
elif (playback_state == PlaybackState.PAUSED):
|
||||
context.core.playback.resume()
|
||||
context.core.playback.resume().get()
|
||||
elif state:
|
||||
context.core.playback.pause()
|
||||
context.core.playback.pause().get()
|
||||
else:
|
||||
context.core.playback.resume()
|
||||
context.core.playback.resume().get()
|
||||
|
||||
|
||||
@protocol.commands.add('play', songpos=protocol.INT)
|
||||
|
||||
@ -1,10 +1,20 @@
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
import warnings
|
||||
|
||||
from mopidy.compat import urllib
|
||||
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')
|
||||
def listplaylist(context, name):
|
||||
@ -135,7 +145,7 @@ def load(context, name, playlist_slice=slice(0, None)):
|
||||
|
||||
|
||||
@protocol.commands.add('playlistadd')
|
||||
def playlistadd(context, name, uri):
|
||||
def playlistadd(context, name, track_uri):
|
||||
"""
|
||||
*musicpd.org, stored playlists section:*
|
||||
|
||||
@ -145,7 +155,64 @@ def playlistadd(context, name, uri):
|
||||
|
||||
``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')
|
||||
@ -156,8 +223,20 @@ def playlistclear(context, name):
|
||||
``playlistclear {NAME}``
|
||||
|
||||
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)
|
||||
@ -169,7 +248,25 @@ def playlistdelete(context, name, songpos):
|
||||
|
||||
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(
|
||||
@ -189,7 +286,31 @@ def playlistmove(context, name, from_pos, to_pos):
|
||||
documentation, but just the ``SONGPOS`` to move *from*, i.e.
|
||||
``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')
|
||||
@ -201,7 +322,31 @@ def rename(context, old_name, new_name):
|
||||
|
||||
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')
|
||||
@ -213,7 +358,11 @@ def rm(context, name):
|
||||
|
||||
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')
|
||||
@ -226,4 +375,17 @@ def save(context, name):
|
||||
Saves the current playlist to ``NAME.m3u`` in the playlist
|
||||
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)
|
||||
|
||||
def on_idle(self, subsystem):
|
||||
def on_event(self, subsystem):
|
||||
self.dispatcher.handle_idle(subsystem)
|
||||
|
||||
def decode(self, line):
|
||||
|
||||
@ -71,7 +71,7 @@ class MpdUriMapper(object):
|
||||
"""
|
||||
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()
|
||||
return self._uri_from_name.get(name)
|
||||
|
||||
|
||||
@ -4,12 +4,12 @@ import fnmatch
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import urlparse
|
||||
|
||||
import pykka
|
||||
|
||||
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.models import Track
|
||||
|
||||
@ -25,10 +25,19 @@ class StreamBackend(pykka.ThreadingActor, backend.Backend):
|
||||
timeout=config['stream']['timeout'],
|
||||
proxy_config=config['proxy'])
|
||||
|
||||
self.library = StreamLibraryProvider(
|
||||
backend=self, blacklist=config['stream']['metadata_blacklist'])
|
||||
self.playback = StreamPlaybackProvider(
|
||||
audio=audio, backend=self, config=config)
|
||||
self._session = http.get_requests_session(
|
||||
proxy_config=config['proxy'],
|
||||
user_agent='%s/%s' % (
|
||||
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.uri_schemes = audio_lib.supported_uri_schemes(
|
||||
@ -43,27 +52,23 @@ class StreamBackend(pykka.ThreadingActor, backend.Backend):
|
||||
|
||||
|
||||
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):
|
||||
if urlparse.urlsplit(uri).scheme not in self.backend.uri_schemes:
|
||||
if urllib.parse.urlsplit(uri).scheme not in self.backend.uri_schemes:
|
||||
return []
|
||||
|
||||
if self._blacklist_re.match(uri):
|
||||
if self.backend._blacklist_re.match(uri):
|
||||
logger.debug('URI matched metadata lookup blacklist: %s', uri)
|
||||
return [Track(uri=uri)]
|
||||
|
||||
try:
|
||||
result = self._scanner.scan(uri)
|
||||
track = utils.convert_tags_to_track(result.tags).replace(
|
||||
uri=uri, length=result.duration)
|
||||
except exceptions.ScannerError as e:
|
||||
logger.warning('Problem looking up %s: %s', uri, e)
|
||||
_, scan_result = _unwrap_stream(
|
||||
uri, timeout=self.backend._timeout, scanner=self.backend._scanner,
|
||||
requests_session=self.backend._session)
|
||||
|
||||
if scan_result:
|
||||
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)
|
||||
|
||||
return [track]
|
||||
@ -71,23 +76,21 @@ class StreamLibraryProvider(backend.LibraryProvider):
|
||||
|
||||
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):
|
||||
return _unwrap_stream(
|
||||
uri,
|
||||
timeout=self._config['stream']['timeout'],
|
||||
scanner=self._scanner,
|
||||
requests_session=self._session)
|
||||
if urllib.parse.urlsplit(uri).scheme not in self.backend.uri_schemes:
|
||||
return None
|
||||
|
||||
if self.backend._blacklist_re.match(uri):
|
||||
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):
|
||||
"""
|
||||
Get a stream URI from a playlist URI, ``uri``.
|
||||
@ -105,7 +108,7 @@ def _unwrap_stream(uri, timeout, scanner, requests_session):
|
||||
logger.info(
|
||||
'Unwrapping stream from URI (%s) failed: '
|
||||
'playlist referenced itself', uri)
|
||||
return None
|
||||
return None, None
|
||||
else:
|
||||
seen_uris.add(uri)
|
||||
|
||||
@ -117,7 +120,7 @@ def _unwrap_stream(uri, timeout, scanner, requests_session):
|
||||
logger.info(
|
||||
'Unwrapping stream from URI (%s) failed: '
|
||||
'timed out in %sms', uri, timeout)
|
||||
return None
|
||||
return None, None
|
||||
scan_result = scanner.scan(uri, timeout=scan_timeout)
|
||||
except exceptions.ScannerError as 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(
|
||||
'Unwrapped potential %s stream: %s', scan_result.mime, uri)
|
||||
return uri
|
||||
return uri, scan_result
|
||||
|
||||
download_timeout = deadline - time.time()
|
||||
if download_timeout < 0:
|
||||
logger.info(
|
||||
'Unwrapping stream from URI (%s) failed: timed out in %sms',
|
||||
uri, timeout)
|
||||
return None
|
||||
return None, None
|
||||
content = http.download(
|
||||
requests_session, uri, timeout=download_timeout)
|
||||
|
||||
@ -145,14 +148,14 @@ def _unwrap_stream(uri, timeout, scanner, requests_session):
|
||||
logger.info(
|
||||
'Unwrapping stream from URI (%s) failed: '
|
||||
'error downloading URI %s', original_uri, uri)
|
||||
return None
|
||||
return None, None
|
||||
|
||||
uris = playlists.parse(content)
|
||||
if not uris:
|
||||
logger.debug(
|
||||
'Failed parsing URI (%s) as playlist; found potential stream.',
|
||||
uri)
|
||||
return uri
|
||||
return uri, None
|
||||
|
||||
# TODO Test streams and return first that seems to be playable
|
||||
logger.debug(
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import string
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -37,31 +36,43 @@ class Zeroconf(object):
|
||||
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 int port: TCP port of the service, e.g. 6600
|
||||
: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 host: interface to advertise the service on, defaults to all
|
||||
interfaces
|
||||
:param str host: interface to advertise the service on, defaults to ''
|
||||
:param text: extra information depending on ``stype``, defaults to empty
|
||||
list
|
||||
:type text: list of str
|
||||
"""
|
||||
|
||||
def __init__(self, name, port, stype=None, domain=None, text=None):
|
||||
self.group = None
|
||||
self.stype = stype or '_http._tcp'
|
||||
self.domain = domain or ''
|
||||
def __init__(self, name, stype, port, domain='', host='', text=None):
|
||||
self.stype = stype
|
||||
self.port = port
|
||||
self.domain = domain
|
||||
self.host = host
|
||||
self.text = text or []
|
||||
|
||||
template = string.Template(name)
|
||||
self.name = template.safe_substitute(
|
||||
hostname=socket.getfqdn(), port=self.port)
|
||||
self.host = '%s.local' % socket.getfqdn()
|
||||
self.bus = None
|
||||
self.server = None
|
||||
self.group = None
|
||||
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):
|
||||
return 'Zeroconf service %s at [%s]:%d' % (
|
||||
self.stype, self.host, self.port)
|
||||
return 'Zeroconf service "%s" (%s at [%s]:%d)' % (
|
||||
self.name, self.stype, self.host, self.port)
|
||||
|
||||
def publish(self):
|
||||
"""Publish the service.
|
||||
@ -78,26 +89,29 @@ class Zeroconf(object):
|
||||
logger.debug('%s: dbus not installed; publish failed.', self)
|
||||
return False
|
||||
|
||||
try:
|
||||
bus = dbus.SystemBus()
|
||||
if not self.bus:
|
||||
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(
|
||||
'%s: Avahi service not running; publish failed.', self)
|
||||
return False
|
||||
|
||||
server = dbus.Interface(
|
||||
bus.get_object('org.freedesktop.Avahi', '/'),
|
||||
'org.freedesktop.Avahi.Server')
|
||||
|
||||
self.group = dbus.Interface(
|
||||
bus.get_object(
|
||||
'org.freedesktop.Avahi', server.EntryGroupNew()),
|
||||
self.bus.get_object(
|
||||
'org.freedesktop.Avahi', self.server.EntryGroupNew()),
|
||||
'org.freedesktop.Avahi.EntryGroup')
|
||||
|
||||
self.group.AddService(
|
||||
_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),
|
||||
_convert_text_list_to_dbus_format(self.text))
|
||||
|
||||
|
||||
@ -32,6 +32,6 @@ class IsA(object):
|
||||
return str(self.klass)
|
||||
|
||||
|
||||
any_int = IsA((int, long))
|
||||
any_str = IsA(str)
|
||||
any_int = IsA(compat.integer_types)
|
||||
any_str = IsA(compat.string_types)
|
||||
any_unicode = IsA(compat.text_type)
|
||||
|
||||
@ -3,20 +3,14 @@ from __future__ import absolute_import, unicode_literals
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
import gobject
|
||||
gobject.threads_init()
|
||||
|
||||
import mock
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst # noqa
|
||||
|
||||
import pykka
|
||||
|
||||
from mopidy import audio
|
||||
from mopidy.audio.constants import PlaybackState
|
||||
from mopidy.internal import path
|
||||
from mopidy.internal.gi import Gst
|
||||
|
||||
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):
|
||||
config = {
|
||||
'audio': {
|
||||
'buffer_time': None,
|
||||
'mixer': 'fakemixer track_max_volume=65536',
|
||||
'mixer_track': None,
|
||||
'mixer_volume': None,
|
||||
@ -44,6 +39,7 @@ class BaseTest(unittest.TestCase):
|
||||
def setUp(self): # noqa: N802
|
||||
config = {
|
||||
'audio': {
|
||||
'buffer_time': None,
|
||||
'mixer': 'foomixer',
|
||||
'mixer_volume': None,
|
||||
'output': 'testoutput',
|
||||
@ -59,7 +55,7 @@ class BaseTest(unittest.TestCase):
|
||||
def tearDown(self): # noqa
|
||||
pykka.ActorRegistry.stop_all()
|
||||
|
||||
def possibly_trigger_fake_playback_error(self):
|
||||
def possibly_trigger_fake_playback_error(self, uri):
|
||||
pass
|
||||
|
||||
def possibly_trigger_fake_about_to_finish(self):
|
||||
@ -69,8 +65,8 @@ class BaseTest(unittest.TestCase):
|
||||
class DummyMixin(object):
|
||||
audio_class = dummy_audio.DummyAudio
|
||||
|
||||
def possibly_trigger_fake_playback_error(self):
|
||||
self.audio.trigger_fake_playback_failure()
|
||||
def possibly_trigger_fake_playback_error(self, uri):
|
||||
self.audio.trigger_fake_playback_failure(uri)
|
||||
|
||||
def possibly_trigger_fake_about_to_finish(self):
|
||||
callback = self.audio.get_about_to_finish_callback().get()
|
||||
@ -86,7 +82,7 @@ class AudioTest(BaseTest):
|
||||
self.assertTrue(self.audio.start_playback().get())
|
||||
|
||||
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.set_uri(self.uris[0] + 'bogus')
|
||||
@ -133,186 +129,253 @@ class AudioDummyTest(DummyMixin, AudioTest):
|
||||
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):
|
||||
|
||||
def setUp(self): # noqa: N802
|
||||
super(AudioEventTest, self).setUp()
|
||||
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: playing->playing triggered by seek should be removed
|
||||
# TODO: codify expected state after EOS
|
||||
# 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.set_uri(self.uris[0])
|
||||
self.audio.start_playback()
|
||||
|
||||
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)
|
||||
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.set_uri(self.uris[0])
|
||||
self.audio.pause_playback()
|
||||
|
||||
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)
|
||||
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.set_uri(self.uris[0])
|
||||
self.audio.pause_playback()
|
||||
|
||||
self.audio.wait_for_state_change()
|
||||
self.listener.clear_events()
|
||||
self.audio.start_playback()
|
||||
|
||||
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)
|
||||
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.set_uri(self.uris[0])
|
||||
self.audio.pause_playback()
|
||||
|
||||
self.audio.wait_for_state_change()
|
||||
self.listener.clear_events()
|
||||
self.audio.stop_playback()
|
||||
|
||||
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)
|
||||
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.set_uri(self.uris[0])
|
||||
self.audio.start_playback()
|
||||
|
||||
self.audio.wait_for_state_change()
|
||||
self.listener.clear_events()
|
||||
self.audio.pause_playback()
|
||||
|
||||
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)
|
||||
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.set_uri(self.uris[0])
|
||||
self.audio.start_playback()
|
||||
|
||||
self.audio.wait_for_state_change()
|
||||
self.listener.clear_events()
|
||||
self.audio.stop_playback()
|
||||
|
||||
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)
|
||||
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.set_uri(self.uris[0])
|
||||
self.listener.clear_events()
|
||||
self.audio.start_playback()
|
||||
|
||||
# Since we are going from stopped to playing, the state change is
|
||||
# enough to ensure the stream changed.
|
||||
self.audio.wait_for_state_change().get()
|
||||
self.assertEvent('stream_changed', uri=self.uris[0])
|
||||
|
||||
call = mock.call('stream_changed', uri=self.uris[0])
|
||||
self.assertIn(call, send_mock.call_args_list)
|
||||
def test_stream_changed_event_on_multiple_changes(self):
|
||||
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.set_uri(self.uris[0])
|
||||
self.audio.pause_playback()
|
||||
|
||||
self.audio.wait_for_state_change()
|
||||
self.listener.clear_events()
|
||||
self.audio.stop_playback()
|
||||
|
||||
self.audio.wait_for_state_change().get()
|
||||
self.assertEvent('stream_changed', uri=None)
|
||||
|
||||
call = mock.call('stream_changed', uri=None)
|
||||
self.assertIn(call, send_mock.call_args_list)
|
||||
|
||||
def test_position_changed_on_pause(self, send_mock):
|
||||
def test_position_changed_on_pause(self):
|
||||
self.audio.prepare_change()
|
||||
self.audio.set_uri(self.uris[0])
|
||||
self.audio.pause_playback()
|
||||
self.audio.wait_for_state_change()
|
||||
|
||||
self.audio.wait_for_state_change().get()
|
||||
self.assertEvent('position_changed', position=0)
|
||||
|
||||
call = mock.call('position_changed', position=0)
|
||||
self.assertIn(call, send_mock.call_args_list)
|
||||
def test_stream_changed_event_on_paused_to_playing(self):
|
||||
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.set_uri(self.uris[0])
|
||||
self.audio.start_playback()
|
||||
self.audio.wait_for_state_change()
|
||||
|
||||
self.audio.wait_for_state_change().get()
|
||||
self.assertEvent('position_changed', position=0)
|
||||
|
||||
call = mock.call('position_changed', position=0)
|
||||
self.assertIn(call, send_mock.call_args_list)
|
||||
|
||||
def test_position_changed_on_seek(self, send_mock):
|
||||
def test_position_changed_on_seek_while_stopped(self):
|
||||
self.audio.prepare_change()
|
||||
self.audio.set_uri(self.uris[0])
|
||||
self.audio.set_position(2000)
|
||||
|
||||
self.audio.wait_for_state_change().get()
|
||||
self.assertNotEvent('position_changed', position=0)
|
||||
|
||||
call = mock.call('position_changed', position=0)
|
||||
self.assertNotIn(call, send_mock.call_args_list)
|
||||
|
||||
def test_position_changed_on_seek_after_play(self, send_mock):
|
||||
def test_position_changed_on_seek_after_play(self):
|
||||
self.audio.prepare_change()
|
||||
self.audio.set_uri(self.uris[0])
|
||||
self.audio.start_playback()
|
||||
|
||||
self.audio.wait_for_state_change()
|
||||
self.listener.clear_events()
|
||||
self.audio.set_position(2000)
|
||||
|
||||
self.audio.wait_for_state_change().get()
|
||||
self.assertEvent('position_changed', position=2000)
|
||||
|
||||
call = mock.call('position_changed', position=2000)
|
||||
self.assertIn(call, send_mock.call_args_list)
|
||||
|
||||
def test_position_changed_on_seek_after_pause(self, send_mock):
|
||||
def test_position_changed_on_seek_after_pause(self):
|
||||
self.audio.prepare_change()
|
||||
self.audio.set_uri(self.uris[0])
|
||||
self.audio.pause_playback()
|
||||
|
||||
self.audio.wait_for_state_change()
|
||||
self.listener.clear_events()
|
||||
self.audio.set_position(2000)
|
||||
|
||||
self.audio.wait_for_state_change().get()
|
||||
self.assertEvent('position_changed', position=2000)
|
||||
|
||||
call = mock.call('position_changed', position=2000)
|
||||
self.assertIn(call, send_mock.call_args_list)
|
||||
|
||||
def test_tags_changed_on_playback(self, send_mock):
|
||||
def test_tags_changed_on_playback(self):
|
||||
self.audio.prepare_change()
|
||||
self.audio.set_uri(self.uris[0])
|
||||
self.audio.start_playback()
|
||||
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
|
||||
# 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
|
||||
# completes.
|
||||
|
||||
def test_stream_changed_event_on_paused(self, send_mock):
|
||||
event = threading.Event()
|
||||
|
||||
def send(name, **kwargs):
|
||||
if name == 'stream_changed':
|
||||
event.set()
|
||||
send_mock.side_effect = send
|
||||
def test_stream_changed_event_on_paused(self):
|
||||
event = self.listener.wait('stream_changed').get()
|
||||
|
||||
self.audio.prepare_change()
|
||||
self.audio.set_uri(self.uris[0])
|
||||
@ -322,13 +385,10 @@ class AudioEventTest(BaseTest):
|
||||
if not event.wait(timeout=1.0):
|
||||
self.fail('Stream changed not reached within deadline')
|
||||
|
||||
def test_reached_end_of_stream_event(self, send_mock):
|
||||
event = threading.Event()
|
||||
self.assertEvent('stream_changed', uri=self.uris[0])
|
||||
|
||||
def send(name, **kwargs):
|
||||
if name == 'reached_end_of_stream':
|
||||
event.set()
|
||||
send_mock.side_effect = send
|
||||
def test_reached_end_of_stream_event(self):
|
||||
event = self.listener.wait('reached_end_of_stream').get()
|
||||
|
||||
self.audio.prepare_change()
|
||||
self.audio.set_uri(self.uris[0])
|
||||
@ -341,21 +401,14 @@ class AudioEventTest(BaseTest):
|
||||
|
||||
self.assertFalse(self.audio.get_current_tags().get())
|
||||
|
||||
def test_gapless(self, send_mock):
|
||||
def test_gapless(self):
|
||||
uris = self.uris[1:]
|
||||
events = []
|
||||
done = threading.Event()
|
||||
event = self.listener.wait('reached_end_of_stream').get()
|
||||
|
||||
def callback():
|
||||
if uris:
|
||||
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.prepare_change()
|
||||
@ -367,15 +420,15 @@ class AudioEventTest(BaseTest):
|
||||
|
||||
self.possibly_trigger_fake_about_to_finish()
|
||||
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')
|
||||
|
||||
# Check that both uris got played
|
||||
self.assertIn(('stream_changed', {'uri': self.uris[0]}), events)
|
||||
self.assertIn(('stream_changed', {'uri': self.uris[1]}), events)
|
||||
self.assertEvent('stream_changed', uri=self.uris[0])
|
||||
self.assertEvent('stream_changed', uri=self.uris[1])
|
||||
|
||||
# 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('position_changed'))
|
||||
self.assertEqual(1, keys.count('state_changed'))
|
||||
@ -383,17 +436,12 @@ class AudioEventTest(BaseTest):
|
||||
|
||||
# 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())
|
||||
|
||||
def test_current_tags_blank_after_end_of_stream(self, send_mock):
|
||||
done = threading.Event()
|
||||
|
||||
def send(name, **kwargs):
|
||||
if name == 'reached_end_of_stream':
|
||||
done.set()
|
||||
|
||||
send_mock.side_effect = send
|
||||
def test_current_tags_blank_after_end_of_stream(self):
|
||||
event = self.listener.wait('reached_end_of_stream').get()
|
||||
|
||||
self.audio.prepare_change()
|
||||
self.audio.set_uri(self.uris[0])
|
||||
@ -402,23 +450,18 @@ class AudioEventTest(BaseTest):
|
||||
self.possibly_trigger_fake_about_to_finish()
|
||||
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.assertFalse(self.audio.get_current_tags().get())
|
||||
|
||||
def test_current_tags_stored(self, send_mock):
|
||||
done = threading.Event()
|
||||
def test_current_tags_stored(self):
|
||||
event = self.listener.wait('reached_end_of_stream').get()
|
||||
tags = []
|
||||
|
||||
def callback():
|
||||
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.prepare_change()
|
||||
@ -428,7 +471,7 @@ class AudioEventTest(BaseTest):
|
||||
self.possibly_trigger_fake_about_to_finish()
|
||||
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.assertTrue(tags[0])
|
||||
@ -473,17 +516,17 @@ class AudioStateTest(unittest.TestCase):
|
||||
|
||||
def test_state_does_not_change_when_in_gst_ready_state(self):
|
||||
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)
|
||||
|
||||
def test_state_changes_from_stopped_to_playing_on_play(self):
|
||||
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(
|
||||
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(
|
||||
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)
|
||||
|
||||
@ -491,7 +534,7 @@ class AudioStateTest(unittest.TestCase):
|
||||
self.audio.state = audio.PlaybackState.PLAYING
|
||||
|
||||
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)
|
||||
|
||||
@ -499,12 +542,12 @@ class AudioStateTest(unittest.TestCase):
|
||||
self.audio.state = audio.PlaybackState.PLAYING
|
||||
|
||||
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(
|
||||
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
|
||||
# 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)
|
||||
|
||||
@ -518,17 +561,17 @@ class AudioBufferingTest(unittest.TestCase):
|
||||
def test_pause_when_buffer_empty(self):
|
||||
playbin = self.audio._playbin
|
||||
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()
|
||||
|
||||
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)
|
||||
|
||||
def test_stay_paused_when_buffering_finished(self):
|
||||
playbin = self.audio._playbin
|
||||
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()
|
||||
|
||||
self.audio._handler.on_buffering(100)
|
||||
@ -538,11 +581,11 @@ class AudioBufferingTest(unittest.TestCase):
|
||||
def test_change_to_paused_while_buffering(self):
|
||||
playbin = self.audio._playbin
|
||||
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()
|
||||
|
||||
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()
|
||||
playbin.set_state.reset_mock()
|
||||
|
||||
@ -553,13 +596,13 @@ class AudioBufferingTest(unittest.TestCase):
|
||||
def test_change_to_stopped_while_buffering(self):
|
||||
playbin = self.audio._playbin
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
|
||||
@ -3,9 +3,6 @@ from __future__ import absolute_import, unicode_literals
|
||||
import os
|
||||
import unittest
|
||||
|
||||
import gobject
|
||||
gobject.threads_init()
|
||||
|
||||
from mopidy import exceptions
|
||||
from mopidy.audio import scan
|
||||
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
|
||||
|
||||
import datetime
|
||||
import unittest
|
||||
import pytest
|
||||
|
||||
from mopidy.audio import utils
|
||||
from mopidy.models import Album, Artist, Track
|
||||
from mopidy.internal.gi import Gst
|
||||
|
||||
|
||||
# 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):
|
||||
class TestCreateBuffer(object):
|
||||
|
||||
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': [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],
|
||||
}
|
||||
def test_creates_buffer(self):
|
||||
buf = utils.create_buffer(b'123', timestamp=0, duration=1000000)
|
||||
|
||||
artist = Artist(name='artist', musicbrainz_id='artistid',
|
||||
sortname='sortname')
|
||||
composer = Artist(name='composer')
|
||||
performer = Artist(name='performer')
|
||||
albumartist = Artist(name='albumartist',
|
||||
musicbrainz_id='albumartistid')
|
||||
assert isinstance(buf, Gst.Buffer)
|
||||
assert buf.pts == 0
|
||||
assert buf.duration == 1000000
|
||||
assert buf.get_size() == len(b'123')
|
||||
|
||||
album = Album(name='album', num_tracks=2, num_discs=3,
|
||||
musicbrainz_id='albumid', artists=[albumartist])
|
||||
def test_fails_if_data_has_zero_length(self):
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
utils.create_buffer(b'', timestamp=0, duration=1000000)
|
||||
|
||||
self.track = Track(name='track', date='2006-01-01',
|
||||
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]))
|
||||
assert 'Cannot create buffer without data' in str(excinfo.value)
|
||||
|
||||
@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import unittest
|
||||
|
||||
from mopidy import compat
|
||||
from mopidy.core import HistoryController
|
||||
from mopidy.models import Artist, Track
|
||||
|
||||
@ -40,7 +41,7 @@ class PlaybackHistoryTest(unittest.TestCase):
|
||||
result = self.history.get_history()
|
||||
(timestamp, ref) = result[0]
|
||||
|
||||
self.assertIsInstance(timestamp, (int, long))
|
||||
self.assertIsInstance(timestamp, compat.integer_types)
|
||||
self.assertEqual(track.uri, ref.uri)
|
||||
self.assertIn(track.name, ref.name)
|
||||
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.sp2.save.called)
|
||||
|
||||
def test_get_uri_schemes(self):
|
||||
result = self.core.playlists.get_uri_schemes()
|
||||
self.assertEquals(result, ['dummy1', 'dummy2'])
|
||||
|
||||
|
||||
class DeprecatedFilterPlaylistsTest(BasePlaylistsTest):
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
#EXTM3U
|
||||
# test
|
||||
#EXTINF:-1,song1
|
||||
#EXTINF:-1,Song #1
|
||||
# test
|
||||
song1.mp3
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
#EXTM3U
|
||||
#EXTINF:-1,song1
|
||||
#EXTINF:-1,Song #1
|
||||
song1.mp3
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
#EXTM3U
|
||||
#EXTINF:-1,song1
|
||||
#EXTINF:-1,Song #1
|
||||
song1.mp3
|
||||
#EXTINF:60,song2
|
||||
#EXTINF:60,Song #2
|
||||
song2.mp3
|
||||
|
||||
@ -15,6 +15,7 @@ def create_proxy(config=None, mixer=None):
|
||||
return DummyAudio.start(config, mixer).proxy()
|
||||
|
||||
|
||||
# TODO: reset position on track change?
|
||||
class DummyAudio(pykka.ThreadingActor):
|
||||
|
||||
def __init__(self, config=None, mixer=None):
|
||||
@ -24,13 +25,15 @@ class DummyAudio(pykka.ThreadingActor):
|
||||
self._position = 0
|
||||
self._callback = None
|
||||
self._uri = None
|
||||
self._state_change_result = True
|
||||
self._stream_changed = False
|
||||
self._tags = {}
|
||||
self._bad_uris = set()
|
||||
|
||||
def set_uri(self, uri):
|
||||
assert self._uri is None, 'prepare change not called before set'
|
||||
self._tags = {}
|
||||
self._uri = uri
|
||||
self._stream_changed = True
|
||||
|
||||
def set_appsrc(self, *args, **kwargs):
|
||||
pass
|
||||
@ -88,12 +91,15 @@ class DummyAudio(pykka.ThreadingActor):
|
||||
if not self._uri:
|
||||
return False
|
||||
|
||||
if self.state == audio.PlaybackState.STOPPED and self._uri:
|
||||
audio.AudioListener.send('position_changed', position=0)
|
||||
audio.AudioListener.send('stream_changed', uri=self._uri)
|
||||
|
||||
if new_state == audio.PlaybackState.STOPPED:
|
||||
if new_state == audio.PlaybackState.STOPPED and self._uri:
|
||||
self._stream_changed = True
|
||||
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)
|
||||
|
||||
old_state, self.state = self.state, new_state
|
||||
@ -105,10 +111,10 @@ class DummyAudio(pykka.ThreadingActor):
|
||||
self._tags['audio-codec'] = [u'fake info...']
|
||||
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):
|
||||
self._state_change_result = False
|
||||
def trigger_fake_playback_failure(self, uri):
|
||||
self._bad_uris.add(uri)
|
||||
|
||||
def trigger_fake_tags_changed(self, tags):
|
||||
self._tags.update(tags)
|
||||
|
||||
@ -22,7 +22,10 @@ class DummyBackend(pykka.ThreadingActor, backend.Backend):
|
||||
super(DummyBackend, self).__init__()
|
||||
|
||||
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.uri_schemes = ['dummy']
|
||||
|
||||
@ -5,13 +5,12 @@ import logging
|
||||
import socket
|
||||
import unittest
|
||||
|
||||
import gobject
|
||||
|
||||
from mock import Mock, call, patch, sentinel
|
||||
|
||||
import pykka
|
||||
|
||||
from mopidy.internal import network
|
||||
from mopidy.internal.gi import GObject
|
||||
|
||||
from tests import any_int, any_unicode
|
||||
|
||||
@ -162,27 +161,27 @@ class ConnectionTest(unittest.TestCase):
|
||||
network.Connection.stop(self.mock, sentinel.reason)
|
||||
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):
|
||||
self.mock.recv_id = None
|
||||
self.mock.sock = Mock(spec=socket.SocketType)
|
||||
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)
|
||||
gobject.io_add_watch.assert_called_once_with(
|
||||
GObject.io_add_watch.assert_called_once_with(
|
||||
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.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):
|
||||
self.mock.sock = Mock(spec=socket.SocketType)
|
||||
self.mock.recv_id = sentinel.tag
|
||||
|
||||
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):
|
||||
self.mock.recv_id = sentinel.tag
|
||||
@ -191,20 +190,20 @@ class ConnectionTest(unittest.TestCase):
|
||||
network.Connection.enable_recv(self.mock)
|
||||
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):
|
||||
self.mock.recv_id = sentinel.tag
|
||||
|
||||
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)
|
||||
|
||||
@patch.object(gobject, 'source_remove', new=Mock())
|
||||
@patch.object(GObject, 'source_remove', new=Mock())
|
||||
def test_disable_recv_already_deregistered(self):
|
||||
self.mock.recv_id = None
|
||||
|
||||
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)
|
||||
|
||||
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.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):
|
||||
self.mock.send_id = None
|
||||
self.mock.sock = Mock(spec=socket.SocketType)
|
||||
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)
|
||||
gobject.io_add_watch.assert_called_once_with(
|
||||
GObject.io_add_watch.assert_called_once_with(
|
||||
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.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):
|
||||
self.mock.sock = Mock(spec=socket.SocketType)
|
||||
self.mock.send_id = sentinel.tag
|
||||
|
||||
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):
|
||||
self.mock.send_id = sentinel.tag
|
||||
@ -245,20 +244,20 @@ class ConnectionTest(unittest.TestCase):
|
||||
network.Connection.enable_send(self.mock)
|
||||
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):
|
||||
self.mock.send_id = sentinel.tag
|
||||
|
||||
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)
|
||||
|
||||
@patch.object(gobject, 'source_remove', new=Mock())
|
||||
@patch.object(GObject, 'source_remove', new=Mock())
|
||||
def test_disable_send_already_deregistered(self):
|
||||
self.mock.send_id = None
|
||||
|
||||
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)
|
||||
|
||||
def test_enable_send_on_closed_socket(self):
|
||||
@ -269,36 +268,36 @@ class ConnectionTest(unittest.TestCase):
|
||||
network.Connection.enable_send(self.mock)
|
||||
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):
|
||||
self.mock.timeout = 10
|
||||
|
||||
network.Connection.enable_timeout(self.mock)
|
||||
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):
|
||||
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)
|
||||
gobject.timeout_add_seconds.assert_called_once_with(
|
||||
GObject.timeout_add_seconds.assert_called_once_with(
|
||||
10, self.mock.timeout_callback)
|
||||
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):
|
||||
self.mock.timeout = 0
|
||||
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
|
||||
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
|
||||
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):
|
||||
self.mock.timeout = 0
|
||||
@ -313,20 +312,20 @@ class ConnectionTest(unittest.TestCase):
|
||||
network.Connection.enable_timeout(self.mock)
|
||||
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):
|
||||
self.mock.timeout_id = sentinel.tag
|
||||
|
||||
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)
|
||||
|
||||
@patch.object(gobject, 'source_remove', new=Mock())
|
||||
@patch.object(GObject, 'source_remove', new=Mock())
|
||||
def test_disable_timeout_already_deregistered(self):
|
||||
self.mock.timeout_id = None
|
||||
|
||||
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)
|
||||
|
||||
def test_queue_send_acquires_and_releases_lock(self):
|
||||
@ -372,7 +371,7 @@ class ConnectionTest(unittest.TestCase):
|
||||
self.mock.actor_ref = Mock()
|
||||
|
||||
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)
|
||||
|
||||
def test_recv_callback_respects_io_hup(self):
|
||||
@ -380,7 +379,7 @@ class ConnectionTest(unittest.TestCase):
|
||||
self.mock.actor_ref = Mock()
|
||||
|
||||
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)
|
||||
|
||||
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.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)
|
||||
|
||||
def test_recv_callback_sends_data_to_actor(self):
|
||||
@ -398,7 +397,7 @@ class ConnectionTest(unittest.TestCase):
|
||||
self.mock.actor_ref = Mock()
|
||||
|
||||
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(
|
||||
{'received': 'data'})
|
||||
|
||||
@ -409,7 +408,7 @@ class ConnectionTest(unittest.TestCase):
|
||||
self.mock.actor_ref.tell.side_effect = pykka.ActorDeadError()
|
||||
|
||||
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)
|
||||
|
||||
def test_recv_callback_gets_no_data(self):
|
||||
@ -418,7 +417,7 @@ class ConnectionTest(unittest.TestCase):
|
||||
self.mock.actor_ref = Mock()
|
||||
|
||||
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, [
|
||||
call.sock.recv(any_int),
|
||||
call.disable_recv(),
|
||||
@ -431,7 +430,7 @@ class ConnectionTest(unittest.TestCase):
|
||||
for error in (errno.EWOULDBLOCK, errno.EINTR):
|
||||
self.mock.sock.recv.side_effect = socket.error(error, '')
|
||||
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)
|
||||
|
||||
def test_recv_callback_unrecoverable_error(self):
|
||||
@ -439,7 +438,7 @@ class ConnectionTest(unittest.TestCase):
|
||||
self.mock.sock.recv.side_effect = socket.error
|
||||
|
||||
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)
|
||||
|
||||
def test_send_callback_respects_io_err(self):
|
||||
@ -450,7 +449,7 @@ class ConnectionTest(unittest.TestCase):
|
||||
self.mock.send_buffer = ''
|
||||
|
||||
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)
|
||||
|
||||
def test_send_callback_respects_io_hup(self):
|
||||
@ -461,7 +460,7 @@ class ConnectionTest(unittest.TestCase):
|
||||
self.mock.send_buffer = ''
|
||||
|
||||
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)
|
||||
|
||||
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.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)
|
||||
|
||||
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.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.release.assert_called_once_with()
|
||||
|
||||
@ -496,7 +495,7 @@ class ConnectionTest(unittest.TestCase):
|
||||
self.mock.sock.send.return_value = 0
|
||||
|
||||
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.assertEqual(0, self.mock.sock.send.call_count)
|
||||
|
||||
@ -507,7 +506,7 @@ class ConnectionTest(unittest.TestCase):
|
||||
self.mock.send.return_value = ''
|
||||
|
||||
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.send.assert_called_once_with('data')
|
||||
self.assertEqual('', self.mock.send_buffer)
|
||||
@ -519,7 +518,7 @@ class ConnectionTest(unittest.TestCase):
|
||||
self.mock.send.return_value = 'ta'
|
||||
|
||||
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.assertEqual('ta', self.mock.send_buffer)
|
||||
|
||||
|
||||
@ -4,11 +4,10 @@ import errno
|
||||
import socket
|
||||
import unittest
|
||||
|
||||
import gobject
|
||||
|
||||
from mock import Mock, patch, sentinel
|
||||
|
||||
from mopidy.internal import network
|
||||
from mopidy.internal.gi import GObject
|
||||
|
||||
from tests import any_int
|
||||
|
||||
@ -91,11 +90,11 @@ class ServerTest(unittest.TestCase):
|
||||
network.Server.create_server_socket(
|
||||
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):
|
||||
network.Server.register_server_socket(self.mock, sentinel.fileno)
|
||||
gobject.io_add_watch.assert_called_once_with(
|
||||
sentinel.fileno, gobject.IO_IN, self.mock.handle_connection)
|
||||
GObject.io_add_watch.assert_called_once_with(
|
||||
sentinel.fileno, GObject.IO_IN, self.mock.handle_connection)
|
||||
|
||||
def test_handle_connection(self):
|
||||
self.mock.accept_connection.return_value = (
|
||||
@ -103,7 +102,7 @@ class ServerTest(unittest.TestCase):
|
||||
self.mock.maximum_connections_exceeded.return_value = False
|
||||
|
||||
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.maximum_connections_exceeded.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.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.maximum_connections_exceeded.assert_called_once_with()
|
||||
self.mock.reject_connection.assert_called_once_with(
|
||||
|
||||
@ -8,11 +8,8 @@ import mock
|
||||
|
||||
import pkg_resources
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst # noqa
|
||||
|
||||
from mopidy.internal import deps
|
||||
from mopidy.internal.gi import Gst, gi
|
||||
|
||||
|
||||
class DepsTest(unittest.TestCase):
|
||||
@ -74,12 +71,11 @@ class DepsTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual('GStreamer', result['name'])
|
||||
self.assertEqual(
|
||||
'.'.join(map(str, gst.get_gst_version())), result['version'])
|
||||
self.assertIn('gst', result['path'])
|
||||
'.'.join(map(str, Gst.version())), result['version'])
|
||||
self.assertIn('gi', result['path'])
|
||||
self.assertNotIn('__init__.py', result['path'])
|
||||
self.assertIn('Python wrapper: gst-python', result['other'])
|
||||
self.assertIn(
|
||||
'.'.join(map(str, gst.get_pygst_version())), result['other'])
|
||||
self.assertIn('Python wrapper: python-gi', result['other'])
|
||||
self.assertIn(gi.__version__, result['other'])
|
||||
self.assertIn('Relevant elements:', result['other'])
|
||||
|
||||
@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