Release v2.0.0

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

View File

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

View File

@ -1,18 +1,11 @@
sudo: false
sudo: required
dist: trusty
language: python
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
View File

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

View File

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

View File

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

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

View File

@ -4,6 +4,241 @@ Changelog
This changelog is used to track all major changes to Mopidy.
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**

View File

@ -30,14 +30,17 @@ supported)" mode because the client tries to fetch all known metadata and do
the search on the client side. The two other search modes works nicely, so this
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
--------

View File

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

View File

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

View File

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

View File

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

View File

@ -25,6 +25,10 @@ create the configuration file yourself, or run the ``mopidy`` command, and it
will create an empty config file for you and print what config values must be
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -37,36 +37,34 @@ please follow the directions :ref:`here <contributing>`.
On Fedora Linux, you must replace ``pip`` with ``pip-python`` in the
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>`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,7 +18,7 @@ class AudioListener(listener.Listener):
@staticmethod
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):
"""

View File

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

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

View File

@ -1,50 +1,41 @@
from __future__ import absolute_import, unicode_literals
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)))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -7,11 +7,10 @@ import socket
import sys
import 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@ -1,117 +1,150 @@
from __future__ import absolute_import, division, unicode_literals
from __future__ import absolute_import, unicode_literals
import glob
import contextlib
import io
import locale
import logging
import 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')

View File

@ -1,129 +1,119 @@
from __future__ import absolute_import, unicode_literals
from __future__ import absolute_import, print_function, unicode_literals
import codecs
import logging
import os
import 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)
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -1,261 +1,23 @@
from __future__ import absolute_import, unicode_literals
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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