diff --git a/.mailmap b/.mailmap index 0682f673..8f98ce5b 100644 --- a/.mailmap +++ b/.mailmap @@ -27,3 +27,4 @@ Ronald Zielaznicki Kyle Heyne Tom Roth Eric Jahn +Loïck Bonniot diff --git a/.travis.yml b/.travis.yml index eb8aadfe..f46d5ae2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,18 +1,11 @@ -sudo: false +sudo: required +dist: trusty language: python python: - "2.7_with_system_site_packages" -addons: - apt: - sources: - - mopidy-stable - packages: - - graphviz-dev - - mopidy - env: - TOX_ENV=py27 - TOX_ENV=py27-tornado23 @@ -20,6 +13,11 @@ env: - TOX_ENV=docs - TOX_ENV=flake8 +before_install: + - "sudo sed -i '/127.0.1.1/d' /etc/hosts" # Workaround tornadoweb/tornado#1573 + - "sudo apt-get update -qq" + - "sudo apt-get install -y gir1.2-gst-plugins-base-1.0 gir1.2-gstreamer-1.0 graphviz-dev gstreamer1.0-plugins-good gstreamer1.0-plugins-bad python-gst-1.0" + install: - "pip install tox" @@ -27,7 +25,7 @@ script: - "tox -e $TOX_ENV" after_success: - - "if [ $TOX_ENV == 'py27' ]; then pip install coveralls==1.0b1; coveralls; fi" + - "if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi" branches: except: diff --git a/AUTHORS b/AUTHORS index a370ce6c..4cf69baa 100644 --- a/AUTHORS +++ b/AUTHORS @@ -67,3 +67,13 @@ - Danilo Bargen - Bjørnar Snoksrud - Giorgos Logiotatidis +- Ben Evans +- vrs01 +- Cadel Watson +- Loïck Bonniot +- Gustaf Hallberg +- kozec +- Jelle van der Waa +- Alex Malone +- Daniel Hahler +- Bryan Bennett diff --git a/README.rst b/README.rst index 1da79a6e..88072eb5 100644 --- a/README.rst +++ b/README.rst @@ -53,8 +53,6 @@ To get started with Mopidy, check out - `Discussion forum `_ - `Source code `_ - `Issue tracker `_ -- `Development branch tarball `_ - - IRC: ``#mopidy`` at `irc.freenode.net `_ - Announcement list: `mopidy@googlegroups.com `_ - Twitter: `@mopidy `_ diff --git a/docs/api/core.rst b/docs/api/core.rst index 5f1e406f..aaa692d2 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -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 diff --git a/docs/audio.rst b/docs/audio.rst new file mode 100644 index 00000000..a5447583 --- /dev/null +++ b/docs/audio.rst @@ -0,0 +1,130 @@ +.. _audio: + +********************* +Advanced audio setups +********************* + +Mopidy has very few :ref:`audio configs `, 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 + + + /mopidy + /silence.mp3 + 1 + + +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 +``...`` in the Icecast configuration. diff --git a/docs/changelog.rst b/docs/changelog.rst index 8054ac82..7dc4a747 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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 ` +who has :ref:`contributed `! + +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 `_ for easy + published to the `npm registry `_ 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 `_ over D-Bus. In + Mopidy through the `MPRIS interface `_ over D-Bus. In practice, this makes it possible to control Mopidy through the `Ubuntu Sound - Menu `_. + Menu `_. **Changes** diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 5d5c3057..e1cb6019 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -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 `_ is a graphical MPD client (GTK+) which works +`GMPC `_ 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 `_ is a graphical MPD client (GTK+). +`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 -------- diff --git a/docs/clients/mpris.rst b/docs/clients/mpris.rst index aef02566..1948afe4 100644 --- a/docs/clients/mpris.rst +++ b/docs/clients/mpris.rst @@ -4,7 +4,7 @@ MPRIS clients ************* -`MPRIS `_ is short for Media Player Remote Interfacing +`MPRIS `_ 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 `_ is the default +The `Ubuntu 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. diff --git a/docs/clients/upnp.rst b/docs/clients/upnp.rst index b5b18268..1a33b456 100644 --- a/docs/clients/upnp.rst +++ b/docs/clients/upnp.rst @@ -4,11 +4,11 @@ UPnP clients ************ -`UPnP `_ is a set of +`UPnP `_ 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 -`_ compatible or certified. +`_ 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 -`_. +`_. diff --git a/docs/codestyle.rst b/docs/codestyle.rst index 4b6e7448..26306631 100644 --- a/docs/codestyle.rst +++ b/docs/codestyle.rst @@ -21,7 +21,7 @@ Code style bar = 'I am a bytestring, but was it intentional?' - Follow :pep:`8` unless otherwise noted. `flake8 - `_ should be used to check your code + `_ should be used to check your code against the guidelines. - Use four spaces for indentation, *never* tabs. diff --git a/docs/conf.py b/docs/conf.py index 3a93cc90..208822a2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,7 +15,6 @@ sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + '/../')) class Mock(object): - def __init__(self, *args, **kwargs): pass @@ -27,39 +26,21 @@ class Mock(object): @classmethod def __getattr__(self, name): - if name in ('__file__', '__path__'): - return '/dev/null' - elif name == 'get_system_config_dirs': - # glib.get_system_config_dirs() - return tuple - elif name == 'get_user_config_dir': - # glib.get_user_config_dir() + if name == 'get_system_config_dirs': # GLib.get_system_config_dirs() + return list + elif name == 'get_user_config_dir': # GLib.get_user_config_dir() return str - elif (name[0] == name[0].upper() and - # gst.Caps - not name.startswith('Caps') and - # gst.PadTemplate - not name.startswith('PadTemplate') and - # dbus.String() - not name == 'String'): - return type(name, (), {}) else: return Mock() + MOCK_MODULES = [ 'dbus', 'dbus.mainloop', 'dbus.mainloop.glib', 'dbus.service', - 'glib', - 'gobject', - 'gst', - 'gst.pbutils', - 'pygst', + 'mopidy.internal.gi', 'pykka', - 'pykka.actor', - 'pykka.future', - 'pykka.registry', ] for mod_name in MOCK_MODULES: sys.modules[mod_name] = Mock() @@ -111,11 +92,7 @@ modindex_common_prefix = ['mopidy.'] # -- Options for HTML output -------------------------------------------------- -# 'sphinx_rtd_theme' is bundled with Sphinx 1.3, which we don't have when -# building the docs as part of the Debian packages on e.g. Debian wheezy. -# html_theme = 'sphinx_rtd_theme' -html_theme = 'default' -html_theme_path = ['_themes'] +html_theme = 'sphinx_rtd_theme' html_static_path = ['_static'] html_use_modindex = True @@ -167,7 +144,17 @@ extlinks = { # -- Options for intersphinx extension ---------------------------------------- intersphinx_mapping = { - 'python': ('http://docs.python.org/2', None), - 'pykka': ('http://www.pykka.org/en/latest/', None), + 'python': ('https://docs.python.org/2', None), + 'pykka': ('https://www.pykka.org/en/latest/', None), 'tornado': ('http://www.tornadoweb.org/en/stable/', None), } + +# -- Options for linkcheck builder ------------------------------------------- + +linkcheck_ignore = [ # Some sites work in browser but linkcheck fails. + r'http://localhost:\d+/', + r'http://wiki.commonjs.org', + r'http://vk.com', + r'http://$'] + +linkcheck_anchors = False # This breaks on links that use # for other stuff diff --git a/docs/config.rst b/docs/config.rst index 7f0bda31..b0d2e52e 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -25,6 +25,10 @@ create the configuration file yourself, or run the ``mopidy`` command, and it will create an empty config file for you and print what config values must be set to successfully start Mopidy. +If running Mopidy as a service, the location of the config file and other +details documented here differs a bit. See :ref:`service` for details about +this. + When you have created the configuration file, open it in a text editor, and add the config values you want to change. If you want to keep the default for a config value, you **should not** add it to the config file, but leave it out so @@ -45,21 +49,18 @@ below, together with their default values. In addition, all :ref:`extensions defaults are documented on the :ref:`extension pages `. -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 - - - /mopidy - /silence.mp3 - 1 - - -The ``silence.mp3`` file needs to be placed in the directory defined by -``...``. - - -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 diff --git a/docs/devenv.rst b/docs/devenv.rst index c00e6050..cd67690b 100644 --- a/docs/devenv.rst +++ b/docs/devenv.rst @@ -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 `_. + `coveralls.io `_. 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 -`_. If you're out of work, coveralls might +`_. 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 -`_, which automatically updates the documentation +`_, which automatically updates the documentation when a change is pushed to the ``mopidy/mopidy`` repo at GitHub. diff --git a/docs/ext/backends.rst b/docs/ext/backends.rst index 5f578e6f..2349006b 100644 --- a/docs/ext/backends.rst +++ b/docs/ext/backends.rst @@ -54,7 +54,7 @@ Mopidy-Dirble https://github.com/mopidy/mopidy-dirble Provides a backend for browsing the Internet radio channels from the `Dirble -`_ directory. +`_ 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 `_ D-Bus interface. +the `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 -`_. +`_. Mopidy-GMusic ============= -https://github.com/hechtus/mopidy-gmusic +https://github.com/mopidy/mopidy-gmusic Provides a backend for playing music from `Google Play 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 `_ web site. +`gpodder `_ 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 `_, `rad.io `_, +`radio.de `_, `radio.net `_, `radio.fr `_, and `radio.at `_. @@ -196,7 +196,7 @@ Mopidy-SoundCloud https://github.com/mopidy/mopidy-soundcloud Provides a backend for playing music from the `SoundCloud -`_ service. +`_ service. Mopidy-Spotify @@ -204,7 +204,7 @@ Mopidy-Spotify https://github.com/mopidy/mopidy-spotify -Extension for playing music from the `Spotify `_ music +Extension for playing music from the `Spotify `_ 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 -`_. This lets you browse playlists, genres and new +`_. 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 -`_ online radio service. +`_ 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 -`_ service. +`_ service. diff --git a/docs/ext/local.rst b/docs/ext/local.rst index ef9df5d7..1512524e 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -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 diff --git a/docs/ext/m3u.rst b/docs/ext/m3u.rst index 2b86b73a..35bd2036 100644 --- a/docs/ext/m3u.rst +++ b/docs/ext/m3u.rst @@ -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``. diff --git a/docs/ext/mpd.rst b/docs/ext/mpd.rst index b02226a2..7f02facc 100644 --- a/docs/ext/mpd.rst +++ b/docs/ext/mpd.rst @@ -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 diff --git a/docs/ext/web.rst b/docs/ext/web.rst index 48017ef0..4c2b6c6c 100644 --- a/docs/ext/web.rst +++ b/docs/ext/web.rst @@ -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. diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 77fd69fd..72747e28 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -214,7 +214,7 @@ file:: include mopidy_soundspot/ext.conf For details on the ``MANIFEST.in`` file format, check out the `distutils docs -`_. +`_. `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 `__. + +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 `_. +When it comes to running tests, Mopidy typically makes use of testing tools +like `tox `_ and +`pytest `_. + +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 `_ - A utility library for + mocking out the requests Python library. +- `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 `_ 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 `_ +directory on the Mopidy development branch. \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index e6b2da98..b9b65c80 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -82,6 +82,7 @@ announcements related to Mopidy and Mopidy extensions. config running service + audio troubleshooting diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index c2fd10ad..6d2dd3cd 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -4,7 +4,7 @@ Raspberry Pi ************ -Mopidy runs on all versions of `Raspberry Pi `_. +Mopidy runs on all versions of `Raspberry Pi `_. 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. diff --git a/docs/installation/source.rst b/docs/installation/source.rst index 204cc1df..ee2ffad5 100644 --- a/docs/installation/source.rst +++ b/docs/installation/source.rst @@ -37,36 +37,34 @@ please follow the directions :ref:`here `. 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 `. `_. 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 `, and then you're ready to :doc:`run Mopidy `. diff --git a/docs/service.rst b/docs/service.rst index e99e1645..10c47a68 100644 --- a/docs/service.rst +++ b/docs/service.rst @@ -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 +`_ +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 diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 59d0444e..4a6370e8 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -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' diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 208d2ff1..86a0c19c 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -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) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 60e88a9d..64300ff9 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -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): """ diff --git a/mopidy/audio/icy.py b/mopidy/audio/icy.py deleted file mode 100644 index dd59baae..00000000 --- a/mopidy/audio/icy.py +++ /dev/null @@ -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) diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py index e4e3f427..08bda98d 100644 --- a/mopidy/audio/listener.py +++ b/mopidy/audio/listener.py @@ -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): """ diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index fd5d2d49..c63405b0 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -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) diff --git a/mopidy/audio/tags.py b/mopidy/audio/tags.py new file mode 100644 index 00000000..38a0bac9 --- /dev/null +++ b/mopidy/audio/tags.py @@ -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]] diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index a4333b5a..2027485a 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -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))) diff --git a/mopidy/backend.py b/mopidy/backend.py index 8d7a831e..7412ccc6 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -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): """ diff --git a/mopidy/commands.py b/mopidy/commands.py index 4890c722..50590172 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -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, diff --git a/mopidy/compat.py b/mopidy/compat.py index b563f735..72abcf66 100644 --- a/mopidy/compat.py +++ b/mopidy/compat.py @@ -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)) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 042c20d9..21a6a00b 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -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, diff --git a/mopidy/config/default.conf b/mopidy/config/default.conf index 675381d9..c747703b 100644 --- a/mopidy/config/default.conf +++ b/mopidy/config/default.conf @@ -15,6 +15,7 @@ config_file = mixer = software mixer_volume = output = autoaudiosink +buffer_time = [proxy] scheme = diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 6d10a593..93cb814e 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -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 diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 240de619..04fe0a7e 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -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: diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index 5b7ea221..b8ef734d 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -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 diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index dd484ff8..d6c470f2 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -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) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 0ea78f26..3c17a898 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -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 diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 13efe322..6d7ceeb7 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -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 diff --git a/mopidy/ext.py b/mopidy/ext.py index fe8d0daf..48a623bb 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -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): diff --git a/mopidy/file/library.py b/mopidy/file/library.py index 20ac0632..09fa2cf1 100644 --- a/mopidy/file/library.py +++ b/mopidy/file/library.py @@ -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) diff --git a/mopidy/http/actor.py b/mopidy/http/actor.py index 5fe29134..8b4835da 100644 --- a/mopidy/http/actor.py +++ b/mopidy/http/actor.py @@ -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() diff --git a/mopidy/internal/deprecation.py b/mopidy/internal/deprecation.py index 7b1b915e..776a10a4 100644 --- a/mopidy/internal/deprecation.py +++ b/mopidy/internal/deprecation.py @@ -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: diff --git a/mopidy/internal/deps.py b/mopidy/internal/deps.py index 1f363657..fc67e6fe 100644 --- a/mopidy/internal/deps.py +++ b/mopidy/internal/deps.py @@ -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] diff --git a/mopidy/internal/gi.py b/mopidy/internal/gi.py new file mode 100644 index 00000000..7fa51f09 --- /dev/null +++ b/mopidy/internal/gi.py @@ -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', +] diff --git a/mopidy/internal/network.py b/mopidy/internal/network.py index 4b8b35fe..cefdf8ea 100644 --- a/mopidy/internal/network.py +++ b/mopidy/internal/network.py @@ -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 diff --git a/mopidy/internal/path.py b/mopidy/internal/path.py index 8c560187..498b3016 100644 --- a/mopidy/internal/path.py +++ b/mopidy/internal/path.py @@ -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): diff --git a/mopidy/internal/playlists.py b/mopidy/internal/playlists.py index f8e654af..e80588c9 100644 --- a/mopidy/internal/playlists.py +++ b/mopidy/internal/playlists.py @@ -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 diff --git a/mopidy/internal/process.py b/mopidy/internal/process.py index e826e43c..4bf681dd 100644 --- a/mopidy/internal/process.py +++ b/mopidy/internal/process.py @@ -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 diff --git a/mopidy/internal/timer.py b/mopidy/internal/timer.py index b8dcb30d..7da02e55 100644 --- a/mopidy/internal/timer.py +++ b/mopidy/internal/timer.py @@ -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) diff --git a/mopidy/internal/validation.py b/mopidy/internal/validation.py index 52acc64f..5e15d83b 100644 --- a/mopidy/internal/validation.py +++ b/mopidy/internal/validation.py @@ -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)) diff --git a/mopidy/internal/versioning.py b/mopidy/internal/versioning.py index db1aa949..cb72cc8f 100644 --- a/mopidy/internal/versioning.py +++ b/mopidy/internal/versioning.py @@ -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 diff --git a/mopidy/internal/xdg.py b/mopidy/internal/xdg.py index adb43f39..4b5855f1 100644 --- a/mopidy/internal/xdg.py +++ b/mopidy/internal/xdg.py @@ -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} diff --git a/mopidy/listener.py b/mopidy/listener.py index 7b129955..79c53570 100644 --- a/mopidy/listener.py +++ b/mopidy/listener.py @@ -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)) diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 552e5341..3ee2703e 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -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( diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index d61cf441..ead874a0 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -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) diff --git a/mopidy/local/ext.conf b/mopidy/local/ext.conf index c8fe6b86..b37a3a7a 100644 --- a/mopidy/local/ext.conf +++ b/mopidy/local/ext.conf @@ -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 diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 6fc53f63..16842f59 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -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) diff --git a/mopidy/m3u/__init__.py b/mopidy/m3u/__init__.py index 06825932..6a7fad9a 100644 --- a/mopidy/m3u/__init__.py +++ b/mopidy/m3u/__init__.py @@ -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) diff --git a/mopidy/m3u/actor.py b/mopidy/m3u/actor.py deleted file mode 100644 index 55257f87..00000000 --- a/mopidy/m3u/actor.py +++ /dev/null @@ -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) diff --git a/mopidy/m3u/backend.py b/mopidy/m3u/backend.py new file mode 100644 index 00000000..02719cc7 --- /dev/null +++ b/mopidy/m3u/backend.py @@ -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) diff --git a/mopidy/m3u/ext.conf b/mopidy/m3u/ext.conf index adc0d00a..16291c83 100644 --- a/mopidy/m3u/ext.conf +++ b/mopidy/m3u/ext.conf @@ -1,3 +1,6 @@ [m3u] enabled = true playlists_dir = +base_dir = $XDG_MUSIC_DIR +default_encoding = latin-1 +default_extension = .m3u8 diff --git a/mopidy/m3u/library.py b/mopidy/m3u/library.py deleted file mode 100644 index 291a6194..00000000 --- a/mopidy/m3u/library.py +++ /dev/null @@ -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 [] diff --git a/mopidy/m3u/playlists.py b/mopidy/m3u/playlists.py index 3567f8aa..28be28d9 100644 --- a/mopidy/m3u/playlists.py +++ b/mopidy/m3u/playlists.py @@ -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') diff --git a/mopidy/m3u/translator.py b/mopidy/m3u/translator.py index 0055e56d..da74cc1b 100644 --- a/mopidy/m3u/translator.py +++ b/mopidy/m3u/translator.py @@ -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) + ) diff --git a/mopidy/mixer.py b/mopidy/mixer.py index eb43d810..55531817 100644 --- a/mopidy/mixer.py +++ b/mopidy/mixer.py @@ -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): """ diff --git a/mopidy/models/__init__.py b/mopidy/models/__init__.py index 1ea23be0..f477a323 100644 --- a/mopidy/models/__init__.py +++ b/mopidy/models/__init__.py @@ -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): diff --git a/mopidy/models/fields.py b/mopidy/models/fields.py index 1f3935b4..c686b447 100644 --- a/mopidy/models/fields.py +++ b/mopidy/models/fields.py @@ -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: diff --git a/mopidy/models/immutable.py b/mopidy/models/immutable.py index 8bbf568b..18de7d76 100644 --- a/mopidy/models/immutable.py +++ b/mopidy/models/immutable.py @@ -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 diff --git a/mopidy/mpd/__init__.py b/mopidy/mpd/__init__.py index b2438b07..84cf47cb 100644 --- a/mopidy/mpd/__init__.py +++ b/mopidy/mpd/__init__.py @@ -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): diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index 58c758e4..067d20c5 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -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) diff --git a/mopidy/mpd/exceptions.py b/mopidy/mpd/exceptions.py index 3bd51567..05762683 100644 --- a/mopidy/mpd/exceptions.py +++ b/mopidy/mpd/exceptions.py @@ -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 diff --git a/mopidy/mpd/ext.conf b/mopidy/mpd/ext.conf index fe9a0494..ee518a86 100644 --- a/mopidy/mpd/ext.conf +++ b/mopidy/mpd/ext.conf @@ -7,3 +7,4 @@ max_connections = 20 connection_timeout = 60 zeroconf = Mopidy MPD server on $hostname command_blacklist = listall,listallinfo +default_playlist_scheme = m3u diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index 0d07452c..d619996d 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -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 diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index 48aaae2c..7b943930 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -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) diff --git a/mopidy/mpd/protocol/stored_playlists.py b/mopidy/mpd/protocol/stored_playlists.py index bf31fa10..68ae1e9e 100644 --- a/mopidy/mpd/protocol/stored_playlists.py +++ b/mopidy/mpd/protocol/stored_playlists.py @@ -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) diff --git a/mopidy/mpd/session.py b/mopidy/mpd/session.py index 68550f3b..d484d986 100644 --- a/mopidy/mpd/session.py +++ b/mopidy/mpd/session.py @@ -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): diff --git a/mopidy/mpd/uri_mapper.py b/mopidy/mpd/uri_mapper.py index 9e7ec2dd..bb627a47 100644 --- a/mopidy/mpd/uri_mapper.py +++ b/mopidy/mpd/uri_mapper.py @@ -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) diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index 818d570e..0861b5b0 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -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( diff --git a/mopidy/zeroconf.py b/mopidy/zeroconf.py index ddd155b6..9b7b3808 100644 --- a/mopidy/zeroconf.py +++ b/mopidy/zeroconf.py @@ -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)) diff --git a/tests/__init__.py b/tests/__init__.py index c76c48f0..99806e97 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -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) diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index 046971a8..b6ec6170 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -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) diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index 8c2b9af3..411ce805 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -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 diff --git a/tests/audio/test_tags.py b/tests/audio/test_tags.py new file mode 100644 index 00000000..01475124 --- /dev/null +++ b/tests/audio/test_tags.py @@ -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])) diff --git a/tests/audio/test_utils.py b/tests/audio/test_utils.py index 0b497dad..99c99eb6 100644 --- a/tests/audio/test_utils.py +++ b/tests/audio/test_utils.py @@ -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) diff --git a/tests/core/test_history.py b/tests/core/test_history.py index 068518b6..7f034cad 100644 --- a/tests/core/test_history.py +++ b/tests/core/test_history.py @@ -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: diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 46564860..cfd58793 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -8,14 +8,898 @@ import pykka from mopidy import backend, core from mopidy.internal import deprecation -from mopidy.models import TlTrack, Track +from mopidy.models import Track -from tests import dummy_audio as audio +from tests import dummy_audio -# TODO: split into smaller easier to follow tests. setup is way to complex. -# TODO: just mock tracklist? -class CorePlaybackTest(unittest.TestCase): +class TestPlaybackProvider(backend.PlaybackProvider): + def translate_uri(self, uri): + if 'error' in uri: + raise Exception(uri) + elif 'unplayable' in uri: + return None + else: + return uri + + +# TODO: Replace this with dummy_backend now that it uses a real +# playbackprovider Since we rely on our DummyAudio to actually emit events we +# need a "real" backend and not a mock so the right calls make it through to +# audio. +class TestBackend(pykka.ThreadingActor, backend.Backend): + uri_schemes = ['dummy'] + + def __init__(self, config, audio): + super(TestBackend, self).__init__() + self.playback = TestPlaybackProvider(audio=audio, backend=self) + + +class BaseTest(unittest.TestCase): + config = {'core': {'max_tracklist_length': 10000}} + tracks = [Track(uri='dummy:a', length=1234), + Track(uri='dummy:b', length=1234), + Track(uri='dummy:c', length=1234)] + + def setUp(self): # noqa: N802 + # TODO: use create_proxy helpers. + self.audio = dummy_audio.DummyAudio.start().proxy() + self.backend = TestBackend.start( + audio=self.audio, config=self.config).proxy() + self.core = core.Core( + audio=self.audio, backends=[self.backend], config=self.config) + self.playback = self.core.playback + + # We don't have a core actor running, so call about to finish directly. + self.audio.set_about_to_finish_callback( + self.playback._on_about_to_finish) + + with deprecation.ignore('core.tracklist.add:tracks_arg'): + self.core.tracklist.add(self.tracks) + + self.events = [] + self.patcher = mock.patch('mopidy.audio.listener.AudioListener.send') + self.send_mock = self.patcher.start() + + def send(event, **kwargs): + self.events.append((event, kwargs)) + + self.send_mock.side_effect = send + + def tearDown(self): # noqa: N802 + pykka.ActorRegistry.stop_all() + self.patcher.stop() + + def replay_events(self, until=None): + while self.events: + if self.events[0][0] == until: + break + event, kwargs = self.events.pop(0) + self.core.on_event(event, **kwargs) + + def trigger_about_to_finish(self, replay_until=None): + self.replay_events() + callback = self.audio.get_about_to_finish_callback().get() + callback() + self.replay_events(until=replay_until) + + +class TestPlayHandling(BaseTest): + + def test_get_current_tl_track_play(self): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[0]) + self.replay_events() + + self.assertEqual( + self.core.playback.get_current_tl_track(), tl_tracks[0]) + + def test_get_current_track_play(self): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[0]) + self.replay_events() + + self.assertEqual( + self.core.playback.get_current_track(), self.tracks[0]) + + def test_get_current_tlid_play(self): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[0]) + self.replay_events() + + self.assertEqual( + self.core.playback.get_current_tlid(), tl_tracks[0].tlid) + + def test_play_skips_to_next_on_unplayable_track(self): + """Checks that we handle backend.change_track failing.""" + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.audio.trigger_fake_playback_failure(tl_tracks[0].track.uri) + + self.core.playback.play(tl_tracks[0]) + self.replay_events() + + current_tl_track = self.core.playback.get_current_tl_track() + self.assertEqual(tl_tracks[1], current_tl_track) + + def test_resume_skips_to_next_on_unplayable_track(self): + """Checks that we handle backend.change_track failing when + resuming playback.""" + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[0]) + self.core.playback.pause() + + self.audio.trigger_fake_playback_failure(tl_tracks[1].track.uri) + + self.core.playback.next() + self.core.playback.resume() + self.replay_events() + + current_tl_track = self.core.playback.get_current_tl_track() + self.assertEqual(tl_tracks[2], current_tl_track) + + def test_play_tlid(self): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tlid=tl_tracks[1].tlid) + self.replay_events() + + current_tl_track = self.core.playback.get_current_tl_track() + self.assertEqual(tl_tracks[1], current_tl_track) + + +class TestNextHandling(BaseTest): + + def test_get_current_tl_track_next(self): + self.core.playback.play() + self.replay_events() + + self.core.playback.next() + self.replay_events() + + tl_tracks = self.core.tracklist.get_tl_tracks() + current_tl_track = self.core.playback.get_current_tl_track() + self.assertEqual(current_tl_track, tl_tracks[1]) + + def test_get_pending_tl_track_next(self): + self.core.playback.play() + self.replay_events() + + self.core.playback.next() + + tl_tracks = self.core.tracklist.get_tl_tracks() + self.assertEqual(self.core.playback._pending_tl_track, tl_tracks[1]) + + def test_get_current_track_next(self): + self.core.playback.play() + self.replay_events() + + self.core.playback.next() + self.replay_events() + + current_track = self.core.playback.get_current_track() + self.assertEqual(current_track, self.tracks[1]) + + def test_next_keeps_finished_track_in_tracklist(self): + tl_track = self.core.tracklist.get_tl_tracks()[0] + + self.core.playback.play(tl_track) + self.replay_events() + + self.core.playback.next() + self.replay_events() + + self.assertIn(tl_track, self.core.tracklist.tl_tracks) + + def test_next_skips_over_unplayable_track(self): + tl_tracks = self.core.tracklist.get_tl_tracks() + self.audio.trigger_fake_playback_failure(tl_tracks[1].track.uri) + self.core.playback.play(tl_tracks[0]) + self.replay_events() + + self.core.playback.next() + self.replay_events() + + assert self.core.playback.get_current_tl_track() == tl_tracks[2] + + def test_next_skips_over_change_track_error(self): + # Trigger an exception in translate_uri. + track = Track(uri='dummy:error', length=1234) + self.core.tracklist.add(tracks=[track], at_position=1) + + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play() + self.replay_events() + + self.core.playback.next() + self.replay_events() + + assert self.core.playback.get_current_tl_track() == tl_tracks[2] + + def test_next_skips_over_change_track_unplayable(self): + # Make translate_uri return None. + track = Track(uri='dummy:unplayable', length=1234) + self.core.tracklist.add(tracks=[track], at_position=1) + + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play() + self.replay_events() + + self.core.playback.next() + self.replay_events() + + assert self.core.playback.get_current_tl_track() == tl_tracks[2] + + +class TestPreviousHandling(BaseTest): + # TODO Test previous() more + + def test_get_current_tl_track_prev(self): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[1]) + self.core.playback.previous() + self.replay_events() + + self.assertEqual( + self.core.playback.get_current_tl_track(), tl_tracks[0]) + + def test_get_current_track_prev(self): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[1]) + self.core.playback.previous() + self.replay_events() + + self.assertEqual( + self.core.playback.get_current_track(), self.tracks[0]) + + def test_previous_keeps_finished_track_in_tracklist(self): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[1]) + + self.core.playback.previous() + self.replay_events() + + self.assertIn(tl_tracks[1], self.core.tracklist.tl_tracks) + + def test_previous_keeps_finished_track_even_in_consume_mode(self): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[1]) + self.core.tracklist.consume = True + + self.core.playback.previous() + self.replay_events() + + self.assertIn(tl_tracks[1], self.core.tracklist.tl_tracks) + + def test_previous_skips_over_unplayable_track(self): + tl_tracks = self.core.tracklist.get_tl_tracks() + self.audio.trigger_fake_playback_failure(tl_tracks[1].track.uri) + self.core.playback.play(tl_tracks[2]) + self.replay_events() + + self.core.playback.previous() + self.replay_events() + + assert self.core.playback.get_current_tl_track() == tl_tracks[0] + + def test_previous_skips_over_change_track_error(self): + # Trigger an exception in translate_uri. + track = Track(uri='dummy:error', length=1234) + self.core.tracklist.add(tracks=[track], at_position=1) + + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[2]) + self.replay_events() + + self.core.playback.previous() + self.replay_events() + + assert self.core.playback.get_current_tl_track() == tl_tracks[0] + + def test_previous_skips_over_change_track_unplayable(self): + # Makes translate_uri return None. + track = Track(uri='dummy:unplayable', length=1234) + self.core.tracklist.add(tracks=[track], at_position=1) + + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[2]) + self.replay_events() + + self.core.playback.previous() + self.replay_events() + + assert self.core.playback.get_current_tl_track() == tl_tracks[0] + + +class TestOnAboutToFinish(BaseTest): + + def test_on_about_to_finish_keeps_finished_track_in_tracklist(self): + tl_track = self.core.tracklist.get_tl_tracks()[0] + + self.core.playback.play(tl_track) + self.trigger_about_to_finish() + + self.assertIn(tl_track, self.core.tracklist.tl_tracks) + + def test_on_about_to_finish_skips_over_change_track_error(self): + # Trigger an exception in translate_uri. + track = Track(uri='dummy:error', length=1234) + self.core.tracklist.add(tracks=[track], at_position=1) + + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[0]) + self.replay_events() + + self.trigger_about_to_finish() + + assert self.core.playback.get_current_tl_track() == tl_tracks[2] + + def test_on_about_to_finish_skips_over_change_track_unplayable(self): + # Makes translate_uri return None. + track = Track(uri='dummy:unplayable', length=1234) + self.core.tracklist.add(tracks=[track], at_position=1) + + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[0]) + self.replay_events() + + self.trigger_about_to_finish() + + assert self.core.playback.get_current_tl_track() == tl_tracks[2] + + +class TestConsumeHandling(BaseTest): + + def test_next_in_consume_mode_removes_finished_track(self): + tl_track = self.core.tracklist.get_tl_tracks()[0] + + self.core.playback.play(tl_track) + self.core.tracklist.set_consume(True) + self.replay_events() + + self.core.playback.next() + self.replay_events() + + self.assertNotIn(tl_track, self.core.tracklist.get_tl_tracks()) + + def test_next_in_consume_mode_removes_unplayable_track(self): + last_playable_tl_track = self.core.tracklist.get_tl_tracks()[-2] + unplayable_tl_track = self.core.tracklist.get_tl_tracks()[-1] + self.audio.trigger_fake_playback_failure(unplayable_tl_track.track.uri) + + self.core.playback.play(last_playable_tl_track) + self.core.tracklist.set_consume(True) + + self.core.playback.next() + self.replay_events() + + self.assertNotIn( + unplayable_tl_track, self.core.tracklist.get_tl_tracks()) + + def test_on_about_to_finish_in_consume_mode_removes_finished_track(self): + tl_track = self.core.tracklist.get_tl_tracks()[0] + + self.core.playback.play(tl_track) + self.core.tracklist.consume = True + self.trigger_about_to_finish() + + self.assertNotIn(tl_track, self.core.tracklist.get_tl_tracks()) + + +class TestCurrentAndPendingTlTrack(BaseTest): + + def test_get_current_tl_track_none(self): + self.assertEqual( + self.core.playback.get_current_tl_track(), None) + + def test_get_current_tlid_none(self): + self.assertEqual(self.core.playback.get_current_tlid(), None) + + def test_pending_tl_track_is_none(self): + self.core.playback.play() + self.replay_events() + self.assertEqual(self.playback._pending_tl_track, None) + + def test_pending_tl_track_after_about_to_finish(self): + self.core.playback.play() + self.replay_events() + + self.trigger_about_to_finish(replay_until='stream_changed') + self.assertEqual(self.playback._pending_tl_track.track.uri, 'dummy:b') + + def test_pending_tl_track_after_stream_changed(self): + self.trigger_about_to_finish() + self.assertEqual(self.playback._pending_tl_track, None) + + def test_current_tl_track_after_about_to_finish(self): + self.core.playback.play() + self.replay_events() + self.trigger_about_to_finish(replay_until='stream_changed') + self.assertEqual(self.playback.current_tl_track.track.uri, 'dummy:a') + + def test_current_tl_track_after_stream_changed(self): + self.core.playback.play() + self.replay_events() + self.trigger_about_to_finish() + self.assertEqual(self.playback.current_tl_track.track.uri, 'dummy:b') + + def test_current_tl_track_after_end_of_stream(self): + self.core.playback.play() + self.replay_events() + self.trigger_about_to_finish() + self.trigger_about_to_finish() + self.trigger_about_to_finish() # EOS + self.assertEqual(self.playback.current_tl_track, None) + + +@mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) +class EventEmissionTest(BaseTest): + + maxDiff = None + + def test_play_when_stopped_emits_events(self, listener_mock): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[0]) + self.replay_events() + + self.assertListEqual( + [ + mock.call( + 'playback_state_changed', + old_state='stopped', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=tl_tracks[0]), + ], + listener_mock.send.mock_calls) + + def test_play_when_paused_emits_events(self, listener_mock): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[0]) + self.replay_events() + + self.core.playback.pause() + self.replay_events() + listener_mock.reset_mock() + + self.core.playback.play(tl_tracks[1]) + self.replay_events() + + self.assertListEqual( + [ + mock.call( + 'track_playback_ended', + tl_track=tl_tracks[0], time_position=mock.ANY), + mock.call( + 'playback_state_changed', + old_state='paused', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=tl_tracks[1]), + ], + listener_mock.send.mock_calls) + + def test_play_when_playing_emits_events(self, listener_mock): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[0]) + self.replay_events() + listener_mock.reset_mock() + + self.core.playback.play(tl_tracks[2]) + self.replay_events() + + self.assertListEqual( + [ + mock.call( + 'track_playback_ended', + tl_track=tl_tracks[0], time_position=mock.ANY), + mock.call( + 'playback_state_changed', old_state='playing', + new_state='playing'), + mock.call( + 'track_playback_started', tl_track=tl_tracks[2]), + ], + listener_mock.send.mock_calls) + + def test_pause_emits_events(self, listener_mock): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[0]) + self.replay_events() + + self.core.playback.seek(1000) + listener_mock.reset_mock() + + self.core.playback.pause() + + self.assertListEqual( + [ + mock.call( + 'playback_state_changed', + old_state='playing', new_state='paused'), + mock.call( + 'track_playback_paused', + tl_track=tl_tracks[0], time_position=1000), + ], + listener_mock.send.mock_calls) + + def test_resume_emits_events(self, listener_mock): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[0]) + self.replay_events() + + self.core.playback.pause() + self.core.playback.seek(1000) + listener_mock.reset_mock() + + self.core.playback.resume() + + self.assertListEqual( + [ + mock.call( + 'playback_state_changed', + old_state='paused', new_state='playing'), + mock.call( + 'track_playback_resumed', + tl_track=tl_tracks[0], time_position=1000), + ], + listener_mock.send.mock_calls) + + def test_stop_emits_events(self, listener_mock): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[0]) + self.replay_events() + self.core.playback.seek(1000) + self.replay_events() + listener_mock.reset_mock() + + self.core.playback.stop() + self.replay_events() + + self.assertListEqual( + [ + mock.call( + 'playback_state_changed', + old_state='playing', new_state='stopped'), + mock.call( + 'track_playback_ended', + tl_track=tl_tracks[0], time_position=1000), + ], + listener_mock.send.mock_calls) + + def test_next_emits_events(self, listener_mock): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[0]) + self.replay_events() + self.core.playback.seek(1000) + self.replay_events() + listener_mock.reset_mock() + + self.core.playback.next() + self.replay_events() + + self.assertListEqual( + [ + mock.call( + 'track_playback_ended', + tl_track=tl_tracks[0], time_position=mock.ANY), + mock.call( + 'playback_state_changed', + old_state='playing', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=tl_tracks[1]), + ], + listener_mock.send.mock_calls) + + def test_next_emits_events_when_consume_mode_is_enabled( + self, + listener_mock): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.tracklist.set_consume(True) + self.core.playback.play(tl_tracks[0]) + self.replay_events() + self.core.playback.seek(1000) + self.replay_events() + listener_mock.reset_mock() + + self.core.playback.next() + self.replay_events() + + self.assertListEqual( + [ + mock.call( + 'tracklist_changed'), + mock.call( + 'track_playback_ended', + tl_track=tl_tracks[0], time_position=mock.ANY), + mock.call( + 'playback_state_changed', + old_state='playing', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=tl_tracks[1]), + ], + listener_mock.send.mock_calls) + + def test_gapless_track_change_emits_events(self, listener_mock): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[0]) + self.replay_events() + listener_mock.reset_mock() + + self.trigger_about_to_finish() + + self.assertListEqual( + [ + mock.call( + 'track_playback_ended', + tl_track=tl_tracks[0], time_position=mock.ANY), + mock.call( + 'playback_state_changed', + old_state='playing', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=tl_tracks[1]), + ], + listener_mock.send.mock_calls) + + def test_seek_emits_seeked_event(self, listener_mock): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[0]) + self.replay_events() + listener_mock.reset_mock() + + self.core.playback.seek(1000) + self.replay_events() + + listener_mock.send.assert_called_once_with( + 'seeked', time_position=1000) + + def test_seek_past_end_of_track_emits_events(self, listener_mock): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[0]) + self.replay_events() + listener_mock.reset_mock() + + self.core.playback.seek(self.tracks[0].length * 5) + self.replay_events() + + self.assertListEqual( + [ + mock.call( + 'track_playback_ended', + tl_track=tl_tracks[0], time_position=mock.ANY), + mock.call( + 'playback_state_changed', + old_state='playing', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=tl_tracks[1]), + ], + listener_mock.send.mock_calls) + + def test_seek_race_condition_emits_events(self, listener_mock): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[0]) + self.trigger_about_to_finish(replay_until='stream_changed') + listener_mock.reset_mock() + + self.core.playback.seek(1000) + self.replay_events() + + # When we trigger seek after an about to finish the other code that + # emits track stopped/started and playback state changed events gets + # triggered as we have to switch back to the previous track. + # The correct behavior would be to only emit seeked. + self.assertListEqual( + [mock.call('seeked', time_position=1000)], + listener_mock.send.mock_calls) + + def test_previous_emits_events(self, listener_mock): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[1]) + self.replay_events() + listener_mock.reset_mock() + + self.core.playback.previous() + self.replay_events() + + self.assertListEqual( + [ + mock.call( + 'track_playback_ended', + tl_track=tl_tracks[1], time_position=mock.ANY), + mock.call( + 'playback_state_changed', + old_state='playing', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=tl_tracks[0]), + ], + listener_mock.send.mock_calls) + + +class TestUnplayableURI(BaseTest): + + tracks = [ + Track(uri='unplayable://'), + Track(uri='dummy:b'), + ] + + def setUp(self): # noqa: N802 + super(TestUnplayableURI, self).setUp() + tl_tracks = self.core.tracklist.get_tl_tracks() + self.core.playback._set_current_tl_track(tl_tracks[0]) + + def test_play_skips_to_next_if_track_is_unplayable(self): + self.core.playback.play() + + self.replay_events() + + current_track = self.core.playback.get_current_track() + self.assertEqual(current_track, self.tracks[1]) + + def test_pause_changes_state_even_if_track_is_unplayable(self): + self.core.playback.pause() + self.assertEqual(self.core.playback.state, core.PlaybackState.PAUSED) + + def test_resume_does_nothing_if_track_is_unplayable(self): + self.core.playback.state = core.PlaybackState.PAUSED + self.core.playback.resume() + + self.assertEqual(self.core.playback.state, core.PlaybackState.PAUSED) + + def test_stop_changes_state_even_if_track_is_unplayable(self): + self.core.playback.state = core.PlaybackState.PAUSED + self.core.playback.stop() + + self.assertEqual(self.core.playback.state, core.PlaybackState.STOPPED) + + def test_time_position_returns_0_if_track_is_unplayable(self): + result = self.core.playback.time_position + + self.assertEqual(result, 0) + + def test_seek_fails_for_unplayable_track(self): + self.core.playback.state = core.PlaybackState.PLAYING + success = self.core.playback.seek(1000) + + self.assertFalse(success) + + +class SeekTest(BaseTest): + + def test_seek_normalizes_negative_positions_to_zero(self): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[0]) + self.replay_events() + + self.core.playback.seek(-100) # Dummy audio doesn't progress time. + self.assertEqual(0, self.core.playback.get_time_position()) + + def test_seek_fails_for_track_without_duration(self): + track = self.tracks[0].replace(length=None) + self.core.tracklist.clear() + self.core.tracklist.add([track]) + + self.core.playback.play() + self.replay_events() + + self.assertFalse(self.core.playback.seek(1000)) + self.assertEqual(0, self.core.playback.get_time_position()) + + def test_seek_play_stay_playing(self): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[0]) + self.replay_events() + + self.core.playback.seek(1000) + self.assertEqual(self.core.playback.state, core.PlaybackState.PLAYING) + + def test_seek_paused_stay_paused(self): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[0]) + self.replay_events() + + self.core.playback.pause() + self.replay_events() + + self.core.playback.seek(1000) + self.assertEqual(self.core.playback.state, core.PlaybackState.PAUSED) + + def test_seek_race_condition_after_about_to_finish(self): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[0]) + self.replay_events() + + self.trigger_about_to_finish(replay_until='stream_changed') + self.core.playback.seek(1000) + self.replay_events() + + current_tl_track = self.core.playback.get_current_tl_track() + self.assertEqual(current_tl_track, tl_tracks[0]) + + +class TestStream(BaseTest): + + def test_get_stream_title_before_playback(self): + self.assertEqual(self.playback.get_stream_title(), None) + + def test_get_stream_title_during_playback(self): + self.core.playback.play() + self.replay_events() + + self.assertEqual(self.playback.get_stream_title(), None) + + def test_get_stream_title_during_playback_with_tags_change(self): + self.core.playback.play() + self.audio.trigger_fake_tags_changed({'organization': ['baz']}) + self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() + self.replay_events() + + self.assertEqual(self.playback.get_stream_title(), 'foobar') + + def test_get_stream_title_after_next(self): + self.core.playback.play() + self.audio.trigger_fake_tags_changed({'organization': ['baz']}) + self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() + self.replay_events() + + self.core.playback.next() + self.replay_events() + + self.assertEqual(self.playback.get_stream_title(), None) + + def test_get_stream_title_after_next_with_tags_change(self): + self.core.playback.play() + self.audio.trigger_fake_tags_changed({'organization': ['baz']}) + self.audio.trigger_fake_tags_changed({'title': ['foo']}).get() + self.replay_events() + + self.core.playback.next() + self.audio.trigger_fake_tags_changed({'organization': ['baz']}) + self.audio.trigger_fake_tags_changed({'title': ['bar']}).get() + self.replay_events() + + self.assertEqual(self.playback.get_stream_title(), 'bar') + + def test_get_stream_title_after_stop(self): + self.core.playback.play() + self.audio.trigger_fake_tags_changed({'organization': ['baz']}) + self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() + self.replay_events() + + self.core.playback.stop() + self.replay_events() + self.assertEqual(self.playback.get_stream_title(), None) + + +class TestBackendSelection(unittest.TestCase): def setUp(self): # noqa: N802 config = { @@ -27,134 +911,33 @@ class CorePlaybackTest(unittest.TestCase): self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] self.playback1 = mock.Mock(spec=backend.PlaybackProvider) - self.playback1.get_time_position.return_value.get.return_value = 1000 self.backend1.playback = self.playback1 self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2'] self.playback2 = mock.Mock(spec=backend.PlaybackProvider) - self.playback2.get_time_position.return_value.get.return_value = 2000 self.backend2.playback = self.playback2 - # A backend without the optional playback provider - self.backend3 = mock.Mock() - self.backend3.uri_schemes.get.return_value = ['dummy3'] - self.backend3.has_playback.return_value.get.return_value = False - - # A backend for which 'change_track' fails - self.backend4 = mock.Mock() - self.backend4.uri_schemes.get.return_value = ['dummy4'] - self.playback4 = mock.Mock(spec=backend.PlaybackProvider) - self.playback4.get_time_position.return_value.get.return_value = 1000 - future_mock = mock.Mock(spec=pykka.future.Future) - future_mock.get.return_value = False - self.playback4.change_track.return_value = future_mock - self.backend4.playback = self.playback4 - self.tracks = [ Track(uri='dummy1:a', length=40000), Track(uri='dummy2:a', length=40000), - Track(uri='dummy3:a', length=40000), # No playback provider - Track(uri='dummy1:b', length=40000), - Track(uri='dummy1:c', length=None), # No duration - Track(uri='dummy4:a', length=40000), # Unplayable - Track(uri='dummy1:d', length=40000), ] - self.uris = [ - 'dummy1:a', 'dummy2:a', 'dummy3:a', 'dummy1:b', 'dummy1:c', - 'dummy4:a', 'dummy1:d'] - self.core = core.Core(config, mixer=None, backends=[ - self.backend1, self.backend2, self.backend3, self.backend4]) + self.backend1, self.backend2]) - 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.tl_tracks = self.core.tracklist.add(self.tracks) - self.lookup_patcher = mock.patch.object(self.core.library, 'lookup') - self.lookup_mock = self.lookup_patcher.start() - self.lookup_mock.side_effect = lookup - - self.core.tracklist.add(uris=self.uris) - - self.tl_tracks = self.core.tracklist.tl_tracks - self.unplayable_tl_track = self.tl_tracks[2] - self.duration_less_tl_track = self.tl_tracks[4] - - def tearDown(self): # noqa: N802 - self.lookup_patcher.stop() - - def trigger_end_of_track(self): - self.core.playback._on_end_of_track() - - def set_current_tl_track(self, tl_track): - self.core.playback._set_current_tl_track(tl_track) - - def test_get_current_tl_track_none(self): - self.set_current_tl_track(None) - - self.assertEqual( - self.core.playback.get_current_tl_track(), None) - - def test_get_current_tl_track_play(self): - self.core.playback.play(self.tl_tracks[0]) - - self.assertEqual( - self.core.playback.get_current_tl_track(), self.tl_tracks[0]) - - def test_get_current_tl_track_next(self): - self.core.playback.play(self.tl_tracks[0]) - self.core.playback.next() - - self.assertEqual( - self.core.playback.get_current_tl_track(), self.tl_tracks[1]) - - def test_get_current_tl_track_prev(self): - self.core.playback.play(self.tl_tracks[1]) - self.core.playback.previous() - - self.assertEqual( - self.core.playback.get_current_tl_track(), self.tl_tracks[0]) - - def test_get_current_track_play(self): - self.core.playback.play(self.tl_tracks[0]) - - self.assertEqual( - self.core.playback.get_current_track(), self.tracks[0]) - - def test_get_current_track_next(self): - self.core.playback.play(self.tl_tracks[0]) - self.core.playback.next() - - self.assertEqual( - self.core.playback.get_current_track(), self.tracks[1]) - - def test_get_current_track_prev(self): - self.core.playback.play(self.tl_tracks[1]) - self.core.playback.previous() - - self.assertEqual( - self.core.playback.get_current_track(), self.tracks[0]) - - def test_get_current_tlid_none(self): - self.set_current_tl_track(None) - - self.assertEqual(self.core.playback.get_current_tlid(), None) - - def test_get_current_tlid_play(self): - self.core.playback.play(self.tl_tracks[0]) - - self.assertEqual( - self.core.playback.get_current_tlid(), self.tl_tracks[0].tlid) - - # TODO Test state + def trigger_stream_changed(self): + pending = self.core.playback._pending_tl_track + if pending: + self.core.stream_changed(uri=pending.track.uri) + else: + self.core.stream_changed(uri=None) def test_play_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) + self.trigger_stream_changed() self.playback1.prepare_change.assert_called_once_with() self.playback1.change_track.assert_called_once_with(self.tracks[0]) @@ -163,123 +946,17 @@ class CorePlaybackTest(unittest.TestCase): def test_play_selects_dummy2_backend(self): self.core.playback.play(self.tl_tracks[1]) + self.trigger_stream_changed() self.assertFalse(self.playback1.play.called) self.playback2.prepare_change.assert_called_once_with() self.playback2.change_track.assert_called_once_with(self.tracks[1]) self.playback2.play.assert_called_once_with() - def test_play_skips_to_next_on_track_without_playback_backend(self): - self.core.playback.play(self.unplayable_tl_track) - - self.playback1.prepare_change.assert_called_once_with() - self.playback1.change_track.assert_called_once_with(self.tracks[3]) - self.playback1.play.assert_called_once_with() - self.assertFalse(self.playback2.play.called) - - self.assertEqual( - self.core.playback.current_tl_track, self.tl_tracks[3]) - - def test_play_skips_to_next_on_unplayable_track(self): - """Checks that we handle backend.change_track failing.""" - self.playback2.change_track.return_value.get.return_value = False - - self.core.tracklist.clear() - self.core.tracklist.add(uris=self.uris[-2:]) - tl_tracks = self.core.tracklist.tl_tracks - - self.core.playback.play(tl_tracks[0]) - - assert self.core.playback.get_current_tl_track() == tl_tracks[1] - - def test_pause_play_skips_to_next_on_unplayable_track(self): - """Checks that we handle backend.change_track failing.""" - self.playback2.change_track.return_value.get.return_value = False - - self.core.tracklist.clear() - self.core.tracklist.add(uris=self.uris[-3:]) - tl_tracks = self.core.tracklist.tl_tracks - - self.core.playback.pause() - self.core.playback._set_current_tl_track(tl_tracks[0]) - self.core.playback.next() - self.core.playback.play(self.core.playback.get_current_tl_track()) - assert self.core.playback.get_current_tl_track() == tl_tracks[2] - - def test_pause_resume_skips_to_next_on_unplayable_track(self): - """Checks that we handle backend.change_track failing.""" - self.playback2.change_track.return_value.get.return_value = False - - self.core.tracklist.clear() - self.core.tracklist.add(uris=self.uris[-3:]) - tl_tracks = self.core.tracklist.tl_tracks - - self.core.playback.pause() - self.core.playback._set_current_tl_track(tl_tracks[0]) - self.core.playback.next() - self.core.playback.resume() - assert self.core.playback.get_current_tl_track() == tl_tracks[2] - - @mock.patch( - 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) - def test_play_when_stopped_emits_events(self, listener_mock): - self.core.playback.play(self.tl_tracks[0]) - - self.assertListEqual( - listener_mock.send.mock_calls, - [ - mock.call( - 'playback_state_changed', - old_state='stopped', new_state='playing'), - mock.call( - 'track_playback_started', tl_track=self.tl_tracks[0]), - ]) - - @mock.patch( - 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) - def test_play_when_paused_emits_events(self, listener_mock): - self.core.playback.play(self.tl_tracks[0]) - self.core.playback.pause() - listener_mock.reset_mock() - - self.core.playback.play(self.tl_tracks[1]) - - self.assertListEqual( - listener_mock.send.mock_calls, - [ - mock.call( - 'playback_state_changed', - old_state='paused', new_state='playing'), - mock.call( - 'track_playback_started', tl_track=self.tl_tracks[1]), - ]) - - @mock.patch( - 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) - def test_play_when_playing_emits_events(self, listener_mock): - self.core.playback.play(self.tl_tracks[0]) - listener_mock.reset_mock() - - self.core.playback.play(self.tl_tracks[3]) - - self.assertListEqual( - listener_mock.send.mock_calls, - [ - mock.call( - 'playback_state_changed', - old_state='playing', new_state='stopped'), - mock.call( - 'track_playback_ended', - tl_track=self.tl_tracks[0], time_position=1000), - mock.call( - 'playback_state_changed', - old_state='stopped', new_state='playing'), - mock.call( - 'track_playback_started', tl_track=self.tl_tracks[3]), - ]) - def test_pause_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) + self.trigger_stream_changed() + self.core.playback.pause() self.playback1.pause.assert_called_once_with() @@ -287,40 +964,17 @@ class CorePlaybackTest(unittest.TestCase): def test_pause_selects_dummy2_backend(self): self.core.playback.play(self.tl_tracks[1]) + self.trigger_stream_changed() + self.core.playback.pause() self.assertFalse(self.playback1.pause.called) self.playback2.pause.assert_called_once_with() - def test_pause_changes_state_even_if_track_is_unplayable(self): - self.set_current_tl_track(self.unplayable_tl_track) - self.core.playback.pause() - - self.assertEqual(self.core.playback.state, core.PlaybackState.PAUSED) - self.assertFalse(self.playback1.pause.called) - self.assertFalse(self.playback2.pause.called) - - @mock.patch( - 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) - def test_pause_emits_events(self, listener_mock): - self.core.playback.play(self.tl_tracks[0]) - listener_mock.reset_mock() - - self.core.playback.pause() - - self.assertListEqual( - listener_mock.send.mock_calls, - [ - mock.call( - 'playback_state_changed', - old_state='playing', new_state='paused'), - mock.call( - 'track_playback_paused', - tl_track=self.tl_tracks[0], time_position=1000), - ]) - def test_resume_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) + self.trigger_stream_changed() + self.core.playback.pause() self.core.playback.resume() @@ -329,255 +983,38 @@ class CorePlaybackTest(unittest.TestCase): def test_resume_selects_dummy2_backend(self): self.core.playback.play(self.tl_tracks[1]) + self.trigger_stream_changed() + self.core.playback.pause() self.core.playback.resume() self.assertFalse(self.playback1.resume.called) self.playback2.resume.assert_called_once_with() - def test_resume_does_nothing_if_track_is_unplayable(self): - self.set_current_tl_track(self.unplayable_tl_track) - self.core.playback.state = core.PlaybackState.PAUSED - self.core.playback.resume() - - self.assertEqual(self.core.playback.state, core.PlaybackState.PAUSED) - self.assertFalse(self.playback1.resume.called) - self.assertFalse(self.playback2.resume.called) - - @mock.patch( - 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) - def test_resume_emits_events(self, listener_mock): - self.core.playback.play(self.tl_tracks[0]) - self.core.playback.pause() - listener_mock.reset_mock() - - self.core.playback.resume() - - self.assertListEqual( - listener_mock.send.mock_calls, - [ - mock.call( - 'playback_state_changed', - old_state='paused', new_state='playing'), - mock.call( - 'track_playback_resumed', - tl_track=self.tl_tracks[0], time_position=1000), - ]) - def test_stop_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) + self.trigger_stream_changed() + self.core.playback.stop() + self.trigger_stream_changed() self.playback1.stop.assert_called_once_with() self.assertFalse(self.playback2.stop.called) def test_stop_selects_dummy2_backend(self): self.core.playback.play(self.tl_tracks[1]) + self.trigger_stream_changed() + self.core.playback.stop() + self.trigger_stream_changed() self.assertFalse(self.playback1.stop.called) self.playback2.stop.assert_called_once_with() - def test_stop_changes_state_even_if_track_is_unplayable(self): - self.set_current_tl_track(self.unplayable_tl_track) - self.core.playback.state = core.PlaybackState.PAUSED - self.core.playback.stop() - - self.assertEqual(self.core.playback.state, core.PlaybackState.STOPPED) - self.assertFalse(self.playback1.stop.called) - self.assertFalse(self.playback2.stop.called) - - @mock.patch( - 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) - def test_stop_emits_events(self, listener_mock): - self.core.playback.play(self.tl_tracks[0]) - listener_mock.reset_mock() - - self.core.playback.stop() - - self.assertListEqual( - listener_mock.send.mock_calls, - [ - mock.call( - 'playback_state_changed', - old_state='playing', new_state='stopped'), - mock.call( - 'track_playback_ended', - tl_track=self.tl_tracks[0], time_position=1000), - ]) - - # TODO Test next() more - - def test_next_keeps_finished_track_in_tracklist(self): - tl_track = self.tl_tracks[0] - self.core.playback.play(tl_track) - - self.core.playback.next() - - self.assertIn(tl_track, self.core.tracklist.tl_tracks) - - def test_next_in_consume_mode_removes_finished_track(self): - tl_track = self.tl_tracks[0] - self.core.playback.play(tl_track) - self.core.tracklist.consume = True - - self.core.playback.next() - - self.assertNotIn(tl_track, self.core.tracklist.tl_tracks) - - def test_next_in_consume_mode_removes_unplayable_track(self): - self.backend1.playback.change_track = mock.PropertyMock() - self.backend1.playback.change_track.return_value.get.return_value = ( - False) - - self.backend2.playback.change_track = mock.PropertyMock() - self.backend2.playback.change_track.return_value.get.return_value = ( - False) - self.core.tracklist.set_consume(True) - - self.core.playback.play(self.tl_tracks[0]) - self.core.playback.next() - - tl_tracks = self.core.tracklist.get_tl_tracks() - self.assertNotIn(self.tl_tracks[1], tl_tracks) - self.assertNotIn(self.tl_tracks[2], tl_tracks) - - @mock.patch( - 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) - def test_next_emits_events(self, listener_mock): - self.core.playback.play(self.tl_tracks[0]) - listener_mock.reset_mock() - - self.core.playback.next() - - self.assertListEqual( - listener_mock.send.mock_calls, - [ - mock.call( - 'playback_state_changed', - old_state='playing', new_state='stopped'), - mock.call( - 'track_playback_ended', - tl_track=self.tl_tracks[0], time_position=mock.ANY), - mock.call( - 'playback_state_changed', - old_state='stopped', new_state='playing'), - mock.call( - 'track_playback_started', tl_track=self.tl_tracks[1]), - ]) - - # TODO Test previous() more - - def test_previous_keeps_finished_track_in_tracklist(self): - tl_track = self.tl_tracks[1] - self.core.playback.play(tl_track) - - self.core.playback.previous() - - self.assertIn(tl_track, self.core.tracklist.tl_tracks) - - def test_previous_keeps_finished_track_even_in_consume_mode(self): - tl_track = self.tl_tracks[1] - self.core.playback.play(tl_track) - self.core.tracklist.consume = True - - self.core.playback.previous() - - self.assertIn(tl_track, self.core.tracklist.tl_tracks) - - @mock.patch( - 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) - def test_previous_emits_events(self, listener_mock): - self.core.playback.play(self.tl_tracks[1]) - listener_mock.reset_mock() - - self.core.playback.previous() - - self.assertListEqual( - listener_mock.send.mock_calls, - [ - mock.call( - 'playback_state_changed', - old_state='playing', new_state='stopped'), - mock.call( - 'track_playback_ended', - tl_track=self.tl_tracks[1], time_position=mock.ANY), - mock.call( - 'playback_state_changed', - old_state='stopped', new_state='playing'), - mock.call( - 'track_playback_started', tl_track=self.tl_tracks[0]), - ]) - - # TODO Test on_end_of_track() more - - def test_on_end_of_track_keeps_finished_track_in_tracklist(self): - tl_track = self.tl_tracks[0] - self.core.playback.play(tl_track) - - self.trigger_end_of_track() - - self.assertIn(tl_track, self.core.tracklist.tl_tracks) - - def test_on_end_of_track_in_consume_mode_removes_finished_track(self): - tl_track = self.tl_tracks[0] - self.core.playback.play(tl_track) - self.core.tracklist.consume = True - - self.trigger_end_of_track() - - self.assertNotIn(tl_track, self.core.tracklist.tl_tracks) - - @mock.patch( - 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) - def test_on_end_of_track_emits_events(self, listener_mock): - self.core.playback.play(self.tl_tracks[0]) - listener_mock.reset_mock() - - self.trigger_end_of_track() - - self.assertListEqual( - listener_mock.send.mock_calls, - [ - mock.call( - 'playback_state_changed', - old_state='playing', new_state='stopped'), - mock.call( - 'track_playback_ended', - tl_track=self.tl_tracks[0], time_position=mock.ANY), - mock.call( - 'playback_state_changed', - old_state='stopped', new_state='playing'), - mock.call( - 'track_playback_started', tl_track=self.tl_tracks[1]), - ]) - - @mock.patch( - 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) - def test_seek_past_end_of_track_emits_events(self, listener_mock): - self.core.playback.play(self.tl_tracks[0]) - listener_mock.reset_mock() - - self.core.playback.seek(self.tracks[0].length * 5) - - self.assertListEqual( - listener_mock.send.mock_calls, - [ - mock.call( - 'playback_state_changed', - old_state='playing', new_state='stopped'), - mock.call( - 'track_playback_ended', - tl_track=self.tl_tracks[0], time_position=mock.ANY), - mock.call( - 'playback_state_changed', - old_state='stopped', new_state='playing'), - mock.call( - 'track_playback_started', tl_track=self.tl_tracks[1]), - ]) - def test_seek_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) + self.trigger_stream_changed() + self.core.playback.seek(10000) self.playback1.seek.assert_called_once_with(10000) @@ -585,63 +1022,17 @@ class CorePlaybackTest(unittest.TestCase): def test_seek_selects_dummy2_backend(self): self.core.playback.play(self.tl_tracks[1]) + self.trigger_stream_changed() + self.core.playback.seek(10000) self.assertFalse(self.playback1.seek.called) self.playback2.seek.assert_called_once_with(10000) - def test_seek_normalizes_negative_positions_to_zero(self): - self.core.playback.play(self.tl_tracks[0]) - self.core.playback.seek(-100) - - self.playback1.seek.assert_called_once_with(0) - - def test_seek_fails_for_unplayable_track(self): - self.set_current_tl_track(self.unplayable_tl_track) - self.core.playback.state = core.PlaybackState.PLAYING - success = self.core.playback.seek(1000) - - self.assertFalse(success) - self.assertFalse(self.playback1.seek.called) - self.assertFalse(self.playback2.seek.called) - - def test_seek_fails_for_track_without_duration(self): - self.set_current_tl_track(self.duration_less_tl_track) - self.core.playback.state = core.PlaybackState.PLAYING - success = self.core.playback.seek(1000) - - self.assertFalse(success) - self.assertFalse(self.playback1.seek.called) - self.assertFalse(self.playback2.seek.called) - - def test_seek_play_stay_playing(self): - self.core.playback.play(self.tl_tracks[0]) - self.core.playback.state = core.PlaybackState.PLAYING - self.core.playback.seek(1000) - - self.assertEqual(self.core.playback.state, core.PlaybackState.PLAYING) - - def test_seek_paused_stay_paused(self): - self.core.playback.play(self.tl_tracks[0]) - self.core.playback.state = core.PlaybackState.PAUSED - self.core.playback.seek(1000) - - self.assertEqual(self.core.playback.state, core.PlaybackState.PAUSED) - - @mock.patch( - 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) - def test_seek_emits_seeked_event(self, listener_mock): - self.core.playback.play(self.tl_tracks[0]) - listener_mock.reset_mock() - - self.core.playback.seek(1000) - - listener_mock.send.assert_called_once_with( - 'seeked', time_position=1000) - def test_time_position_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) - self.core.playback.seek(10000) + self.trigger_stream_changed() + self.core.playback.time_position self.playback1.get_time_position.assert_called_once_with() @@ -649,126 +1040,15 @@ class CorePlaybackTest(unittest.TestCase): def test_time_position_selects_dummy2_backend(self): self.core.playback.play(self.tl_tracks[1]) - self.core.playback.seek(10000) + self.trigger_stream_changed() + self.core.playback.time_position self.assertFalse(self.playback1.get_time_position.called) self.playback2.get_time_position.assert_called_once_with() - def test_time_position_returns_0_if_track_is_unplayable(self): - self.set_current_tl_track(self.unplayable_tl_track) - result = self.core.playback.time_position - - self.assertEqual(result, 0) - self.assertFalse(self.playback1.get_time_position.called) - self.assertFalse(self.playback2.get_time_position.called) - - # TODO Test on_tracklist_change - - -# Since we rely on our DummyAudio to actually emit events we need a "real" -# backend and not a mock so the right calls make it through to audio. -class TestBackend(pykka.ThreadingActor, backend.Backend): - uri_schemes = ['dummy'] - - def __init__(self, config, audio): - super(TestBackend, self).__init__() - self.playback = backend.PlaybackProvider(audio=audio, backend=self) - - -class TestStream(unittest.TestCase): - - def setUp(self): # noqa: N802 - config = { - 'core': { - 'max_tracklist_length': 10000, - } - } - - self.audio = audio.DummyAudio.start().proxy() - self.backend = TestBackend.start(config={}, audio=self.audio).proxy() - self.core = core.Core( - config, audio=self.audio, backends=[self.backend]) - self.playback = self.core.playback - - self.tracks = [Track(uri='dummy:a', length=1234), - Track(uri='dummy:b', length=1234)] - - self.lookup_patcher = mock.patch.object(self.core.library, 'lookup') - self.lookup_mock = self.lookup_patcher.start() - self.lookup_mock.return_value = {t.uri: [t] for t in self.tracks} - - self.core.tracklist.add(uris=[t.uri for t in self.tracks]) - - self.events = [] - self.send_patcher = mock.patch( - 'mopidy.audio.listener.AudioListener.send') - self.send_mock = self.send_patcher.start() - - def send(event, **kwargs): - self.events.append((event, kwargs)) - - self.send_mock.side_effect = send - - def tearDown(self): # noqa: N802 - pykka.ActorRegistry.stop_all() - self.lookup_patcher.stop() - self.send_patcher.stop() - - def replay_audio_events(self): - while self.events: - event, kwargs = self.events.pop(0) - self.core.on_event(event, **kwargs) - - def test_get_stream_title_before_playback(self): - self.assertEqual(self.playback.get_stream_title(), None) - - def test_get_stream_title_during_playback(self): - self.core.playback.play() - - self.replay_audio_events() - self.assertEqual(self.playback.get_stream_title(), None) - - def test_get_stream_title_during_playback_with_tags_change(self): - self.core.playback.play() - self.audio.trigger_fake_tags_changed({'organization': ['baz']}) - self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() - - self.replay_audio_events() - self.assertEqual(self.playback.get_stream_title(), 'foobar') - - def test_get_stream_title_after_next(self): - self.core.playback.play() - self.audio.trigger_fake_tags_changed({'organization': ['baz']}) - self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() - self.core.playback.next() - - self.replay_audio_events() - self.assertEqual(self.playback.get_stream_title(), None) - - def test_get_stream_title_after_next_with_tags_change(self): - self.core.playback.play() - self.audio.trigger_fake_tags_changed({'organization': ['baz']}) - self.audio.trigger_fake_tags_changed({'title': ['foo']}).get() - self.core.playback.next() - self.audio.trigger_fake_tags_changed({'organization': ['baz']}) - self.audio.trigger_fake_tags_changed({'title': ['bar']}).get() - - self.replay_audio_events() - self.assertEqual(self.playback.get_stream_title(), 'bar') - - def test_get_stream_title_after_stop(self): - self.core.playback.play() - self.audio.trigger_fake_tags_changed({'organization': ['baz']}) - self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() - self.core.playback.stop() - - self.replay_audio_events() - self.assertEqual(self.playback.get_stream_title(), None) - - -class CorePlaybackWithOldBackendTest(unittest.TestCase): +class TestCorePlaybackWithOldBackend(unittest.TestCase): def test_type_error_from_old_backend_does_not_crash_core(self): config = { @@ -791,32 +1071,7 @@ class CorePlaybackWithOldBackendTest(unittest.TestCase): b.playback.play.assert_called_once_with() -class TestPlay(unittest.TestCase): - - def setUp(self): # noqa: N802 - config = { - 'core': { - 'max_tracklist_length': 10000, - } - } - - self.backend = mock.Mock() - self.backend.uri_schemes.get.return_value = ['dummy'] - self.core = core.Core(config, backends=[self.backend]) - - self.tracks = [Track(uri='dummy:a', length=1234), - Track(uri='dummy:b', length=1234)] - - with deprecation.ignore('core.tracklist.add:tracks_arg'): - self.tl_tracks = self.core.tracklist.add(tracks=self.tracks) - - def test_play_tlid(self): - self.core.playback.play(tlid=self.tl_tracks[1].tlid) - self.backend.playback.change_track.assert_called_once_with( - self.tl_tracks[1].track) - - -class Bug1177RegressionTest(unittest.TestCase): +class TestBug1177Regression(unittest.TestCase): def test(self): config = { 'core': { @@ -845,100 +1100,28 @@ class Bug1177RegressionTest(unittest.TestCase): b.playback.change_track.assert_called_once_with(track2) -class Bug1352RegressionTest(unittest.TestCase): - def test(self): - config = { - 'core': { - 'max_tracklist_length': 10000, - } - } - - b = mock.Mock() - b.uri_schemes.get.return_value = ['dummy'] - b.playback = mock.Mock(spec=backend.PlaybackProvider) - b.playback.change_track.return_value.get.return_value = True - b.playback.play.return_value.get.return_value = True - - track1 = Track(uri='dummy:a', length=40000) - track2 = Track(uri='dummy:b', length=40000) - - tl_track2 = TlTrack(1, track2) - - c = core.Core(config, mixer=None, backends=[b]) - c.tracklist.add([track1, track2]) - - c.history._add_track = mock.PropertyMock() - c.tracklist._mark_playing = mock.PropertyMock() - - c.playback.play() - b.playback.change_track.reset_mock() - c.history._add_track.reset_mock() - c.tracklist._mark_playing.reset_mock() - - c.playback.pause() - c.playback.next() - b.playback.change_track.assert_called_once_with(track2) - c.history._add_track.assert_called_once_with(track2) - c.tracklist._mark_playing.assert_called_once_with(tl_track2) - - -class Bug1358RegressionTest(unittest.TestCase): - - def setUp(self): # noqa: N802 - config = { - 'core': { - 'max_tracklist_length': 10000, - } - } - - self.backend1 = mock.Mock() - self.backend1.uri_schemes.get.return_value = ['dummy1'] - self.playback1 = mock.Mock(spec=backend.PlaybackProvider) - self.backend1.playback.change_track.return_value.get.return_value = ( - False) - self.backend1.playback = self.playback1 - - self.backend2 = mock.Mock() - self.backend2.uri_schemes.get.return_value = ['dummy2'] - self.playback2 = mock.Mock(spec=backend.PlaybackProvider) - self.backend1.playback.change_track.return_value.get.return_value = ( - False) - self.backend2.playback = self.playback2 - - self.tracks = [ - Track(uri='dummy1:a', length=40000), - Track(uri='dummy2:a', length=40000), - ] - - self.uris = [t.uri for t in self.tracks] - - self.core = core.Core( - config, mixer=None, backends=[self.backend1, self.backend2]) - - 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.lookup_patcher = mock.patch.object(self.core.library, 'lookup') - self.lookup_mock = self.lookup_patcher.start() - self.lookup_mock.side_effect = lookup - - self.core.tracklist.add(uris=self.uris) - - self.tl_tracks = self.core.tracklist.get_tl_tracks() - - def tearDown(self): # noqa: N802 - self.lookup_patcher.stop() - - def test_next_in_consume_mode_removes_unplayable_track(self): - self.core.tracklist.set_consume(True) - - self.core.playback.play(self.tl_tracks[0]) - self.core.playback.next() +class TestBug1352Regression(BaseTest): + tracks = [ + Track(uri='dummy:a', length=40000), + Track(uri='dummy:b', length=40000), + ] + def test_next_when_paused_updates_history(self): + self.core.history._add_track = mock.Mock() + self.core.tracklist._mark_playing = mock.Mock() tl_tracks = self.core.tracklist.get_tl_tracks() - self.assertNotIn(self.tl_tracks[0], tl_tracks) - self.assertNotIn(self.tl_tracks[1], tl_tracks) + + self.playback.play() + self.replay_events() + + self.core.history._add_track.assert_called_once_with(self.tracks[0]) + self.core.tracklist._mark_playing.assert_called_once_with(tl_tracks[0]) + self.core.history._add_track.reset_mock() + self.core.tracklist._mark_playing.reset_mock() + + self.playback.pause() + self.playback.next() + self.replay_events() + + self.core.history._add_track.assert_called_once_with(self.tracks[1]) + self.core.tracklist._mark_playing.assert_called_once_with(tl_tracks[1]) diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index 029254a8..c908af6a 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -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): diff --git a/tests/data/comment-ext.m3u b/tests/data/comment-ext.m3u index 95983d06..a9b675b8 100644 --- a/tests/data/comment-ext.m3u +++ b/tests/data/comment-ext.m3u @@ -1,5 +1,5 @@ #EXTM3U # test -#EXTINF:-1,song1 +#EXTINF:-1,Song #1 # test song1.mp3 diff --git a/tests/data/one-ext.m3u b/tests/data/one-ext.m3u index 7e94d5e9..a8a51c2f 100644 --- a/tests/data/one-ext.m3u +++ b/tests/data/one-ext.m3u @@ -1,3 +1,3 @@ #EXTM3U -#EXTINF:-1,song1 +#EXTINF:-1,Song #1 song1.mp3 diff --git a/tests/data/two-ext.m3u b/tests/data/two-ext.m3u index c2bf3e75..f50feb94 100644 --- a/tests/data/two-ext.m3u +++ b/tests/data/two-ext.m3u @@ -1,5 +1,5 @@ #EXTM3U -#EXTINF:-1,song1 +#EXTINF:-1,Song #1 song1.mp3 -#EXTINF:60,song2 +#EXTINF:60,Song #2 song2.mp3 diff --git a/tests/dummy_audio.py b/tests/dummy_audio.py index 7c48d9f0..fdd57d7e 100644 --- a/tests/dummy_audio.py +++ b/tests/dummy_audio.py @@ -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) diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index 9ce8e38f..465aeab6 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -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'] diff --git a/tests/internal/network/test_connection.py b/tests/internal/network/test_connection.py index 8ae7d15c..9ee0aaf3 100644 --- a/tests/internal/network/test_connection.py +++ b/tests/internal/network/test_connection.py @@ -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) diff --git a/tests/internal/network/test_server.py b/tests/internal/network/test_server.py index af8effd2..072e24de 100644 --- a/tests/internal/network/test_server.py +++ b/tests/internal/network/test_server.py @@ -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( diff --git a/tests/internal/test_deps.py b/tests/internal/test_deps.py index 27e6f629..84c79d9c 100644 --- a/tests/internal/test_deps.py +++ b/tests/internal/test_deps.py @@ -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') diff --git a/tests/internal/test_path.py b/tests/internal/test_path.py index 0d266725..9e09c39a 100644 --- a/tests/internal/test_path.py +++ b/tests/internal/test_path.py @@ -7,10 +7,9 @@ import shutil import tempfile import unittest -import glib - from mopidy import compat, exceptions from mopidy.internal import path +from mopidy.internal.gi import GLib import tests @@ -133,7 +132,7 @@ class GetOrCreateFileTest(unittest.TestCase): file_path = os.path.join(self.parent, b'test') created = path.get_or_create_file(file_path, content='foobaræøå') with open(created) as fh: - self.assertEqual(fh.read(), b'foobaræøå') + self.assertEqual(fh.read(), b'foobar\xc3\xa6\xc3\xb8\xc3\xa5') class PathToFileURITest(unittest.TestCase): @@ -215,7 +214,7 @@ class ExpandPathTest(unittest.TestCase): def test_xdg_subsititution(self): self.assertEqual( - glib.get_user_data_dir() + b'/foo', + GLib.get_user_data_dir() + b'/foo', path.expand_path(b'$XDG_DATA_DIR/foo')) def test_xdg_subsititution_unknown(self): diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index b99f8508..f0dcf20b 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -42,8 +42,12 @@ class LocalPlaybackProviderTest(unittest.TestCase): track = Track(uri=uri, length=4464) self.tracklist.add([track]) - def trigger_end_of_track(self): - self.playback._on_end_of_track() + def trigger_about_to_finish(self): + # Flush any queued core calls. + self.playback.get_current_tl_track().get() + + callback = self.audio.get_about_to_finish_callback().get() + callback() def run(self, result=None): with deprecation.ignore('core.tracklist.add:tracks_arg'): @@ -53,7 +57,9 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() - self.core = core.Core(self.config, backends=[self.backend]) + self.core = core.Core.start(audio=self.audio, + backends=[self.backend], + config=self.config).proxy() self.playback = self.core.playback self.tracklist = self.core.tracklist @@ -65,285 +71,308 @@ class LocalPlaybackProviderTest(unittest.TestCase): def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() + def assert_state_is(self, state): + self.assertEqual(self.playback.get_state().get(), state) + + def assert_current_track_is(self, track): + self.assertEqual(self.playback.get_current_track().get(), track) + + def assert_current_track_is_not(self, track): + self.assertNotEqual(self.playback.get_current_track().get(), track) + + def assert_current_track_index_is(self, index): + tl_track = self.playback.get_current_tl_track().get() + self.assertEqual(self.tracklist.index(tl_track).get(), index) + + def assert_next_tl_track_is(self, tl_track): + current = self.playback.get_current_tl_track().get() + self.assertEqual(self.tracklist.next_track(current).get(), tl_track) + + def assert_next_tl_track_is_not(self, tl_track): + current = self.playback.get_current_tl_track().get() + self.assertNotEqual(self.tracklist.next_track(current).get(), tl_track) + + def assert_previous_tl_track_is(self, tl_track): + current = self.playback.get_current_tl_track().get() + previous = self.tracklist.previous_track(current).get() + self.assertEqual(previous, tl_track) + + def assert_eot_tl_track_is(self, tl_track): + current = self.playback.get_current_tl_track().get() + self.assertEqual(self.tracklist.eot_track(current).get(), tl_track) + + def assert_eot_tl_track_is_not(self, tl_track): + current = self.playback.get_current_tl_track().get() + self.assertNotEqual(self.tracklist.eot_track(current).get(), tl_track) + def test_uri_scheme(self): - self.assertNotIn('file', self.core.uri_schemes) - self.assertIn('local', self.core.uri_schemes) + self.assertNotIn('file', self.core.uri_schemes.get()) + self.assertIn('local', self.core.uri_schemes.get()) def test_play_mp3(self): self.add_track('local:track:blank.mp3') - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.playback.play().get() + self.assert_state_is(PlaybackState.PLAYING) def test_play_ogg(self): self.add_track('local:track:blank.ogg') - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.playback.play().get() + self.assert_state_is(PlaybackState.PLAYING) def test_play_flac(self): self.add_track('local:track:blank.flac') - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.playback.play().get() + self.assert_state_is(PlaybackState.PLAYING) def test_play_uri_with_non_ascii_bytes(self): # Regression test: If trying to do .split(u':') on a bytestring, the # string will be decoded from ASCII to Unicode, which will crash on # non-ASCII strings, like the bytestring the following URI decodes to. self.add_track('local:track:12%20Doin%E2%80%99%20It%20Right.flac') - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.playback.play().get() + self.assert_state_is(PlaybackState.PLAYING) def test_initial_state_is_stopped(self): - self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assert_state_is(PlaybackState.STOPPED) def test_play_with_empty_playlist(self): - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assert_state_is(PlaybackState.STOPPED) + self.playback.play().get() + self.assert_state_is(PlaybackState.STOPPED) def test_play_with_empty_playlist_return_value(self): - self.assertEqual(self.playback.play(), None) + self.assertEqual(self.playback.play().get(), None) @populate_tracklist def test_play_state(self): - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.assert_state_is(PlaybackState.STOPPED) + self.playback.play().get() + self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_play_return_value(self): - self.assertEqual(self.playback.play(), None) + self.assertEqual(self.playback.play().get(), None) @populate_tracklist def test_play_track_state(self): - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.playback.play(self.tracklist.tl_tracks[-1]) - self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.assert_state_is(PlaybackState.STOPPED) + self.playback.play(self.tl_tracks.get()[-1]).get() + self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_play_track_return_value(self): - self.assertEqual(self.playback.play( - self.tracklist.tl_tracks[-1]), None) + self.assertIsNone(self.playback.play(self.tl_tracks.get()[-1]).get()) @populate_tracklist def test_play_when_playing(self): - self.playback.play() - track = self.playback.current_track - self.playback.play() - self.assertEqual(track, self.playback.current_track) + self.playback.play().get() + track = self.playback.get_current_track().get() + self.playback.play().get() + self.assert_current_track_is(track) @populate_tracklist def test_play_when_paused(self): - self.playback.play() - track = self.playback.current_track - self.playback.pause() - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(track, self.playback.current_track) + self.playback.play().get() + track = self.playback.get_current_track().get() + self.playback.pause().get() + self.playback.play().get() + self.assert_state_is(PlaybackState.PLAYING) + self.assert_current_track_is(track) @populate_tracklist - def test_play_when_pause_after_next(self): - self.playback.play() - self.playback.next() - self.playback.next() - track = self.playback.current_track - self.playback.pause() - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(track, self.playback.current_track) + def test_play_when_paused_after_next(self): + self.playback.play().get() + self.playback.next().get() + self.playback.next().get() + track = self.playback.get_current_track().get() + self.playback.pause().get() + self.playback.play().get() + self.assert_state_is(PlaybackState.PLAYING) + self.assert_current_track_is(track) @populate_tracklist def test_play_sets_current_track(self): - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[0]) + self.playback.play().get() + self.assert_current_track_is(self.tracks[0]) @populate_tracklist def test_play_track_sets_current_track(self): - self.playback.play(self.tracklist.tl_tracks[-1]) - self.assertEqual(self.playback.current_track, self.tracks[-1]) + self.playback.play(self.tl_tracks.get()[-1]).get() + self.assert_current_track_is(self.tracks[-1]) @populate_tracklist def test_play_skips_to_next_track_on_failure(self): # If backend's play() returns False, it is a failure. - return_values = [True, False] - self.backend.playback.play = lambda: return_values.pop() - self.playback.play() - self.assertNotEqual(self.playback.current_track, self.tracks[0]) - self.assertEqual(self.playback.current_track, self.tracks[1]) + uri = self.backend.playback.translate_uri(self.tracks[0].uri).get() + self.audio.trigger_fake_playback_failure(uri) + + self.playback.play().get() + self.assert_current_track_is_not(self.tracks[0]) + self.assert_current_track_is(self.tracks[1]) @populate_tracklist def test_current_track_after_completed_playlist(self): - self.playback.play(self.tracklist.tl_tracks[-1]) - self.trigger_end_of_track() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.assertEqual(self.playback.current_track, None) + self.playback.play(self.tl_tracks.get()[-1]).get() + self.trigger_about_to_finish() + # EOS should have triggered + self.assert_state_is(PlaybackState.STOPPED) + self.assert_current_track_is(None) - self.playback.play(self.tracklist.tl_tracks[-1]) - self.playback.next() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.assertEqual(self.playback.current_track, None) + self.playback.play(self.tl_tracks.get()[-1]).get() + self.playback.next().get() + self.assert_state_is(PlaybackState.STOPPED) + self.assert_current_track_is(None) @populate_tracklist def test_previous(self): - self.playback.play() - self.playback.next() - self.playback.previous() - self.assertEqual(self.playback.current_track, self.tracks[0]) + self.playback.play().get() + self.playback.next().get() + self.playback.previous().get() + self.assert_current_track_is(self.tracks[0]) @populate_tracklist def test_previous_more(self): - self.playback.play() # At track 0 - self.playback.next() # At track 1 - self.playback.next() # At track 2 - self.playback.previous() # At track 1 - self.assertEqual(self.playback.current_track, self.tracks[1]) + self.playback.play().get() # At track 0 + self.playback.next().get() # At track 1 + self.playback.next().get() # At track 2 + self.playback.previous().get() # At track 1 + self.assert_current_track_is(self.tracks[1]) @populate_tracklist def test_previous_return_value(self): - self.playback.play() - self.playback.next() - self.assertEqual(self.playback.previous(), None) + self.playback.play().get() + self.playback.next().get() + self.assertIsNone(self.playback.previous().get()) @populate_tracklist def test_previous_does_not_trigger_playback(self): - self.playback.play() - self.playback.next() + self.playback.play().get() + self.playback.next().get() self.playback.stop() - self.playback.previous() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.playback.previous().get() + self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_previous_at_start_of_playlist(self): - self.playback.previous() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.assertEqual(self.playback.current_track, None) + self.playback.previous().get() + self.assert_state_is(PlaybackState.STOPPED) + self.assert_current_track_is(None) def test_previous_for_empty_playlist(self): - self.playback.previous() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.assertEqual(self.playback.current_track, None) + self.playback.previous().get() + self.assert_state_is(PlaybackState.STOPPED) + self.assert_current_track_is(None) @populate_tracklist def test_previous_skips_to_previous_track_on_failure(self): # If backend's play() returns False, it is a failure. - return_values = [True, False, True] - self.backend.playback.play = lambda: return_values.pop() - self.playback.play(self.tracklist.tl_tracks[2]) - self.assertEqual(self.playback.current_track, self.tracks[2]) - self.playback.previous() - self.assertNotEqual(self.playback.current_track, self.tracks[1]) - self.assertEqual(self.playback.current_track, self.tracks[0]) + uri = self.backend.playback.translate_uri(self.tracks[1].uri).get() + self.audio.trigger_fake_playback_failure(uri) + + self.playback.play(self.tl_tracks.get()[2]).get() + self.assert_current_track_is(self.tracks[2]) + self.playback.previous().get() + self.assert_current_track_is_not(self.tracks[1]) + self.assert_current_track_is(self.tracks[0]) @populate_tracklist def test_next(self): - self.playback.play() + self.playback.play().get() - tl_track = self.playback.current_tl_track - old_position = self.tracklist.index(tl_track) - old_uri = tl_track.track.uri + old_track = self.playback.get_current_track().get() + old_position = self.tracklist.index().get() - self.playback.next() + self.playback.next().get() - tl_track = self.playback.current_tl_track - self.assertEqual( - self.tracklist.index(tl_track), old_position + 1) - self.assertNotEqual(self.playback.current_track.uri, old_uri) + self.assertEqual(self.tracklist.index().get(), old_position + 1) + self.assert_current_track_is_not(old_track) @populate_tracklist def test_next_return_value(self): - self.playback.play() - self.assertEqual(self.playback.next(), None) + self.playback.play().get() + self.assertEqual(self.playback.next().get(), None) @populate_tracklist def test_next_does_not_trigger_playback(self): - self.playback.next() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.playback.next().get() + self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_next_at_end_of_playlist(self): - self.playback.play() + self.playback.play().get() for i, track in enumerate(self.tracks): - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(self.playback.current_track, track) - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.index(tl_track), i) + self.assert_state_is(PlaybackState.PLAYING) + self.assert_current_track_is(track) + self.assertEqual(self.tracklist.index().get(), i) self.playback.next() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_next_until_end_of_playlist_and_play_from_start(self): - self.playback.play() + self.playback.play().get() for _ in self.tracks: - self.playback.next() + self.playback.next().get() - self.assertEqual(self.playback.current_track, None) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assert_current_track_is(None) + self.assert_state_is(PlaybackState.STOPPED) - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(self.playback.current_track, self.tracks[0]) + self.playback.play().get() + self.assert_state_is(PlaybackState.PLAYING) + self.assert_current_track_is(self.tracks[0]) def test_next_for_empty_playlist(self): - self.playback.next() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.playback.next().get() + self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_next_skips_to_next_track_on_failure(self): # If backend's play() returns False, it is a failure. - return_values = [True, False, True] - self.backend.playback.play = lambda: return_values.pop() - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[0]) - self.playback.next() - self.assertNotEqual(self.playback.current_track, self.tracks[1]) - self.assertEqual(self.playback.current_track, self.tracks[2]) + uri = self.backend.playback.translate_uri(self.tracks[1].uri).get() + self.audio.trigger_fake_playback_failure(uri) + + self.playback.play().get() + self.assert_current_track_is(self.tracks[0]) + self.playback.next().get() + self.assert_current_track_is_not(self.tracks[1]) + self.assert_current_track_is(self.tracks[2]) @populate_tracklist def test_next_track_before_play(self): - tl_track = self.playback.current_tl_track - self.assertEqual( - self.tracklist.next_track(tl_track), self.tl_tracks[0]) + self.assert_next_tl_track_is(self.tl_tracks.get()[0]) @populate_tracklist def test_next_track_during_play(self): - self.playback.play() - tl_track = self.playback.current_tl_track - self.assertEqual( - self.tracklist.next_track(tl_track), self.tl_tracks[1]) + self.playback.play().get() + self.assert_next_tl_track_is(self.tl_tracks.get()[1]) @populate_tracklist def test_next_track_after_previous(self): - self.playback.play() - self.playback.next() - self.playback.previous() - tl_track = self.playback.current_tl_track - self.assertEqual( - self.tracklist.next_track(tl_track), self.tl_tracks[1]) + self.playback.play().get() + self.playback.next().get() + self.playback.previous().get() + self.assert_next_tl_track_is(self.tl_tracks.get()[1]) def test_next_track_empty_playlist(self): - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), None) + self.assert_next_tl_track_is(None) @populate_tracklist def test_next_track_at_end_of_playlist(self): - self.playback.play() - for _ in self.tracklist.tl_tracks[1:]: - self.playback.next() - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), None) + self.playback.play().get() + for _ in self.tl_tracks.get()[1:]: + self.playback.next().get() + self.assert_next_tl_track_is(None) @populate_tracklist def test_next_track_at_end_of_playlist_with_repeat(self): self.tracklist.repeat = True - self.playback.play() + self.playback.play().get() for _ in self.tracks[1:]: - self.playback.next() - tl_track = self.playback.current_tl_track - self.assertEqual( - self.tracklist.next_track(tl_track), self.tl_tracks[0]) + self.playback.next().get() + self.assert_next_tl_track_is(self.tl_tracks.get()[0]) @populate_tracklist @mock.patch('random.shuffle') @@ -351,25 +380,23 @@ class LocalPlaybackProviderTest(unittest.TestCase): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.random = True - current_tl_track = self.playback.current_tl_track - next_tl_track = self.tracklist.next_track(current_tl_track) - self.assertEqual(next_tl_track, self.tl_tracks[-1]) + self.assert_next_tl_track_is(self.tl_tracks.get()[-1]) @populate_tracklist def test_next_with_consume(self): self.tracklist.consume = True - self.playback.play() - self.playback.next() - self.assertNotIn(self.tracks[0], self.tracklist.tracks) + self.playback.play().get() + self.playback.next().get() + self.assertNotIn(self.tracks[0], self.tracklist.get_tracks().get()) @populate_tracklist def test_next_with_single_and_repeat(self): self.tracklist.single = True self.tracklist.repeat = True - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[0]) - self.playback.next() - self.assertEqual(self.playback.current_track, self.tracks[1]) + self.playback.play().get() + self.assert_current_track_is(self.tracks[0]) + self.playback.next().get() + self.assert_current_track_is(self.tracks[1]) @populate_tracklist @mock.patch('random.shuffle') @@ -377,10 +404,10 @@ class LocalPlaybackProviderTest(unittest.TestCase): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.random = True - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[-1]) - self.playback.next() - self.assertEqual(self.playback.current_track, self.tracks[-2]) + self.playback.play().get() + self.assert_current_track_is(self.tracks[-1]) + self.playback.next().get() + self.assert_current_track_is(self.tracks[-2]) @populate_tracklist @mock.patch('random.shuffle') @@ -388,10 +415,10 @@ class LocalPlaybackProviderTest(unittest.TestCase): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.random = True - current_tl_track = self.playback.current_tl_track + current_tl_track = self.playback.get_current_tl_track().get() - expected_tl_track = self.tracklist.tl_tracks[-1] - next_tl_track = self.tracklist.next_track(current_tl_track) + expected_tl_track = self.tl_tracks.get()[-1] + next_tl_track = self.tracklist.next_track(current_tl_track).get() # Baseline checking that first next_track is last tl track per our fake # shuffle. @@ -400,8 +427,8 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.add(self.tracks[:1]) old_next_tl_track = next_tl_track - expected_tl_track = self.tracklist.tl_tracks[-1] - next_tl_track = self.tracklist.next_track(current_tl_track) + expected_tl_track = self.tracklist.tl_tracks.get()[-1] + next_tl_track = self.tracklist.next_track(current_tl_track).get() # Verify that first next track has changed since we added to the # playlist. @@ -410,115 +437,106 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_end_of_track(self): - self.playback.play() + self.playback.play().get() - tl_track = self.playback.current_tl_track - old_position = self.tracklist.index(tl_track) - old_uri = tl_track.track.uri + old_track = self.playback.get_current_track().get() + old_position = self.tracklist.index().get() - self.trigger_end_of_track() + self.trigger_about_to_finish() - tl_track = self.playback.current_tl_track - self.assertEqual( - self.tracklist.index(tl_track), old_position + 1) - self.assertNotEqual(self.playback.current_track.uri, old_uri) + new_track = self.playback.get_current_track().get() + self.assertEqual(self.tracklist.index().get(), old_position + 1) + self.assertNotEqual(new_track.uri, old_track.uri) @populate_tracklist def test_end_of_track_return_value(self): - self.playback.play() - self.assertEqual(self.trigger_end_of_track(), None) + self.playback.play().get() + self.assertEqual(self.trigger_about_to_finish(), None) @populate_tracklist def test_end_of_track_does_not_trigger_playback(self): - self.trigger_end_of_track() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.trigger_about_to_finish() + self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_end_of_track_at_end_of_playlist(self): - self.playback.play() + self.playback.play().get() for i, track in enumerate(self.tracks): - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(self.playback.current_track, track) - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.index(tl_track), i) + self.assert_state_is(PlaybackState.PLAYING) + self.assert_current_track_is(track) + self.assertEqual(self.tracklist.index().get(), i) - self.trigger_end_of_track() + self.trigger_about_to_finish() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_end_of_track_until_end_of_playlist_and_play_from_start(self): - self.playback.play() + self.playback.play().get() for _ in self.tracks: - self.trigger_end_of_track() + self.trigger_about_to_finish() - self.assertEqual(self.playback.current_track, None) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assertEqual(self.playback.get_current_track().get(), None) + self.assert_state_is(PlaybackState.STOPPED) - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(self.playback.current_track, self.tracks[0]) + self.playback.play().get() + self.assert_state_is(PlaybackState.PLAYING) + self.assert_current_track_is(self.tracks[0]) def test_end_of_track_for_empty_playlist(self): - self.trigger_end_of_track() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.trigger_about_to_finish() + self.assert_state_is(PlaybackState.STOPPED) + # TODO: On about to finish does not handle skipping to next track yet. + @unittest.expectedFailure @populate_tracklist def test_end_of_track_skips_to_next_track_on_failure(self): # If backend's play() returns False, it is a failure. return_values = [True, False, True] self.backend.playback.play = lambda: return_values.pop() - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[0]) - self.trigger_end_of_track() - self.assertNotEqual(self.playback.current_track, self.tracks[1]) - self.assertEqual(self.playback.current_track, self.tracks[2]) + self.playback.play().get() + self.assert_current_track_is(self.tracks[0]) + self.trigger_about_to_finish() + self.assert_current_track_is_not(self.tracks[1]) + self.assert_current_track_is(self.tracks[2]) @populate_tracklist def test_end_of_track_track_before_play(self): - tl_track = self.playback.current_tl_track - self.assertEqual( - self.tracklist.next_track(tl_track), self.tl_tracks[0]) + self.assert_next_tl_track_is(self.tl_tracks.get()[0]) @populate_tracklist def test_end_of_track_track_during_play(self): - self.playback.play() - tl_track = self.playback.current_tl_track - self.assertEqual( - self.tracklist.next_track(tl_track), self.tl_tracks[1]) + self.playback.play().get() + self.assert_next_tl_track_is(self.tl_tracks.get()[1]) @populate_tracklist - def test_end_of_track_track_after_previous(self): - self.playback.play() - self.trigger_end_of_track() - self.playback.previous() - tl_track = self.playback.current_tl_track - self.assertEqual( - self.tracklist.next_track(tl_track), self.tl_tracks[1]) + def test_about_to_finish_after_previous(self): + self.playback.play().get() + self.trigger_about_to_finish() + self.playback.previous().get() + self.assert_next_tl_track_is(self.tl_tracks.get()[1]) def test_end_of_track_track_empty_playlist(self): - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), None) + self.assert_next_tl_track_is(None) @populate_tracklist def test_end_of_track_track_at_end_of_playlist(self): - self.playback.play() - for _ in self.tracklist.tl_tracks[1:]: - self.trigger_end_of_track() - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), None) + self.playback.play().get() + for _ in self.tracks[1:]: + self.trigger_about_to_finish() + + self.assert_next_tl_track_is(None) @populate_tracklist def test_end_of_track_track_at_end_of_playlist_with_repeat(self): self.tracklist.repeat = True - self.playback.play() + self.playback.play().get() for _ in self.tracks[1:]: - self.trigger_end_of_track() - tl_track = self.playback.current_tl_track - self.assertEqual( - self.tracklist.next_track(tl_track), self.tl_tracks[0]) + self.trigger_about_to_finish() + + self.assert_next_tl_track_is(self.tl_tracks.get()[0]) @populate_tracklist @mock.patch('random.shuffle') @@ -526,16 +544,14 @@ class LocalPlaybackProviderTest(unittest.TestCase): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.random = True - tl_track = self.playback.current_tl_track - self.assertEqual( - self.tracklist.next_track(tl_track), self.tl_tracks[-1]) + self.assert_next_tl_track_is(self.tl_tracks.get()[-1]) @populate_tracklist def test_end_of_track_with_consume(self): self.tracklist.consume = True - self.playback.play() - self.trigger_end_of_track() - self.assertNotIn(self.tracks[0], self.tracklist.tracks) + self.playback.play().get() + self.trigger_about_to_finish() + self.assertNotIn(self.tracks[0], self.tracklist.get_tracks().get()) @populate_tracklist @mock.patch('random.shuffle') @@ -543,10 +559,10 @@ class LocalPlaybackProviderTest(unittest.TestCase): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.random = True - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[-1]) - self.trigger_end_of_track() - self.assertEqual(self.playback.current_track, self.tracks[-2]) + self.playback.play().get() + self.assert_current_track_is(self.tracks[-1]) + self.trigger_about_to_finish() + self.assert_current_track_is(self.tracks[-2]) @populate_tracklist @mock.patch('random.shuffle') @@ -555,10 +571,10 @@ class LocalPlaybackProviderTest(unittest.TestCase): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.random = True - current_tl_track = self.playback.current_tl_track + current_tl_track = self.playback.get_current_tl_track().get() - expected_tl_track = self.tracklist.tl_tracks[-1] - eot_tl_track = self.tracklist.eot_track(current_tl_track) + expected_tl_track = self.tracklist.get_tl_tracks().get()[-1] + eot_tl_track = self.tracklist.eot_track(current_tl_track).get() # Baseline checking that first eot_track is last tl track per our fake # shuffle. @@ -567,8 +583,8 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.add(self.tracks[:1]) old_eot_tl_track = eot_tl_track - expected_tl_track = self.tracklist.tl_tracks[-1] - eot_tl_track = self.tracklist.eot_track(current_tl_track) + expected_tl_track = self.tracklist.get_tl_tracks().get()[-1] + eot_tl_track = self.tracklist.eot_track(current_tl_track).get() # Verify that first next track has changed since we added to the # playlist. @@ -577,193 +593,162 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_previous_track_before_play(self): - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.previous_track(tl_track), None) + self.assert_previous_tl_track_is(None) @populate_tracklist def test_previous_track_after_play(self): - self.playback.play() - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.previous_track(tl_track), None) + self.playback.play().get() + self.assert_previous_tl_track_is(None) @populate_tracklist def test_previous_track_after_next(self): - self.playback.play() - self.playback.next() - tl_track = self.playback.current_tl_track - self.assertEqual( - self.tracklist.previous_track(tl_track), self.tl_tracks[0]) + self.playback.play().get() + self.playback.next().get() + self.assert_previous_tl_track_is(self.tl_tracks.get()[0]) @populate_tracklist def test_previous_track_after_previous(self): - self.playback.play() # At track 0 - self.playback.next() # At track 1 - self.playback.next() # At track 2 - self.playback.previous() # At track 1 - tl_track = self.playback.current_tl_track - self.assertEqual( - self.tracklist.previous_track(tl_track), self.tl_tracks[0]) + self.playback.play().get() # At track 0 + self.playback.next().get() # At track 1 + self.playback.next().get() # At track 2 + self.playback.previous().get() # At track 1 + self.assert_previous_tl_track_is(self.tl_tracks.get()[0]) def test_previous_track_empty_playlist(self): - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.previous_track(tl_track), None) + self.assert_previous_tl_track_is(None) @populate_tracklist def test_previous_track_with_consume(self): self.tracklist.consume = True for _ in self.tracks: self.playback.next() - tl_track = self.playback.current_tl_track - self.assertEqual( - self.tracklist.previous_track(tl_track), - self.playback.current_tl_track) + current = self.playback.get_current_tl_track().get() + self.assert_previous_tl_track_is(current) @populate_tracklist def test_previous_track_with_random(self): self.tracklist.random = True for _ in self.tracks: self.playback.next() - tl_track = self.playback.current_tl_track - self.assertEqual( - self.tracklist.previous_track(tl_track), - self.playback.current_tl_track) + current = self.playback.get_current_tl_track().get() + self.assert_previous_tl_track_is(current) @populate_tracklist def test_initial_current_track(self): - self.assertEqual(self.playback.current_track, None) + self.assert_current_track_is(None) @populate_tracklist def test_current_track_during_play(self): - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[0]) + self.playback.play().get() + self.assert_current_track_is(self.tracks[0]) @populate_tracklist def test_current_track_after_next(self): self.playback.play() - self.playback.next() - self.assertEqual(self.playback.current_track, self.tracks[1]) + self.playback.next().get() + self.assert_current_track_is(self.tracks[1]) @populate_tracklist def test_initial_tracklist_position(self): - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.index(tl_track), None) + self.assertEqual(self.tracklist.index().get(), None) @populate_tracklist def test_tracklist_position_during_play(self): - self.playback.play() - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.index(tl_track), 0) + self.playback.play().get() + self.assert_current_track_index_is(0) @populate_tracklist def test_tracklist_position_after_next(self): - self.playback.play() - self.playback.next() - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.index(tl_track), 1) + self.playback.play().get() + self.playback.next().get() + self.assert_current_track_index_is(1) @populate_tracklist def test_tracklist_position_at_end_of_playlist(self): - self.playback.play(self.tracklist.tl_tracks[-1]) - self.trigger_end_of_track() - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.index(tl_track), None) + self.playback.play(self.tl_tracks.get()[-1]).get() + self.trigger_about_to_finish() + # EOS should have triggered + self.assert_current_track_index_is(None) - def test_on_tracklist_change_gets_called(self): - callback = self.playback._on_tracklist_change - - def wrapper(): - wrapper.called = True - return callback() - wrapper.called = False - - self.playback._on_tracklist_change = wrapper - self.tracklist.add([Track()]) - - self.assert_(wrapper.called) - - @unittest.SkipTest # Blocks for 10ms - @populate_tracklist - def test_end_of_track_callback_gets_called(self): - self.playback.play() - result = self.playback.seek(self.tracks[0].length - 10) - self.assertTrue(result, 'Seek failed') - message = self.core_queue.get(True, 1) - self.assertEqual('end_of_track', message['command']) + @mock.patch('mopidy.core.playback.PlaybackController._on_tracklist_change') + def test_on_tracklist_change_gets_called(self, change_mock): + self.tracklist.add([Track()]).get() + change_mock.assert_called_once_with() @populate_tracklist def test_on_tracklist_change_when_playing(self): - self.playback.play() - current_track = self.playback.current_track + self.playback.play().get() + current_track = self.playback.get_current_track().get() self.tracklist.add([self.tracks[2]]) - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(self.playback.current_track, current_track) + self.assert_state_is(PlaybackState.PLAYING) + self.assert_current_track_is(current_track) @populate_tracklist def test_on_tracklist_change_when_stopped(self): self.tracklist.add([self.tracks[2]]) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.assertEqual(self.playback.current_track, None) + self.assert_state_is(PlaybackState.STOPPED) + self.assert_current_track_is(None) @populate_tracklist def test_on_tracklist_change_when_paused(self): - self.playback.play() + self.playback.play().get() self.playback.pause() - current_track = self.playback.current_track + current_track = self.playback.get_current_track().get() self.tracklist.add([self.tracks[2]]) - self.assertEqual(self.playback.state, PlaybackState.PAUSED) - self.assertEqual(self.playback.current_track, current_track) + self.assert_state_is(PlaybackState.PAUSED) + self.assert_current_track_is(current_track) @populate_tracklist def test_pause_when_stopped(self): self.playback.pause() - self.assertEqual(self.playback.state, PlaybackState.PAUSED) + self.assert_state_is(PlaybackState.PAUSED) @populate_tracklist def test_pause_when_playing(self): - self.playback.play() + self.playback.play().get() self.playback.pause() - self.assertEqual(self.playback.state, PlaybackState.PAUSED) + self.assert_state_is(PlaybackState.PAUSED) @populate_tracklist def test_pause_when_paused(self): - self.playback.play() + self.playback.play().get() self.playback.pause() self.playback.pause() - self.assertEqual(self.playback.state, PlaybackState.PAUSED) + self.assert_state_is(PlaybackState.PAUSED) @populate_tracklist def test_pause_return_value(self): - self.playback.play() - self.assertEqual(self.playback.pause(), None) + self.playback.play().get() + self.assertIsNone(self.playback.pause().get()) @populate_tracklist def test_resume_when_stopped(self): self.playback.resume() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_resume_when_playing(self): - self.playback.play() + self.playback.play().get() self.playback.resume() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_resume_when_paused(self): - self.playback.play() + self.playback.play().get() self.playback.pause() self.playback.resume() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_resume_return_value(self): - self.playback.play() + self.playback.play().get() self.playback.pause() - self.assertEqual(self.playback.resume(), None) + self.assertIsNone(self.playback.resume().get()) @unittest.SkipTest # Uses sleep and might not work with LocalBackend @populate_tracklist def test_resume_continues_from_right_position(self): - self.playback.play() + self.playback.play().get() time.sleep(0.2) self.playback.pause() self.playback.resume() @@ -776,143 +761,135 @@ class LocalPlaybackProviderTest(unittest.TestCase): @populate_tracklist def test_seek_when_stopped_updates_position(self): - self.playback.seek(1000) + self.playback.seek(1000).get() position = self.playback.time_position self.assertGreaterEqual(position, 990) def test_seek_on_empty_playlist(self): - self.assertFalse(self.playback.seek(0)) + self.assertFalse(self.playback.seek(0).get()) def test_seek_on_empty_playlist_updates_position(self): - self.playback.seek(0) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.playback.seek(0).get() + self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_seek_when_stopped_triggers_play(self): - self.playback.seek(0) - self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.playback.seek(0).get() + self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_seek_when_playing(self): - self.playback.play() + self.playback.play().get() result = self.playback.seek(self.tracks[0].length - 1000) self.assert_(result, 'Seek return value was %s' % result) @populate_tracklist def test_seek_when_playing_updates_position(self): - length = self.tracklist.tracks[0].length - self.playback.play() - self.playback.seek(length - 1000) - position = self.playback.time_position + length = self.tracks[0].length + self.playback.play().get() + self.playback.seek(length - 1000).get() + position = self.playback.get_time_position().get() self.assertGreaterEqual(position, length - 1010) @populate_tracklist def test_seek_when_paused(self): - self.playback.play() + self.playback.play().get() self.playback.pause() result = self.playback.seek(self.tracks[0].length - 1000) self.assert_(result, 'Seek return value was %s' % result) - self.assertEqual(self.playback.state, PlaybackState.PAUSED) + self.assert_state_is(PlaybackState.PAUSED) @populate_tracklist def test_seek_when_paused_updates_position(self): - length = self.tracklist.tracks[0].length - self.playback.play() + length = self.tracks[0].length + self.playback.play().get() self.playback.pause() self.playback.seek(length - 1000) - position = self.playback.time_position + position = self.playback.get_time_position().get() self.assertGreaterEqual(position, length - 1010) @unittest.SkipTest @populate_tracklist def test_seek_beyond_end_of_song(self): # FIXME need to decide return value - self.playback.play() + self.playback.play().get() result = self.playback.seek(self.tracks[0].length * 100) self.assert_(not result, 'Seek return value was %s' % result) @populate_tracklist def test_seek_beyond_end_of_song_jumps_to_next_song(self): - self.playback.play() - self.playback.seek(self.tracks[0].length * 100) - self.assertEqual(self.playback.current_track, self.tracks[1]) + self.playback.play().get() + self.playback.seek(self.tracks[0].length * 100).get() + self.assert_current_track_is(self.tracks[1]) @populate_tracklist def test_seek_beyond_end_of_song_for_last_track(self): - self.playback.play(self.tracklist.tl_tracks[-1]) - self.playback.seek(self.tracklist.tracks[-1].length * 100) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.playback.play(self.tl_tracks.get()[-1]).get() + self.playback.seek(self.tracks[-1].length * 100) + self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_stop_when_stopped(self): self.playback.stop() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_stop_when_playing(self): - self.playback.play() + self.playback.play().get() self.playback.stop() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_stop_when_paused(self): - self.playback.play() + self.playback.play().get() self.playback.pause() self.playback.stop() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assert_state_is(PlaybackState.STOPPED) def test_stop_return_value(self): - self.playback.play() - self.assertEqual(self.playback.stop(), None) + self.playback.play().get() + self.assertIsNone(self.playback.stop().get()) def test_time_position_when_stopped(self): - future = mock.Mock() - future.get = mock.Mock(return_value=0) - self.audio.get_position = mock.Mock(return_value=future) - - self.assertEqual(self.playback.time_position, 0) + self.assertEqual(self.playback.get_time_position().get(), 0) @populate_tracklist def test_time_position_when_stopped_with_playlist(self): - future = mock.Mock() - future.get = mock.Mock(return_value=0) - self.audio.get_position = mock.Mock(return_value=future) - - self.assertEqual(self.playback.time_position, 0) + self.assertEqual(self.playback.get_time_position().get(), 0) @unittest.SkipTest # Uses sleep and does might not work with LocalBackend @populate_tracklist def test_time_position_when_playing(self): - self.playback.play() + self.playback.play().get() first = self.playback.time_position time.sleep(1) second = self.playback.time_position self.assertGreater(second, first) - @unittest.SkipTest # Uses sleep @populate_tracklist def test_time_position_when_paused(self): - self.playback.play() - time.sleep(0.2) - self.playback.pause() - time.sleep(0.2) - first = self.playback.time_position - second = self.playback.time_position + self.playback.play().get() + self.playback.pause().get() + first = self.playback.get_time_position().get() + second = self.playback.get_time_position().get() self.assertEqual(first, second) @populate_tracklist def test_play_with_consume(self): self.tracklist.consume = True - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[0]) + self.playback.play().get() + self.assert_current_track_is(self.tracks[0]) @populate_tracklist def test_playlist_is_empty_after_all_tracks_are_played_with_consume(self): self.tracklist.consume = True - self.playback.play() - for _ in range(len(self.tracklist.tracks)): - self.trigger_end_of_track() - self.assertEqual(len(self.tracklist.tracks), 0) + self.playback.play().get() + + for t in self.tracks: + self.trigger_about_to_finish() + # EOS should have trigger + + self.assertEqual(len(self.tracklist.get_tracks().get()), 0) @populate_tracklist @mock.patch('random.shuffle') @@ -920,8 +897,8 @@ class LocalPlaybackProviderTest(unittest.TestCase): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.random = True - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[-1]) + self.playback.play().get() + self.assert_current_track_is(self.tracks[-1]) @populate_tracklist @mock.patch('random.shuffle') @@ -929,131 +906,133 @@ class LocalPlaybackProviderTest(unittest.TestCase): shuffle_mock.side_effect = lambda tracks: tracks.reverse() self.tracklist.random = True - self.playback.play() - self.playback.next() - current_track = self.playback.current_track + self.playback.play().get() + self.playback.next().get() + current_track = self.playback.get_current_track().get() self.playback.previous() - self.assertEqual(self.playback.current_track, current_track) + self.assert_current_track_is(current_track) @populate_tracklist def test_end_of_song_starts_next_track(self): - self.playback.play() - self.trigger_end_of_track() - self.assertEqual(self.playback.current_track, self.tracks[1]) + self.playback.play().get() + self.trigger_about_to_finish() + self.assert_current_track_is(self.tracks[1]) @populate_tracklist def test_end_of_song_with_single_and_repeat_starts_same(self): self.tracklist.single = True self.tracklist.repeat = True - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[0]) - self.trigger_end_of_track() - self.assertEqual(self.playback.current_track, self.tracks[0]) + self.playback.play().get() + self.assert_current_track_is(self.tracks[0]) + self.trigger_about_to_finish() + self.assert_current_track_is(self.tracks[0]) @populate_tracklist def test_end_of_song_with_single_random_and_repeat_starts_same(self): self.tracklist.single = True self.tracklist.repeat = True self.tracklist.random = True - self.playback.play() - current_track = self.playback.current_track - self.trigger_end_of_track() - self.assertEqual(self.playback.current_track, current_track) + self.playback.play().get() + current_track = self.playback.get_current_track().get() + self.trigger_about_to_finish() + self.assert_current_track_is(current_track) @populate_tracklist def test_end_of_song_with_single_stops(self): self.tracklist.single = True - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[0]) - self.trigger_end_of_track() - self.assertEqual(self.playback.current_track, None) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.playback.play().get() + self.assert_current_track_is(self.tracks[0]) + self.trigger_about_to_finish() + self.assert_current_track_is(None) + # EOS should have triggered + self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_end_of_song_with_single_and_random_stops(self): self.tracklist.single = True self.tracklist.random = True - self.playback.play() - self.trigger_end_of_track() - self.assertEqual(self.playback.current_track, None) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.playback.play().get() + self.trigger_about_to_finish() + # EOS should have triggered + self.assert_current_track_is(None) + self.assert_state_is(PlaybackState.STOPPED) @populate_tracklist def test_end_of_playlist_stops(self): - self.playback.play(self.tracklist.tl_tracks[-1]) - self.trigger_end_of_track() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.playback.play(self.tl_tracks.get()[-1]).get() + self.trigger_about_to_finish() + # EOS should have triggered + self.assert_state_is(PlaybackState.STOPPED) def test_repeat_off_by_default(self): - self.assertEqual(self.tracklist.repeat, False) + self.assertEqual(self.tracklist.get_repeat().get(), False) def test_random_off_by_default(self): - self.assertEqual(self.tracklist.random, False) + self.assertEqual(self.tracklist.get_random().get(), False) def test_consume_off_by_default(self): - self.assertEqual(self.tracklist.consume, False) + self.assertEqual(self.tracklist.get_consume().get(), False) @populate_tracklist def test_random_until_end_of_playlist(self): self.tracklist.random = True - self.playback.play() + self.playback.play().get() for _ in self.tracks[1:]: - self.playback.next() - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.next_track(tl_track), None) + self.playback.next().get() + self.assert_next_tl_track_is(None) @populate_tracklist def test_random_with_eot_until_end_of_playlist(self): self.tracklist.random = True - self.playback.play() + self.playback.play().get() for _ in self.tracks[1:]: - self.trigger_end_of_track() - tl_track = self.playback.current_tl_track - self.assertEqual(self.tracklist.eot_track(tl_track), None) + self.trigger_about_to_finish() + + self.assert_eot_tl_track_is(None) @populate_tracklist def test_random_until_end_of_playlist_and_play_from_start(self): self.tracklist.random = True - self.playback.play() + self.playback.play().get() for _ in self.tracks: - self.playback.next() - tl_track = self.playback.current_tl_track - self.assertNotEqual(self.tracklist.next_track(tl_track), None) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.playback.next().get() + self.assert_next_tl_track_is_not(None) + self.assert_state_is(PlaybackState.STOPPED) + self.playback.play().get() + self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_random_with_eot_until_end_of_playlist_and_play_from_start(self): self.tracklist.random = True - self.playback.play() + self.playback.play().get() for _ in self.tracks: - self.trigger_end_of_track() - tl_track = self.playback.current_tl_track - self.assertNotEqual(self.tracklist.eot_track(tl_track), None) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.trigger_about_to_finish() + # EOS should have triggered + + self.assert_eot_tl_track_is_not(None) + self.assert_state_is(PlaybackState.STOPPED) + self.playback.play().get() + self.assert_state_is(PlaybackState.PLAYING) @populate_tracklist def test_random_until_end_of_playlist_with_repeat(self): self.tracklist.repeat = True self.tracklist.random = True - self.playback.play() + self.playback.play().get() for _ in self.tracks[1:]: self.playback.next() - tl_track = self.playback.current_tl_track - self.assertNotEqual(self.tracklist.next_track(tl_track), None) + self.assert_next_tl_track_is_not(None) @populate_tracklist def test_played_track_during_random_not_played_again(self): self.tracklist.random = True - self.playback.play() + self.playback.play().get() played = [] for _ in self.tracks: - self.assertNotIn(self.playback.current_track, played) - played.append(self.playback.current_track) - self.playback.next() + track = self.playback.get_current_track().get() + self.assertNotIn(track, played) + played.append(track) + self.playback.next().get() @populate_tracklist @mock.patch('random.shuffle') @@ -1061,17 +1040,19 @@ class LocalPlaybackProviderTest(unittest.TestCase): # Covers underlying issue IssueGH17RegressionTest tests for. shuffle_mock.side_effect = lambda tracks: tracks.reverse() - expected = self.tl_tracks[::-1] + [None] + expected = self.tl_tracks.get()[::-1] + [None] actual = [] - self.playback.play() + self.playback.play().get() self.tracklist.random = True - while self.playback.state != PlaybackState.STOPPED: - self.playback.next() - actual.append(self.playback.current_tl_track) + while self.playback.get_state().get() != PlaybackState.STOPPED: + self.playback.next().get() + actual.append(self.playback.get_current_tl_track().get()) + if len(actual) > len(expected): + break self.assertEqual(actual, expected) @populate_tracklist def test_playing_track_that_isnt_in_playlist(self): with self.assertRaises(AssertionError): - self.playback.play(TlTrack(17, Track())) + self.playback.play(TlTrack(17, Track())).get() diff --git a/tests/local/test_tracklist.py b/tests/local/test_tracklist.py index b7ed7dcb..6c9532e8 100644 --- a/tests/local/test_tracklist.py +++ b/tests/local/test_tracklist.py @@ -38,7 +38,9 @@ class LocalTracklistProviderTest(unittest.TestCase): self.audio = dummy_audio.create_proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() - self.core = core.Core(self.config, mixer=None, backends=[self.backend]) + self.core = core.Core.start(audio=self.audio, + backends=[self.backend], + config=self.config).proxy() self.controller = self.core.tracklist self.playback = self.core.playback @@ -47,216 +49,254 @@ class LocalTracklistProviderTest(unittest.TestCase): def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() + def assert_state_is(self, state): + self.assertEqual(self.playback.get_state().get(), state) + + def assert_current_track_is(self, track): + self.assertEqual(self.playback.get_current_track().get(), track) + def test_length(self): - self.assertEqual(0, len(self.controller.tl_tracks)) - self.assertEqual(0, self.controller.length) + self.assertEqual(0, len(self.controller.get_tl_tracks().get())) + self.assertEqual(0, self.controller.get_length().get()) self.controller.add(self.tracks) - self.assertEqual(3, len(self.controller.tl_tracks)) - self.assertEqual(3, self.controller.length) + self.assertEqual(3, len(self.controller.get_tl_tracks().get())) + self.assertEqual(3, self.controller.get_length().get()) def test_add(self): for track in self.tracks: - tl_tracks = self.controller.add([track]) - self.assertEqual(track, self.controller.tracks[-1]) - self.assertEqual(tl_tracks[0], self.controller.tl_tracks[-1]) - self.assertEqual(track, tl_tracks[0].track) + added = self.controller.add([track]).get() + tracks = self.controller.get_tracks().get() + tl_tracks = self.controller.get_tl_tracks().get() + + self.assertEqual(track, tracks[-1]) + self.assertEqual(added[0], tl_tracks[-1]) + self.assertEqual(track, added[0].track) def test_add_at_position(self): for track in self.tracks[:-1]: - tl_tracks = self.controller.add([track], 0) - self.assertEqual(track, self.controller.tracks[0]) - self.assertEqual(tl_tracks[0], self.controller.tl_tracks[0]) - self.assertEqual(track, tl_tracks[0].track) + added = self.controller.add([track], 0).get() + tracks = self.controller.get_tracks().get() + tl_tracks = self.controller.get_tl_tracks().get() + + self.assertEqual(track, tracks[0]) + self.assertEqual(added[0], tl_tracks[0]) + self.assertEqual(track, added[0].track) @populate_tracklist def test_add_at_position_outside_of_playlist(self): for track in self.tracks: - tl_tracks = self.controller.add([track], len(self.tracks) + 2) - self.assertEqual(track, self.controller.tracks[-1]) - self.assertEqual(tl_tracks[0], self.controller.tl_tracks[-1]) - self.assertEqual(track, tl_tracks[0].track) + added = self.controller.add([track], len(self.tracks) + 2).get() + tracks = self.controller.get_tracks().get() + tl_tracks = self.controller.get_tl_tracks().get() + + self.assertEqual(track, tracks[-1]) + self.assertEqual(added[0], tl_tracks[-1]) + self.assertEqual(track, added[0].track) @populate_tracklist def test_filter_by_tlid(self): - tl_track = self.controller.tl_tracks[1] - self.assertEqual( - [tl_track], self.controller.filter({'tlid': [tl_track.tlid]})) + tl_track = self.controller.get_tl_tracks().get()[1] + result = self.controller.filter({'tlid': [tl_track.tlid]}).get() + self.assertEqual([tl_track], result) @populate_tracklist def test_filter_by_uri(self): - tl_track = self.controller.tl_tracks[1] - self.assertEqual( - [tl_track], self.controller.filter({'uri': [tl_track.track.uri]})) + tl_track = self.controller.get_tl_tracks().get()[1] + result = self.controller.filter({'uri': [tl_track.track.uri]}).get() + self.assertEqual([tl_track], result) @populate_tracklist def test_filter_by_uri_returns_nothing_for_invalid_uri(self): - self.assertEqual([], self.controller.filter({'uri': ['foobar']})) + self.assertEqual([], self.controller.filter({'uri': ['foobar']}).get()) def test_filter_by_uri_returns_single_match(self): t = Track(uri='a') self.controller.add([Track(uri='z'), t, Track(uri='y')]) - self.assertEqual(t, self.controller.filter({'uri': ['a']})[0].track) + + result = self.controller.filter({'uri': ['a']}).get() + self.assertEqual(t, result[0].track) def test_filter_by_uri_returns_multiple_matches(self): track = Track(uri='a') self.controller.add([Track(uri='z'), track, track]) - tl_tracks = self.controller.filter({'uri': ['a']}) + tl_tracks = self.controller.filter({'uri': ['a']}).get() self.assertEqual(track, tl_tracks[0].track) self.assertEqual(track, tl_tracks[1].track) def test_filter_by_uri_returns_nothing_if_no_match(self): self.controller.playlist = Playlist( tracks=[Track(uri='z'), Track(uri='y')]) - self.assertEqual([], self.controller.filter({'uri': ['a']})) + self.assertEqual([], self.controller.filter({'uri': ['a']}).get()) def test_filter_by_multiple_criteria_returns_elements_matching_all(self): t1 = Track(uri='a', name='x') t2 = Track(uri='b', name='x') t3 = Track(uri='b', name='y') self.controller.add([t1, t2, t3]) - self.assertEqual( - t1, self.controller.filter({'uri': ['a'], 'name': ['x']})[0].track) - self.assertEqual( - t2, self.controller.filter({'uri': ['b'], 'name': ['x']})[0].track) - self.assertEqual( - t3, self.controller.filter({'uri': ['b'], 'name': ['y']})[0].track) + + result1 = self.controller.filter({'uri': ['a'], 'name': ['x']}).get() + self.assertEqual(t1, result1[0].track) + + result2 = self.controller.filter({'uri': ['b'], 'name': ['x']}).get() + self.assertEqual(t2, result2[0].track) + + result3 = self.controller.filter({'uri': ['b'], 'name': ['y']}).get() + self.assertEqual(t3, result3[0].track) def test_filter_by_criteria_that_is_not_present_in_all_elements(self): track1 = Track() track2 = Track(uri='b') track3 = Track() + self.controller.add([track1, track2, track3]) - self.assertEqual( - track2, self.controller.filter({'uri': ['b']})[0].track) + result = self.controller.filter({'uri': ['b']}).get() + self.assertEqual(track2, result[0].track) @populate_tracklist def test_clear(self): - self.controller.clear() - self.assertEqual(len(self.controller.tracks), 0) + self.controller.clear().get() + self.assertEqual(len(self.controller.get_tracks().get()), 0) def test_clear_empty_playlist(self): - self.controller.clear() - self.assertEqual(len(self.controller.tracks), 0) + self.controller.clear().get() + self.assertEqual(len(self.controller.get_tracks().get()), 0) @populate_tracklist def test_clear_when_playing(self): - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.controller.clear() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.playback.play().get() + self.assert_state_is(PlaybackState.PLAYING) + self.controller.clear().get() + self.assert_state_is(PlaybackState.STOPPED) def test_add_appends_to_the_tracklist(self): self.controller.add([Track(uri='a'), Track(uri='b')]) - self.assertEqual(len(self.controller.tracks), 2) + + tracks = self.controller.get_tracks().get() + self.assertEqual(len(tracks), 2) + self.controller.add([Track(uri='c'), Track(uri='d')]) - self.assertEqual(len(self.controller.tracks), 4) - self.assertEqual(self.controller.tracks[0].uri, 'a') - self.assertEqual(self.controller.tracks[1].uri, 'b') - self.assertEqual(self.controller.tracks[2].uri, 'c') - self.assertEqual(self.controller.tracks[3].uri, 'd') + + tracks = self.controller.get_tracks().get() + self.assertEqual(len(tracks), 4) + self.assertEqual(tracks[0].uri, 'a') + self.assertEqual(tracks[1].uri, 'b') + self.assertEqual(tracks[2].uri, 'c') + self.assertEqual(tracks[3].uri, 'd') def test_add_does_not_reset_version(self): - version = self.controller.version + version = self.controller.get_version().get() self.controller.add([]) - self.assertEqual(self.controller.version, version) + self.assertEqual(self.controller.get_version().get(), version) @populate_tracklist def test_add_preserves_playing_state(self): - self.playback.play() - track = self.playback.current_track - self.controller.add(self.controller.tracks[1:2]) - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(self.playback.current_track, track) + self.playback.play().get() + + track = self.playback.get_current_track().get() + tracks = self.controller.get_tracks().get() + self.controller.add(tracks[1:2]).get() + + self.assert_state_is(PlaybackState.PLAYING) + self.assert_current_track_is(track) @populate_tracklist def test_add_preserves_stopped_state(self): - self.controller.add(self.controller.tracks[1:2]) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.assertEqual(self.playback.current_track, None) + tracks = self.controller.get_tracks().get() + self.controller.add(tracks[1:2]).get() + + self.assert_state_is(PlaybackState.STOPPED) + self.assert_current_track_is(None) @populate_tracklist def test_add_returns_the_tl_tracks_that_was_added(self): - tl_tracks = self.controller.add(self.controller.tracks[1:2]) - self.assertEqual(tl_tracks[0].track, self.controller.tracks[1]) + tracks = self.controller.get_tracks().get() + + added = self.controller.add(tracks[1:2]).get() + tracks = self.controller.get_tracks().get() + self.assertEqual(added[0].track, tracks[1]) @populate_tracklist def test_move_single(self): self.controller.move(0, 0, 2) - tracks = self.controller.tracks + tracks = self.controller.get_tracks().get() self.assertEqual(tracks[2], self.tracks[0]) @populate_tracklist def test_move_group(self): self.controller.move(0, 2, 1) - tracks = self.controller.tracks + tracks = self.controller.get_tracks().get() self.assertEqual(tracks[1], self.tracks[0]) self.assertEqual(tracks[2], self.tracks[1]) @populate_tracklist def test_moving_track_outside_of_playlist(self): - tracks = len(self.controller.tracks) + num_tracks = len(self.controller.get_tracks().get()) with self.assertRaises(AssertionError): - self.controller.move(0, 0, tracks + 5) + self.controller.move(0, 0, num_tracks + 5).get() @populate_tracklist def test_move_group_outside_of_playlist(self): - tracks = len(self.controller.tracks) + num_tracks = len(self.controller.get_tracks().get()) with self.assertRaises(AssertionError): - self.controller.move(0, 2, tracks + 5) + self.controller.move(0, 2, num_tracks + 5).get() @populate_tracklist def test_move_group_out_of_range(self): - tracks = len(self.controller.tracks) + num_tracks = len(self.controller.get_tracks().get()) with self.assertRaises(AssertionError): - self.controller.move(tracks + 2, tracks + 3, 0) + self.controller.move(num_tracks + 2, num_tracks + 3, 0).get() @populate_tracklist def test_move_group_invalid_group(self): with self.assertRaises(AssertionError): - self.controller.move(2, 1, 0) + self.controller.move(2, 1, 0).get() def test_tracks_attribute_is_immutable(self): - tracks1 = self.controller.tracks - tracks2 = self.controller.tracks + tracks1 = self.controller.tracks.get() + tracks2 = self.controller.tracks.get() self.assertNotEqual(id(tracks1), id(tracks2)) @populate_tracklist def test_remove(self): - track1 = self.controller.tracks[1] - track2 = self.controller.tracks[2] - version = self.controller.version + track1 = self.controller.get_tracks().get()[1] + track2 = self.controller.get_tracks().get()[2] + version = self.controller.get_version().get() self.controller.remove({'uri': [track1.uri]}) - self.assertLess(version, self.controller.version) - self.assertNotIn(track1, self.controller.tracks) - self.assertEqual(track2, self.controller.tracks[1]) + self.assertLess(version, self.controller.get_version().get()) + self.assertNotIn(track1, self.controller.get_tracks().get()) + self.assertEqual(track2, self.controller.get_tracks().get()[1]) @populate_tracklist def test_removing_track_that_does_not_exist_does_nothing(self): - self.controller.remove({'uri': ['/nonexistant']}) + self.controller.remove({'uri': ['/nonexistant']}).get() def test_removing_from_empty_playlist_does_nothing(self): - self.controller.remove({'uri': ['/nonexistant']}) + self.controller.remove({'uri': ['/nonexistant']}).get() @populate_tracklist def test_remove_lists(self): - track0 = self.controller.tracks[0] - track1 = self.controller.tracks[1] - track2 = self.controller.tracks[2] - version = self.controller.version + version = self.controller.get_version().get() + tracks = self.controller.get_tracks().get() + track0 = tracks[0] + track1 = tracks[1] + track2 = tracks[2] + self.controller.remove({'uri': [track0.uri, track2.uri]}) - self.assertLess(version, self.controller.version) - self.assertNotIn(track0, self.controller.tracks) - self.assertNotIn(track2, self.controller.tracks) - self.assertEqual(track1, self.controller.tracks[0]) + + tracks = self.controller.get_tracks().get() + self.assertLess(version, self.controller.get_version().get()) + self.assertNotIn(track0, tracks) + self.assertNotIn(track2, tracks) + self.assertEqual(track1, tracks[0]) @populate_tracklist def test_shuffle(self): random.seed(1) self.controller.shuffle() - shuffled_tracks = self.controller.tracks + shuffled_tracks = self.controller.get_tracks().get() self.assertNotEqual(self.tracks, shuffled_tracks) self.assertEqual(set(self.tracks), set(shuffled_tracks)) @@ -266,7 +306,7 @@ class LocalTracklistProviderTest(unittest.TestCase): random.seed(1) self.controller.shuffle(1, 3) - shuffled_tracks = self.controller.tracks + shuffled_tracks = self.controller.get_tracks().get() self.assertNotEqual(self.tracks, shuffled_tracks) self.assertEqual(self.tracks[0], shuffled_tracks[0]) @@ -275,20 +315,20 @@ class LocalTracklistProviderTest(unittest.TestCase): @populate_tracklist def test_shuffle_invalid_subset(self): with self.assertRaises(AssertionError): - self.controller.shuffle(3, 1) + self.controller.shuffle(3, 1).get() @populate_tracklist def test_shuffle_superset(self): - tracks = len(self.controller.tracks) + num_tracks = len(self.controller.get_tracks().get()) with self.assertRaises(AssertionError): - self.controller.shuffle(1, tracks + 5) + self.controller.shuffle(1, num_tracks + 5).get() @populate_tracklist def test_shuffle_open_subset(self): random.seed(1) self.controller.shuffle(1) - shuffled_tracks = self.controller.tracks + shuffled_tracks = self.controller.get_tracks().get() self.assertNotEqual(self.tracks, shuffled_tracks) self.assertEqual(self.tracks[0], shuffled_tracks[0]) @@ -296,22 +336,22 @@ class LocalTracklistProviderTest(unittest.TestCase): @populate_tracklist def test_slice_returns_a_subset_of_tracks(self): - track_slice = self.controller.slice(1, 3) + track_slice = self.controller.slice(1, 3).get() self.assertEqual(2, len(track_slice)) self.assertEqual(self.tracks[1], track_slice[0].track) self.assertEqual(self.tracks[2], track_slice[1].track) @populate_tracklist def test_slice_returns_empty_list_if_indexes_outside_tracks_list(self): - self.assertEqual(0, len(self.controller.slice(7, 8))) - self.assertEqual(0, len(self.controller.slice(-1, 1))) + self.assertEqual(0, len(self.controller.slice(7, 8).get())) + self.assertEqual(0, len(self.controller.slice(-1, 1).get())) def test_version_does_not_change_when_adding_nothing(self): - version = self.controller.version + version = self.controller.get_version().get() self.controller.add([]) - self.assertEqual(version, self.controller.version) + self.assertEqual(version, self.controller.get_version().get()) def test_version_increases_when_adding_something(self): - version = self.controller.version + version = self.controller.get_version().get() self.controller.add([Track()]) - self.assertLess(version, self.controller.version) + self.assertLess(version, self.controller.get_version().get()) diff --git a/tests/local/test_translator.py b/tests/local/test_translator.py index 124766dd..7839cd58 100644 --- a/tests/local/test_translator.py +++ b/tests/local/test_translator.py @@ -4,6 +4,7 @@ from __future__ import unicode_literals import pytest +from mopidy import compat from mopidy.local import translator @@ -42,11 +43,15 @@ def test_local_uri_to_file_uri_errors(uri): ('local:directory:A/B', b'/home/alice/Music/A/B'), ('local:directory:A%20B', b'/home/alice/Music/A B'), ('local:directory:A+B', b'/home/alice/Music/A+B'), - ('local:directory:%C3%A6%C3%B8%C3%A5', b'/home/alice/Music/æøå'), + ( + 'local:directory:%C3%A6%C3%B8%C3%A5', + b'/home/alice/Music/\xc3\xa6\xc3\xb8\xc3\xa5'), ('local:track:A/B.mp3', b'/home/alice/Music/A/B.mp3'), ('local:track:A%20B.mp3', b'/home/alice/Music/A B.mp3'), ('local:track:A+B.mp3', b'/home/alice/Music/A+B.mp3'), - ('local:track:%C3%A6%C3%B8%C3%A5.mp3', b'/home/alice/Music/æøå.mp3'), + ( + 'local:track:%C3%A6%C3%B8%C3%A5.mp3', + b'/home/alice/Music/\xc3\xa6\xc3\xb8\xc3\xa5.mp3'), ]) def test_local_uri_to_path(uri, path): media_dir = b'/home/alice/Music' @@ -85,7 +90,9 @@ def test_path_to_file_uri(path, uri): (b'\x00\x01\x02', 'local:track:%00%01%02'), ]) def test_path_to_local_track_uri(path, uri): - assert translator.path_to_local_track_uri(path) == uri + result = translator.path_to_local_track_uri(path) + assert isinstance(result, compat.text_type) + assert result == uri @pytest.mark.parametrize('path,uri', [ @@ -95,4 +102,6 @@ def test_path_to_local_track_uri(path, uri): (b'\x00\x01\x02', 'local:directory:%00%01%02'), ]) def test_path_to_local_directory_uri(path, uri): - assert translator.path_to_local_directory_uri(path) == uri + result = translator.path_to_local_directory_uri(path) + assert isinstance(result, compat.text_type) + assert result == uri diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index edebe65b..e0ea1ce4 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -7,14 +7,12 @@ import platform import shutil import tempfile import unittest -import urllib import pykka from mopidy import core from mopidy.internal import deprecation -from mopidy.m3u import actor -from mopidy.m3u.translator import playlist_uri_to_path +from mopidy.m3u.backend import M3UBackend from mopidy.models import Playlist, Track from tests import dummy_audio, path_to_data_dir @@ -22,9 +20,13 @@ from tests.m3u import generate_song class M3UPlaylistsProviderTest(unittest.TestCase): - backend_class = actor.M3UBackend + backend_class = M3UBackend config = { 'm3u': { + 'enabled': True, + 'base_dir': None, + 'default_encoding': 'latin-1', + 'default_extension': '.m3u', 'playlists_dir': path_to_data_dir(''), } } @@ -32,9 +34,10 @@ class M3UPlaylistsProviderTest(unittest.TestCase): def setUp(self): # noqa: N802 self.config['m3u']['playlists_dir'] = tempfile.mkdtemp() self.playlists_dir = self.config['m3u']['playlists_dir'] + self.base_dir = self.config['m3u']['base_dir'] or self.playlists_dir audio = dummy_audio.create_proxy() - backend = actor.M3UBackend.start( + backend = M3UBackend.start( config=self.config, audio=audio).proxy() self.core = core.Core(backends=[backend]) @@ -46,7 +49,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): def test_created_playlist_is_persisted(self): uri = 'm3u:test.m3u' - path = playlist_uri_to_path(uri, self.playlists_dir) + path = os.path.join(self.playlists_dir, b'test.m3u') self.assertFalse(os.path.exists(path)) playlist = self.core.playlists.create('test') @@ -57,7 +60,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): def test_create_sanitizes_playlist_name(self): playlist = self.core.playlists.create(' ../../test FOO baR ') self.assertEqual('..|..|test FOO baR', playlist.name) - path = playlist_uri_to_path(playlist.uri, self.playlists_dir) + path = os.path.join(self.playlists_dir, b'..|..|test FOO baR.m3u') self.assertEqual(self.playlists_dir, os.path.dirname(path)) self.assertTrue(os.path.exists(path)) @@ -65,8 +68,8 @@ class M3UPlaylistsProviderTest(unittest.TestCase): uri1 = 'm3u:test1.m3u' uri2 = 'm3u:test2.m3u' - path1 = playlist_uri_to_path(uri1, self.playlists_dir) - path2 = playlist_uri_to_path(uri2, self.playlists_dir) + path1 = os.path.join(self.playlists_dir, b'test1.m3u') + path2 = os.path.join(self.playlists_dir, b'test2.m3u') playlist = self.core.playlists.create('test1') self.assertEqual('test1', playlist.name) @@ -82,7 +85,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): def test_deleted_playlist_is_removed(self): uri = 'm3u:test.m3u' - path = playlist_uri_to_path(uri, self.playlists_dir) + path = os.path.join(self.playlists_dir, b'test.m3u') self.assertFalse(os.path.exists(path)) @@ -98,7 +101,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): track = Track(uri=generate_song(1)) playlist = self.core.playlists.create('test') playlist = self.core.playlists.save(playlist.replace(tracks=[track])) - path = playlist_uri_to_path(playlist.uri, self.playlists_dir) + path = os.path.join(self.playlists_dir, b'test.m3u') with open(path) as f: contents = f.read() @@ -109,32 +112,32 @@ class M3UPlaylistsProviderTest(unittest.TestCase): track = Track(uri=generate_song(1), name='Test', length=60000) playlist = self.core.playlists.create('test') playlist = self.core.playlists.save(playlist.replace(tracks=[track])) - path = playlist_uri_to_path(playlist.uri, self.playlists_dir) + path = os.path.join(self.playlists_dir, b'test.m3u') with open(path) as f: m3u = f.read().splitlines() - self.assertEqual(['#EXTM3U', '#EXTINF:60,Test', track.uri], m3u) + self.assertEqual(['#EXTM3U', '#EXTINF:-1,Test', track.uri], m3u) def test_latin1_playlist_contents_is_written_to_disk(self): track = Track(uri=generate_song(1), name='Test\x9f', length=60000) playlist = self.core.playlists.create('test') playlist = self.core.playlists.save(playlist.copy(tracks=[track])) - path = playlist_uri_to_path(playlist.uri, self.playlists_dir) + path = os.path.join(self.playlists_dir, b'test.m3u') with open(path, 'rb') as f: m3u = f.read().splitlines() - self.assertEqual([b'#EXTM3U', b'#EXTINF:60,Test\x9f', track.uri], m3u) + self.assertEqual([b'#EXTM3U', b'#EXTINF:-1,Test\x9f', track.uri], m3u) def test_utf8_playlist_contents_is_replaced_and_written_to_disk(self): track = Track(uri=generate_song(1), name='Test\u07b4', length=60000) playlist = self.core.playlists.create('test') playlist = self.core.playlists.save(playlist.copy(tracks=[track])) - path = playlist_uri_to_path(playlist.uri, self.playlists_dir) + path = os.path.join(self.playlists_dir, b'test.m3u') with open(path, 'rb') as f: m3u = f.read().splitlines() - self.assertEqual([b'#EXTM3U', b'#EXTINF:60,Test?', track.uri], m3u) + self.assertEqual([b'#EXTM3U', b'#EXTINF:-1,Test?', track.uri], m3u) def test_playlists_are_loaded_at_startup(self): track = Track(uri='dummy:track:path2') @@ -149,8 +152,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.assertEqual(track.uri, result.tracks[0].uri) def test_load_playlist_with_nonfilesystem_encoding_of_filename(self): - uri = 'm3u:%s.m3u' % urllib.quote('øæå'.encode('latin-1')) - path = playlist_uri_to_path(uri, self.playlists_dir) + path = os.path.join(self.playlists_dir, 'øæå.m3u'.encode('latin-1')) with open(path, 'wb+') as f: f.write(b'#EXTM3U\n') @@ -198,7 +200,7 @@ class M3UPlaylistsProviderTest(unittest.TestCase): playlist = self.core.playlists.create('test') self.assertEqual(playlist, self.core.playlists.lookup(playlist.uri)) - path = playlist_uri_to_path(playlist.uri, self.playlists_dir) + path = os.path.join(self.playlists_dir, b'test.m3u') self.assertTrue(os.path.exists(path)) os.remove(path) @@ -245,12 +247,9 @@ class M3UPlaylistsProviderTest(unittest.TestCase): def test_save_playlist_with_new_uri(self): uri = 'm3u:test.m3u' - - with self.assertRaises(AssertionError): - self.core.playlists.save(Playlist(uri=uri)) - - path = playlist_uri_to_path(uri, self.playlists_dir) - self.assertFalse(os.path.exists(path)) + self.core.playlists.save(Playlist(uri=uri)) + path = os.path.join(self.playlists_dir, b'test.m3u') + self.assertTrue(os.path.exists(path)) def test_playlist_with_unknown_track(self): track = Track(uri='file:///dev/null') @@ -264,6 +263,32 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.assertEqual(playlist.name, result.name) self.assertEqual(track.uri, result.tracks[0].uri) + def test_playlist_with_absolute_path(self): + track = Track(uri='/tmp/test.mp3') + filepath = b'/tmp/test.mp3' + playlist = self.core.playlists.create('test') + playlist = playlist.replace(tracks=[track]) + playlist = self.core.playlists.save(playlist) + + self.assertEqual(len(self.core.playlists.as_list()), 1) + result = self.core.playlists.lookup('m3u:test.m3u') + self.assertEqual('m3u:test.m3u', result.uri) + self.assertEqual(playlist.name, result.name) + self.assertEqual('file://' + filepath, result.tracks[0].uri) + + def test_playlist_with_relative_path(self): + track = Track(uri='test.mp3') + filepath = os.path.join(self.base_dir, b'test.mp3') + playlist = self.core.playlists.create('test') + playlist = playlist.replace(tracks=[track]) + playlist = self.core.playlists.save(playlist) + + self.assertEqual(len(self.core.playlists.as_list()), 1) + result = self.core.playlists.lookup('m3u:test.m3u') + self.assertEqual('m3u:test.m3u', result.uri) + self.assertEqual(playlist.name, result.name) + self.assertEqual('file://' + filepath, result.tracks[0].uri) + def test_playlist_sort_order(self): def check_order(playlists, names): self.assertEqual(names, [playlist.name for playlist in playlists]) @@ -306,6 +331,13 @@ class M3UPlaylistsProviderTest(unittest.TestCase): self.assertIsNone(item_refs) +class M3UPlaylistsProviderBaseDirectoryTest(M3UPlaylistsProviderTest): + + def setUp(self): # noqa: N802 + self.config['m3u']['base_dir'] = tempfile.mkdtemp() + super(M3UPlaylistsProviderBaseDirectoryTest, self).setUp() + + class DeprecatedM3UPlaylistsProviderTest(M3UPlaylistsProviderTest): def run(self, result=None): diff --git a/tests/m3u/test_translator.py b/tests/m3u/test_translator.py index f1e14301..35efed4c 100644 --- a/tests/m3u/test_translator.py +++ b/tests/m3u/test_translator.py @@ -2,133 +2,145 @@ from __future__ import absolute_import, unicode_literals -import os -import tempfile -import unittest +import io -from mopidy.internal import path from mopidy.m3u import translator -from mopidy.models import Track - -from tests import path_to_data_dir - -data_dir = path_to_data_dir('') -song1_path = path_to_data_dir('song1.mp3') -song2_path = path_to_data_dir('song2.mp3') -song3_path = path_to_data_dir('φοο.mp3') -encoded_path = path_to_data_dir('æøå.mp3') -song1_uri = path.path_to_uri(song1_path) -song2_uri = path.path_to_uri(song2_path) -song3_uri = path.path_to_uri(song3_path) -encoded_uri = path.path_to_uri(encoded_path) -song1_track = Track(uri=song1_uri) -song2_track = Track(uri=song2_uri) -song3_track = Track(uri=song3_uri) -encoded_track = Track(uri=encoded_uri) -song1_ext_track = song1_track.replace(name='song1') -song2_ext_track = song2_track.replace(name='song2', length=60000) -encoded_ext_track = encoded_track.replace(name='æøå') +from mopidy.models import Playlist, Ref, Track -# FIXME use mock instead of tempfile.NamedTemporaryFile - -class M3UToUriTest(unittest.TestCase): - - def parse(self, name): - return translator.parse_m3u(name, data_dir) - - def test_empty_file(self): - tracks = self.parse(path_to_data_dir('empty.m3u')) - self.assertEqual([], tracks) - - def test_basic_file(self): - tracks = self.parse(path_to_data_dir('one.m3u')) - self.assertEqual([song1_track], tracks) - - def test_file_with_comment(self): - tracks = self.parse(path_to_data_dir('comment.m3u')) - self.assertEqual([song1_track], tracks) - - def test_file_is_relative_to_correct_dir(self): - with tempfile.NamedTemporaryFile(delete=False) as tmp: - tmp.write('song1.mp3') - try: - tracks = self.parse(tmp.name) - self.assertEqual([song1_track], tracks) - finally: - if os.path.exists(tmp.name): - os.remove(tmp.name) - - def test_file_with_absolute_files(self): - with tempfile.NamedTemporaryFile(delete=False) as tmp: - tmp.write(song1_path) - try: - tracks = self.parse(tmp.name) - self.assertEqual([song1_track], tracks) - finally: - if os.path.exists(tmp.name): - os.remove(tmp.name) - - def test_file_with_multiple_absolute_files(self): - with tempfile.NamedTemporaryFile(delete=False) as tmp: - tmp.write(song1_path + '\n') - tmp.write('# comment \n') - tmp.write(song2_path) - try: - tracks = self.parse(tmp.name) - self.assertEqual([song1_track, song2_track], tracks) - finally: - if os.path.exists(tmp.name): - os.remove(tmp.name) - - def test_file_with_uri(self): - with tempfile.NamedTemporaryFile(delete=False) as tmp: - tmp.write(song1_uri) - try: - tracks = self.parse(tmp.name) - self.assertEqual([song1_track], tracks) - finally: - if os.path.exists(tmp.name): - os.remove(tmp.name) - - def test_encoding_is_latin1(self): - tracks = self.parse(path_to_data_dir('encoding.m3u')) - self.assertEqual([encoded_track], tracks) - - def test_open_missing_file(self): - tracks = self.parse(path_to_data_dir('non-existant.m3u')) - self.assertEqual([], tracks) - - def test_empty_ext_file(self): - tracks = self.parse(path_to_data_dir('empty-ext.m3u')) - self.assertEqual([], tracks) - - def test_basic_ext_file(self): - tracks = self.parse(path_to_data_dir('one-ext.m3u')) - self.assertEqual([song1_ext_track], tracks) - - def test_multi_ext_file(self): - tracks = self.parse(path_to_data_dir('two-ext.m3u')) - self.assertEqual([song1_ext_track, song2_ext_track], tracks) - - def test_ext_file_with_comment(self): - tracks = self.parse(path_to_data_dir('comment-ext.m3u')) - self.assertEqual([song1_ext_track], tracks) - - def test_ext_encoding_is_latin1(self): - tracks = self.parse(path_to_data_dir('encoding-ext.m3u')) - self.assertEqual([encoded_ext_track], tracks) - - def test_m3u8_file(self): - with tempfile.NamedTemporaryFile(suffix='.m3u8', delete=False) as tmp: - tmp.write(song3_path) - try: - tracks = self.parse(tmp.name) - self.assertEqual([song3_track], tracks) - finally: - if os.path.exists(tmp.name): - os.remove(tmp.name) +def loads(s, basedir=b'.'): + return translator.load_items(io.StringIO(s), basedir=basedir) -class URItoM3UTest(unittest.TestCase): - pass +def dumps(items): + fp = io.StringIO() + translator.dump_items(items, fp) + return fp.getvalue() + + +def test_path_to_uri(): + from mopidy.m3u.translator import path_to_uri + + assert path_to_uri(b'test') == 'm3u:test' + assert path_to_uri(b'test.m3u') == 'm3u:test.m3u' + assert path_to_uri(b'./test.m3u') == 'm3u:test.m3u' + assert path_to_uri(b'foo/../test.m3u') == 'm3u:test.m3u' + assert path_to_uri(b'Test Playlist.m3u') == 'm3u:Test%20Playlist.m3u' + assert path_to_uri(b'test.mp3', scheme='file') == 'file:///test.mp3' + + +def test_latin1_path_to_uri(): + path = 'æøå.m3u'.encode('latin-1') + assert translator.path_to_uri(path) == 'm3u:%E6%F8%E5.m3u' + + +def test_utf8_path_to_uri(): + path = 'æøå.m3u'.encode('utf-8') + assert translator.path_to_uri(path) == 'm3u:%C3%A6%C3%B8%C3%A5.m3u' + + +def test_uri_to_path(): + from mopidy.m3u.translator import uri_to_path + + assert uri_to_path('m3u:test.m3u') == b'test.m3u' + assert uri_to_path(b'm3u:test.m3u') == b'test.m3u' + assert uri_to_path('m3u:Test%20Playlist.m3u') == b'Test Playlist.m3u' + assert uri_to_path(b'm3u:Test%20Playlist.m3u') == b'Test Playlist.m3u' + assert uri_to_path('m3u:%E6%F8%E5.m3u') == b'\xe6\xf8\xe5.m3u' + assert uri_to_path(b'm3u:%E6%F8%E5.m3u') == b'\xe6\xf8\xe5.m3u' + assert uri_to_path('file:///test.mp3') == b'/test.mp3' + assert uri_to_path(b'file:///test.mp3') == b'/test.mp3' + + +def test_name_from_path(): + from mopidy.m3u.translator import name_from_path + + assert name_from_path(b'test') == 'test' + assert name_from_path(b'test.m3u') == 'test' + assert name_from_path(b'../test.m3u') == 'test' + + +def test_path_from_name(): + from mopidy.m3u.translator import path_from_name + + assert path_from_name('test') == b'test' + assert path_from_name('test', '.m3u') == b'test.m3u' + assert path_from_name('foo/bar', sep='-') == b'foo-bar' + + +def test_path_to_ref(): + from mopidy.m3u.translator import path_to_ref + + assert path_to_ref(b'test.m3u') == Ref.playlist( + uri='m3u:test.m3u', name='test' + ) + assert path_to_ref(b'Test Playlist.m3u') == Ref.playlist( + uri='m3u:Test%20Playlist.m3u', name='Test Playlist' + ) + + +def test_load_items(): + assert loads('') == [] + + assert loads('test.mp3', basedir=b'/playlists') == [ + Ref.track(uri='file:///playlists/test.mp3', name='test') + ] + assert loads('../test.mp3', basedir=b'/playlists') == [ + Ref.track(uri='file:///test.mp3', name='test') + ] + assert loads('/test.mp3') == [ + Ref.track(uri='file:///test.mp3', name='test') + ] + assert loads('file:///test.mp3') == [ + Ref.track(uri='file:///test.mp3') + ] + assert loads('http://example.com/stream') == [ + Ref.track(uri='http://example.com/stream') + ] + + assert loads('#EXTM3U\n#EXTINF:42,Test\nfile:///test.mp3\n') == [ + Ref.track(uri='file:///test.mp3', name='Test') + ] + assert loads('#EXTM3U\n#EXTINF:-1,Test\nhttp://example.com/stream\n') == [ + Ref.track(uri='http://example.com/stream', name='Test') + ] + + +def test_dump_items(): + assert dumps([]) == '' + assert dumps([Ref.track(uri='file:///test.mp3')]) == ( + 'file:///test.mp3\n' + ) + assert dumps([Ref.track(uri='file:///test.mp3', name='test')]) == ( + '#EXTM3U\n' + '#EXTINF:-1,test\n' + 'file:///test.mp3\n' + ) + assert dumps([Track(uri='file:///test.mp3', name='test', length=42)]) == ( + '#EXTM3U\n' + '#EXTINF:-1,test\n' + 'file:///test.mp3\n' + ) + assert dumps([Track(uri='http://example.com/stream')]) == ( + 'http://example.com/stream\n' + ) + assert dumps([Track(uri='http://example.com/stream', name='Test')]) == ( + '#EXTM3U\n' + '#EXTINF:-1,Test\n' + 'http://example.com/stream\n' + ) + + +def test_playlist(): + from mopidy.m3u.translator import playlist + + assert playlist(b'test.m3u') == Playlist( + uri='m3u:test.m3u', + name='test' + ) + assert playlist(b'test.m3u', [Ref(uri='file:///test.mp3')], 1) == Playlist( + uri='m3u:test.m3u', + name='test', + tracks=[Track(uri='file:///test.mp3')], + last_modified=1000 + ) diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index 754b4418..0e8157bd 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -10,7 +10,7 @@ from mopidy import core from mopidy.internal import deprecation from mopidy.mpd import session, uri_mapper -from tests import dummy_backend, dummy_mixer +from tests import dummy_audio, dummy_backend, dummy_mixer class MockConnection(mock.Mock): @@ -36,6 +36,7 @@ class BaseTestCase(unittest.TestCase): }, 'mpd': { 'password': None, + 'default_playlist_scheme': 'dummy', } } @@ -44,11 +45,13 @@ class BaseTestCase(unittest.TestCase): self.mixer = dummy_mixer.create_proxy() else: self.mixer = None - self.backend = dummy_backend.create_proxy() + self.audio = dummy_audio.create_proxy() + self.backend = dummy_backend.create_proxy(audio=self.audio) with deprecation.ignore(): self.core = core.Core.start( self.get_config(), + audio=self.audio, mixer=self.mixer, backends=[self.backend]).proxy() diff --git a/tests/mpd/protocol/test_current_playlist.py b/tests/mpd/protocol/test_current_playlist.py index 81bec5a4..8dd814e9 100644 --- a/tests/mpd/protocol/test_current_playlist.py +++ b/tests/mpd/protocol/test_current_playlist.py @@ -178,13 +178,13 @@ class MoveCommandsTest(BasePopulatedTracklistTestCase): self.assertInResponse('OK') def test_moveid(self): - self.send_request('moveid "4" "2"') + self.send_request('moveid "5" "2"') result = [t.name for t in self.core.tracklist.tracks.get()] self.assertEqual(result, ['a', 'b', 'e', 'c', 'd', 'f']) self.assertInResponse('OK') def test_moveid_with_tlid_not_found_in_tracklist_should_ack(self): - self.send_request('moveid "9" "0"') + self.send_request('moveid "10" "0"') self.assertEqualResponse( 'ACK [50@0] {moveid} No such song') @@ -210,7 +210,7 @@ class PlaylistFindCommandTest(protocol.BaseTestCase): self.send_request('playlistfind filename "dummy:///exists"') self.assertInResponse('file: dummy:///exists') - self.assertInResponse('Id: 0') + self.assertInResponse('Id: 1') self.assertInResponse('Pos: 0') self.assertInResponse('OK') @@ -224,11 +224,11 @@ class PlaylistIdCommandTest(BasePopulatedTracklistTestCase): self.assertInResponse('OK') def test_playlistid_with_songid(self): - self.send_request('playlistid "1"') + self.send_request('playlistid "2"') self.assertNotInResponse('Title: a') - self.assertNotInResponse('Id: 0') + self.assertNotInResponse('Id: 1') self.assertInResponse('Title: b') - self.assertInResponse('Id: 1') + self.assertInResponse('Id: 2') self.assertInResponse('OK') def test_playlistid_with_not_existing_songid_fails(self): @@ -445,18 +445,18 @@ class SwapCommandTest(BasePopulatedTracklistTestCase): self.assertInResponse('OK') def test_swapid(self): - self.send_request('swapid "1" "4"') + self.send_request('swapid "2" "5"') result = [t.name for t in self.core.tracklist.tracks.get()] self.assertEqual(result, ['a', 'e', 'c', 'd', 'b', 'f']) self.assertInResponse('OK') def test_swapid_with_first_id_unknown_should_ack(self): - self.send_request('swapid "0" "8"') + self.send_request('swapid "1" "8"') self.assertEqualResponse( 'ACK [50@0] {swapid} No such song') def test_swapid_with_second_id_unknown_should_ack(self): - self.send_request('swapid "8" "0"') + self.send_request('swapid "8" "1"') self.assertEqualResponse( 'ACK [50@0] {swapid} No such song') diff --git a/tests/mpd/protocol/test_idle.py b/tests/mpd/protocol/test_idle.py index 075da845..f93bf8aa 100644 --- a/tests/mpd/protocol/test_idle.py +++ b/tests/mpd/protocol/test_idle.py @@ -10,7 +10,7 @@ from tests.mpd import protocol class IdleHandlerTest(protocol.BaseTestCase): def idle_event(self, subsystem): - self.session.on_idle(subsystem) + self.session.on_event(subsystem) def assertEqualEvents(self, events): # noqa: N802 self.assertEqual(set(events), self.context.events) diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index de02ae36..9f13fc22 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -213,7 +213,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertEqual(self.core.playback.current_track.get(), None) self.core.playback.play() self.core.playback.next() - self.core.playback.stop() + self.core.playback.stop().get() self.assertNotEqual(self.core.playback.current_track.get(), None) self.send_request('play "-1"') @@ -231,6 +231,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_play_minus_is_ignored_if_playing(self): + self.core.playback.play().get() self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -243,6 +244,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_play_minus_one_resumes_if_paused(self): + self.core.playback.play().get() self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -257,12 +259,12 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid(self): - self.send_request('playid "0"') + self.send_request('playid "1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_playid_without_quotes(self): - self.send_request('playid 0') + self.send_request('playid 1') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') @@ -277,8 +279,8 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_playid_minus_1_plays_current_track_if_current_track_is_set(self): self.assertEqual(self.core.playback.current_track.get(), None) - self.core.playback.play() - self.core.playback.next() + self.core.playback.play().get() + self.core.playback.next().get() self.core.playback.stop() self.assertNotEqual(None, self.core.playback.current_track.get()) @@ -297,6 +299,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid_minus_is_ignored_if_playing(self): + self.core.playback.play().get() self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -309,6 +312,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid_minus_one_resumes_if_paused(self): + self.core.playback.play().get() self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -363,7 +367,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_seekid_in_current_track(self): self.core.playback.play() - self.send_request('seekid "0" "30"') + self.send_request('seekid "1" "30"') current_track = self.core.playback.current_track.get() self.assertEqual(current_track, self.tracks[0]) @@ -374,15 +378,15 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_seekid_in_another_track(self): self.core.playback.play() - self.send_request('seekid "1" "30"') + self.send_request('seekid "2" "30"') current_tl_track = self.core.playback.current_tl_track.get() - self.assertEqual(current_tl_track.tlid, 1) + self.assertEqual(current_tl_track.tlid, 2) self.assertEqual(current_tl_track.track, self.tracks[1]) self.assertInResponse('OK') def test_seekcur_absolute_value(self): - self.core.playback.play() + self.core.playback.play().get() self.send_request('seekcur "30"') @@ -390,7 +394,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_seekcur_positive_diff(self): - self.core.playback.play() + self.core.playback.play().get() self.core.playback.seek(10000) self.assertGreaterEqual(self.core.playback.time_position.get(), 10000) @@ -400,7 +404,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_seekcur_negative_diff(self): - self.core.playback.play() + self.core.playback.play().get() self.core.playback.seek(30000) self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) diff --git a/tests/mpd/protocol/test_regression.py b/tests/mpd/protocol/test_regression.py index 565b369e..40a3f103 100644 --- a/tests/mpd/protocol/test_regression.py +++ b/tests/mpd/protocol/test_regression.py @@ -31,6 +31,7 @@ class IssueGH17RegressionTest(protocol.BaseTestCase): Track(uri='dummy:e'), Track(uri='dummy:f'), ] + self.audio.trigger_fake_playback_failure('dummy:error') self.backend.library.dummy_library = tracks self.core.tracklist.add(uris=[t.uri for t in tracks]).get() @@ -232,3 +233,24 @@ class IssueGH1120RegressionTest(protocol.BaseTestCase): response2 = self.send_request('lsinfo "/"') self.assertEqual(response1, response2) + + +class IssueGH1348RegressionTest(protocol.BaseTestCase): + + """ + The issue: http://github.com/mopidy/mopidy/issues/1348 + """ + + def test(self): + self.backend.library.dummy_library = [Track(uri='dummy:a')] + + # Create a dummy playlist and trigger population of mapping + self.send_request('playlistadd "testing1" "dummy:a"') + self.send_request('listplaylists') + + # Create an other playlist which isn't in the map + self.send_request('playlistadd "testing2" "dummy:a"') + self.assertEqual(['OK'], self.send_request('rm "testing2"')) + + playlists = self.backend.playlists.as_list().get() + self.assertEqual(['testing1'], [ref.name for ref in playlists]) diff --git a/tests/mpd/protocol/test_status.py b/tests/mpd/protocol/test_status.py index fb448d8d..6369a2e7 100644 --- a/tests/mpd/protocol/test_status.py +++ b/tests/mpd/protocol/test_status.py @@ -16,7 +16,7 @@ class StatusHandlerTest(protocol.BaseTestCase): self.backend.library.dummy_library = [track] self.core.tracklist.add(uris=[track.uri]).get() - self.core.playback.play() + self.core.playback.play().get() self.send_request('currentsong') self.assertInResponse('file: dummy:/a') self.assertInResponse('Time: 0') @@ -26,7 +26,7 @@ class StatusHandlerTest(protocol.BaseTestCase): self.assertNotInResponse('Track: 0') self.assertNotInResponse('Date: ') self.assertInResponse('Pos: 0') - self.assertInResponse('Id: 0') + self.assertInResponse('Id: 1') self.assertInResponse('OK') def test_currentsong_without_song(self): diff --git a/tests/mpd/protocol/test_stored_playlists.py b/tests/mpd/protocol/test_stored_playlists.py index 90c325ff..fc3e8214 100644 --- a/tests/mpd/protocol/test_stored_playlists.py +++ b/tests/mpd/protocol/test_stored_playlists.py @@ -16,6 +16,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) self.send_request('listplaylist "name"') + self.assertInResponse('file: dummy:a') self.assertInResponse('OK') @@ -25,11 +26,13 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) self.send_request('listplaylist name') + self.assertInResponse('file: dummy:a') self.assertInResponse('OK') def test_listplaylist_fails_if_no_playlist_is_found(self): self.send_request('listplaylist "name"') + self.assertEqualResponse('ACK [50@0] {listplaylist} No such playlist') def test_listplaylist_duplicate(self): @@ -38,6 +41,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.backend.playlists.set_dummy_playlists([playlist1, playlist2]) self.send_request('listplaylist "a [2]"') + self.assertInResponse('file: c') self.assertInResponse('OK') @@ -47,6 +51,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) self.send_request('listplaylistinfo "name"') + self.assertInResponse('file: dummy:a') self.assertNotInResponse('Track: 0') self.assertNotInResponse('Pos: 0') @@ -58,6 +63,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): name='name', uri='dummy:name', tracks=[Track(uri='dummy:a')])]) self.send_request('listplaylistinfo name') + self.assertInResponse('file: dummy:a') self.assertNotInResponse('Track: 0') self.assertNotInResponse('Pos: 0') @@ -65,6 +71,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylistinfo_fails_if_no_playlist_is_found(self): self.send_request('listplaylistinfo "name"') + self.assertEqualResponse( 'ACK [50@0] {listplaylistinfo} No such playlist') @@ -74,6 +81,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.backend.playlists.set_dummy_playlists([playlist1, playlist2]) self.send_request('listplaylistinfo "a [2]"') + self.assertInResponse('file: c') self.assertNotInResponse('Track: 0') self.assertNotInResponse('Pos: 0') @@ -86,6 +94,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): Playlist(name='a', uri='dummy:a')]) self.send_request('listplaylists') + self.assertInResponse('playlist: a') # Date without milliseconds and with time zone information self.assertInResponse('Last-Modified: 2015-08-05T22:51:06Z') @@ -97,6 +106,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.backend.playlists.set_dummy_playlists([playlist1, playlist2]) self.send_request('listplaylists') + self.assertInResponse('playlist: a') self.assertInResponse('playlist: a [2]') self.assertInResponse('OK') @@ -107,13 +117,16 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): Playlist(name='', uri='dummy:', last_modified=last_modified)]) self.send_request('listplaylists') + self.assertNotInResponse('playlist: ') self.assertInResponse('OK') def test_listplaylists_replaces_newline_with_space(self): self.backend.playlists.set_dummy_playlists([ Playlist(name='a\n', uri='dummy:')]) + self.send_request('listplaylists') + self.assertInResponse('playlist: a ') self.assertNotInResponse('playlist: a\n') self.assertInResponse('OK') @@ -121,7 +134,9 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists_replaces_carriage_return_with_space(self): self.backend.playlists.set_dummy_playlists([ Playlist(name='a\r', uri='dummy:')]) + self.send_request('listplaylists') + self.assertInResponse('playlist: a ') self.assertNotInResponse('playlist: a\r') self.assertInResponse('OK') @@ -129,7 +144,9 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists_replaces_forward_slash_with_pipe(self): self.backend.playlists.set_dummy_playlists([ Playlist(name='a/b', uri='dummy:')]) + self.send_request('listplaylists') + self.assertInResponse('playlist: a|b') self.assertNotInResponse('playlist: a/b') self.assertInResponse('OK') @@ -211,33 +228,218 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_load_unknown_playlist_acks(self): self.send_request('load "unknown playlist"') + self.assertEqual(0, len(self.core.tracklist.tracks.get())) self.assertEqualResponse('ACK [50@0] {load} No such playlist') + # No invalid name check for load. + self.send_request('load "unknown/playlist"') + self.assertEqualResponse('ACK [50@0] {load} No such playlist') + def test_playlistadd(self): + tracks = [ + Track(uri='dummy:a'), + Track(uri='dummy:b'), + ] + self.backend.library.dummy_library = tracks + self.backend.playlists.set_dummy_playlists([ + Playlist( + name='name', uri='dummy:a1', tracks=[tracks[0]])]) + + self.send_request('playlistadd "name" "dummy:b"') + + self.assertInResponse('OK') + self.assertEqual( + 2, len(self.backend.playlists.get_items('dummy:a1').get())) + + def test_playlistadd_creates_playlist(self): + tracks = [ + Track(uri='dummy:a'), + ] + self.backend.library.dummy_library = tracks + self.send_request('playlistadd "name" "dummy:a"') - self.assertEqualResponse('ACK [0@0] {playlistadd} Not implemented') + + self.assertInResponse('OK') + self.assertIsNotNone(self.backend.playlists.lookup('dummy:name').get()) + + def test_playlistadd_invalid_name_acks(self): + self.send_request('playlistadd "foo/bar" "dummy:a"') + self.assertInResponse('ACK [2@0] {playlistadd} playlist name is ' + 'invalid: playlist names may not contain ' + 'slashes, newlines or carriage returns') def test_playlistclear(self): + self.backend.playlists.set_dummy_playlists([ + Playlist( + name='name', uri='dummy:a1', tracks=[Track(uri='b')])]) + self.send_request('playlistclear "name"') - self.assertEqualResponse('ACK [0@0] {playlistclear} Not implemented') + + self.assertInResponse('OK') + self.assertEqual( + 0, len(self.backend.playlists.get_items('dummy:a1').get())) + + def test_playlistclear_creates_playlist(self): + self.send_request('playlistclear "name"') + + self.assertInResponse('OK') + self.assertIsNotNone(self.backend.playlists.lookup('dummy:name').get()) + + def test_playlistclear_invalid_name_acks(self): + self.send_request('playlistclear "foo/bar"') + self.assertInResponse('ACK [2@0] {playlistclear} playlist name is ' + 'invalid: playlist names may not contain ' + 'slashes, newlines or carriage returns') def test_playlistdelete(self): - self.send_request('playlistdelete "name" "5"') - self.assertEqualResponse('ACK [0@0] {playlistdelete} Not implemented') + tracks = [ + Track(uri='dummy:a'), + Track(uri='dummy:b'), + Track(uri='dummy:c'), + ] # len() == 3 + self.backend.playlists.set_dummy_playlists([ + Playlist( + name='name', uri='dummy:a1', tracks=tracks)]) + + self.send_request('playlistdelete "name" "2"') + + self.assertInResponse('OK') + self.assertEqual( + 2, len(self.backend.playlists.get_items('dummy:a1').get())) + + def test_playlistdelete_invalid_name_acks(self): + self.send_request('playlistdelete "foo/bar" "0"') + self.assertInResponse('ACK [2@0] {playlistdelete} playlist name is ' + 'invalid: playlist names may not contain ' + 'slashes, newlines or carriage returns') + + def test_playlistdelete_unknown_playlist_acks(self): + self.send_request('playlistdelete "foobar" "0"') + self.assertInResponse('ACK [50@0] {playlistdelete} No such playlist') + + def test_playlistdelete_unknown_index_acks(self): + self.send_request('save "foobar"') + self.send_request('playlistdelete "foobar" "0"') + self.assertInResponse('ACK [2@0] {playlistdelete} Bad song index') def test_playlistmove(self): - self.send_request('playlistmove "name" "5" "10"') - self.assertEqualResponse('ACK [0@0] {playlistmove} Not implemented') + tracks = [ + Track(uri='dummy:a'), + Track(uri='dummy:b'), + Track(uri='dummy:c') # this one is getting moved to top + ] + self.backend.playlists.set_dummy_playlists([ + Playlist( + name='name', uri='dummy:a1', tracks=tracks)]) + + self.send_request('playlistmove "name" "2" "0"') + + self.assertInResponse('OK') + self.assertEqual( + "dummy:c", + self.backend.playlists.get_items('dummy:a1').get()[0].uri) + + def test_playlistmove_invalid_name_acks(self): + self.send_request('playlistmove "foo/bar" "0" "1"') + self.assertInResponse('ACK [2@0] {playlistmove} playlist name is ' + 'invalid: playlist names may not contain ' + 'slashes, newlines or carriage returns') + + def test_playlistmove_unknown_playlist_acks(self): + self.send_request('playlistmove "foobar" "0" "1"') + self.assertInResponse('ACK [50@0] {playlistmove} No such playlist') + + def test_playlistmove_unknown_position_acks(self): + self.send_request('save "foobar"') + self.send_request('playlistmove "foobar" "0" "1"') + self.assertInResponse('ACK [2@0] {playlistmove} Bad song index') + + def test_playlistmove_same_index_shortcircuits_everything(self): + # Bad indexes on unknown playlist: + self.send_request('playlistmove "foobar" "0" "0"') + self.assertInResponse('OK') + + self.send_request('playlistmove "foobar" "100000" "100000"') + self.assertInResponse('OK') + + # Bad indexes on known playlist: + self.send_request('save "foobar"') + + self.send_request('playlistmove "foobar" "0" "0"') + self.assertInResponse('OK') + + self.send_request('playlistmove "foobar" "10" "10"') + self.assertInResponse('OK') + + # Invalid playlist name: + self.send_request('playlistmove "foo/bar" "0" "0"') + self.assertInResponse('OK') def test_rename(self): + self.backend.playlists.set_dummy_playlists([ + Playlist( + name='old_name', uri='dummy:a1', tracks=[Track(uri='b')])]) + self.send_request('rename "old_name" "new_name"') - self.assertEqualResponse('ACK [0@0] {rename} Not implemented') + + self.assertInResponse('OK') + self.assertIsNotNone( + self.backend.playlists.lookup('dummy:new_name').get()) + + def test_rename_unknown_playlist_acks(self): + self.send_request('rename "foo" "bar"') + self.assertInResponse('ACK [50@0] {rename} No such playlist') + + def test_rename_to_existing_acks(self): + self.send_request('save "foo"') + self.send_request('save "bar"') + + self.send_request('rename "foo" "bar"') + self.assertInResponse('ACK [56@0] {rename} Playlist already exists') + + def test_rename_invalid_name_acks(self): + expected = ('ACK [2@0] {rename} playlist name is invalid: playlist ' + 'names may not contain slashes, newlines or carriage ' + 'returns') + + self.send_request('rename "foo/bar" "bar"') + self.assertInResponse(expected) + + self.send_request('rename "foo" "foo/bar"') + self.assertInResponse(expected) + + self.send_request('rename "bar/foo" "foo/bar"') + self.assertInResponse(expected) def test_rm(self): + self.backend.playlists.set_dummy_playlists([ + Playlist( + name='name', uri='dummy:a1', tracks=[Track(uri='b')])]) + self.send_request('rm "name"') - self.assertEqualResponse('ACK [0@0] {rm} Not implemented') + + self.assertInResponse('OK') + self.assertIsNone(self.backend.playlists.lookup('dummy:a1').get()) + + def test_rm_unknown_playlist_acks(self): + self.send_request('rm "name"') + self.assertInResponse('ACK [50@0] {rm} No such playlist') + + def test_rm_invalid_name_acks(self): + self.send_request('rm "foo/bar"') + self.assertInResponse('ACK [2@0] {rm} playlist name is invalid: ' + 'playlist names may not contain slashes, ' + 'newlines or carriage returns') def test_save(self): self.send_request('save "name"') - self.assertEqualResponse('ACK [0@0] {save} Not implemented') + + self.assertInResponse('OK') + self.assertIsNotNone(self.backend.playlists.lookup('dummy:name').get()) + + def test_save_invalid_name_acks(self): + self.send_request('save "foo/bar"') + self.assertInResponse('ACK [2@0] {save} playlist name is invalid: ' + 'playlist names may not contain slashes, ' + 'newlines or carriage returns') diff --git a/tests/mpd/test_actor.py b/tests/mpd/test_actor.py new file mode 100644 index 00000000..843e46d3 --- /dev/null +++ b/tests/mpd/test_actor.py @@ -0,0 +1,44 @@ +from __future__ import absolute_import, unicode_literals + +import mock + +import pytest + +from mopidy.mpd import actor + +# NOTE: Should be kept in sync with all events from mopidy.core.listener + + +@pytest.mark.parametrize("event,expected", [ + (['track_playback_paused', 'tl_track', 'time_position'], None), + (['track_playback_resumed', 'tl_track', 'time_position'], None), + (['track_playback_started', 'tl_track'], None), + (['track_playback_ended', 'tl_track', 'time_position'], None), + (['playback_state_changed', 'old_state', 'new_state'], 'player'), + (['tracklist_changed'], 'playlist'), + (['playlists_loaded'], 'stored_playlist'), + (['playlist_changed', 'playlist'], 'stored_playlist'), + (['playlist_deleted', 'uri'], 'stored_playlist'), + (['options_changed'], 'options'), + (['volume_changed', 'volume'], 'mixer'), + (['mute_changed', 'mute'], 'output'), + (['seeked', 'time_position'], 'player'), + (['stream_title_changed', 'title'], 'playlist'), +]) +def test_idle_hooked_up_correctly(event, expected): + config = {'mpd': {'hostname': 'foobar', + 'port': 1234, + 'zeroconf': None, + 'max_connections': None, + 'connection_timeout': None}} + + with mock.patch.object(actor.MpdFrontend, '_setup_server'): + frontend = actor.MpdFrontend(core=mock.Mock(), config=config) + + with mock.patch('mopidy.listener.send') as send_mock: + frontend.on_event(event[0], **{e: None for e in event[1:]}) + + if expected is None: + assert not send_mock.call_args + else: + send_mock.assert_called_once_with(mock.ANY, expected) diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index 76fa9fcb..25b8dd72 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -11,7 +11,7 @@ from mopidy.models import Track from mopidy.mpd import dispatcher from mopidy.mpd.protocol import status -from tests import dummy_backend, dummy_mixer +from tests import dummy_audio, dummy_backend, dummy_mixer PAUSED = PlaybackState.PAUSED @@ -31,12 +31,14 @@ class StatusHandlerTest(unittest.TestCase): } } + self.audio = dummy_audio.create_proxy() self.mixer = dummy_mixer.create_proxy() - self.backend = dummy_backend.create_proxy() + self.backend = dummy_backend.create_proxy(audio=self.audio) with deprecation.ignore(): self.core = core.Core.start( config, + audio=self.audio, mixer=self.mixer, backends=[self.backend]).proxy() @@ -154,21 +156,21 @@ class StatusHandlerTest(unittest.TestCase): def test_status_method_when_playlist_loaded_contains_song(self): self.set_tracklist(Track(uri='dummy:/a')) - self.core.playback.play() + self.core.playback.play().get() result = dict(status.status(self.context)) self.assertIn('song', result) self.assertGreaterEqual(int(result['song']), 0) def test_status_method_when_playlist_loaded_contains_tlid_as_songid(self): self.set_tracklist(Track(uri='dummy:/a')) - self.core.playback.play() + self.core.playback.play().get() result = dict(status.status(self.context)) self.assertIn('songid', result) - self.assertEqual(int(result['songid']), 0) + self.assertEqual(int(result['songid']), 1) def test_status_method_when_playing_contains_time_with_no_length(self): self.set_tracklist(Track(uri='dummy:/a', length=None)) - self.core.playback.play() + self.core.playback.play().get() result = dict(status.status(self.context)) self.assertIn('time', result) (position, total) = result['time'].split(':') @@ -188,7 +190,7 @@ class StatusHandlerTest(unittest.TestCase): def test_status_method_when_playing_contains_elapsed(self): self.set_tracklist(Track(uri='dummy:/a', length=60000)) - self.core.playback.play() + self.core.playback.play().get() self.core.playback.pause() self.core.playback.seek(59123) result = dict(status.status(self.context)) @@ -197,7 +199,7 @@ class StatusHandlerTest(unittest.TestCase): def test_status_method_when_starting_playing_contains_elapsed_zero(self): self.set_tracklist(Track(uri='dummy:/a', length=10000)) - self.core.playback.play() + self.core.playback.play().get() self.core.playback.pause() result = dict(status.status(self.context)) self.assertIn('elapsed', result) @@ -205,7 +207,7 @@ class StatusHandlerTest(unittest.TestCase): def test_status_method_when_playing_contains_bitrate(self): self.set_tracklist(Track(uri='dummy:/a', bitrate=3200)) - self.core.playback.play() + self.core.playback.play().get() result = dict(status.status(self.context)) self.assertIn('bitrate', result) self.assertEqual(int(result['bitrate']), 3200) diff --git a/tests/stream/test_library.py b/tests/stream/test_library.py index 67053924..29348a6c 100644 --- a/tests/stream/test_library.py +++ b/tests/stream/test_library.py @@ -4,7 +4,6 @@ import mock import pytest -from mopidy.audio import scan from mopidy.internal import path from mopidy.models import Track from mopidy.stream import actor @@ -13,16 +12,23 @@ from tests import path_to_data_dir @pytest.fixture -def scanner(): - return scan.Scanner(timeout=100, proxy_config={}) +def config(): + return { + 'proxy': {}, + 'stream': { + 'timeout': 1000, + 'metadata_blacklist': [], + 'protocols': ['file'], + }, + 'file': { + 'enabled': False + }, + } @pytest.fixture -def backend(scanner): - backend = mock.Mock() - backend.uri_schemes = ['file'] - backend._scanner = scanner - return backend +def audio(): + return mock.Mock() @pytest.fixture @@ -30,26 +36,28 @@ def track_uri(): return path.path_to_uri(path_to_data_dir('song1.wav')) -def test_lookup_ignores_unknown_scheme(backend): - library = actor.StreamLibraryProvider(backend, []) - - assert library.lookup('http://example.com') == [] +def test_lookup_ignores_unknown_scheme(audio, config): + backend = actor.StreamBackend(audio=audio, config=config) + assert backend.library.lookup('http://example.com') == [] -def test_lookup_respects_blacklist(backend, track_uri): - library = actor.StreamLibraryProvider(backend, [track_uri]) +def test_lookup_respects_blacklist(audio, config, track_uri): + config['stream']['metadata_blacklist'].append(track_uri) + backend = actor.StreamBackend(audio=audio, config=config) - assert library.lookup(track_uri) == [Track(uri=track_uri)] + assert backend.library.lookup(track_uri) == [Track(uri=track_uri)] -def test_lookup_respects_blacklist_globbing(backend, track_uri): - blacklist = [path.path_to_uri(path_to_data_dir('')) + '*'] - library = actor.StreamLibraryProvider(backend, blacklist) +def test_lookup_respects_blacklist_globbing(audio, config, track_uri): + blacklist_glob = path.path_to_uri(path_to_data_dir('')) + '*' + config['stream']['metadata_blacklist'].append(blacklist_glob) + backend = actor.StreamBackend(audio=audio, config=config) - assert library.lookup(track_uri) == [Track(uri=track_uri)] + assert backend.library.lookup(track_uri) == [Track(uri=track_uri)] -def test_lookup_converts_uri_metadata_to_track(backend, track_uri): - library = actor.StreamLibraryProvider(backend, []) +def test_lookup_converts_uri_metadata_to_track(audio, config, track_uri): + backend = actor.StreamBackend(audio=audio, config=config) - assert library.lookup(track_uri) == [Track(length=4406, uri=track_uri)] + result = backend.library.lookup(track_uri) + assert result == [Track(length=4406, uri=track_uri)] diff --git a/tests/stream/test_playback.py b/tests/stream/test_playback.py index ef7da0bf..1816f73e 100644 --- a/tests/stream/test_playback.py +++ b/tests/stream/test_playback.py @@ -4,6 +4,8 @@ import mock import pytest +import requests.exceptions + import responses from mopidy import exceptions @@ -27,6 +29,11 @@ def config(): 'proxy': {}, 'stream': { 'timeout': TIMEOUT, + 'metadata_blacklist': [], + 'protocols': ['http'], + }, + 'file': { + 'enabled': False }, } @@ -36,24 +43,21 @@ def audio(): return mock.Mock() -@pytest.fixture +@pytest.yield_fixture def scanner(): - scan_mock = mock.Mock(spec=scan.Scanner) - scan_mock.scan.return_value = None - return scan_mock + patcher = mock.patch.object(scan, 'Scanner') + yield patcher.start()() + patcher.stop() @pytest.fixture -def backend(scanner): - backend = mock.Mock() - backend.uri_schemes = ['file'] - backend._scanner = scanner - return backend +def backend(audio, config, scanner): + return actor.StreamBackend(audio=audio, config=config) @pytest.fixture -def provider(audio, backend, config): - return actor.StreamPlaybackProvider(audio, backend, config) +def provider(backend): + return backend.playback class TestTranslateURI(object): @@ -184,14 +188,24 @@ class TestTranslateURI(object): % STREAM_URI in caplog.text()) assert result == STREAM_URI - def test_failed_download_returns_none(self, provider, caplog): - with mock.patch.object(actor, 'http') as http_mock: - http_mock.download.return_value = None + @responses.activate + def test_failed_download_returns_none(self, scanner, provider, caplog): + scanner.scan.side_effect = [ + mock.Mock(mime='text/foo', playable=False) + ] - result = provider.translate_uri(PLAYLIST_URI) + responses.add( + responses.GET, PLAYLIST_URI, + body=requests.exceptions.HTTPError('Kaboom')) + + result = provider.translate_uri(PLAYLIST_URI) assert result is None + assert ( + 'Unwrapping stream from URI (%s) failed: ' + 'error downloading URI' % PLAYLIST_URI) in caplog.text() + @responses.activate def test_playlist_references_itself(self, scanner, provider, caplog): scanner.scan.side_effect = [ diff --git a/tox.ini b/tox.ini index ecc358ac..da6bcc38 100644 --- a/tox.ini +++ b/tox.ini @@ -40,3 +40,8 @@ deps = flake8-import-order pep8-naming commands = flake8 --show-source --statistics mopidy tests + +[testenv:linkcheck] +deps = -r{toxinidir}/docs/requirements.txt +changedir = docs +commands = sphinx-build -b linkcheck -d {envtmpdir}/doctrees . {envtmpdir}/html