diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..e77617cb --- /dev/null +++ b/.coveragerc @@ -0,0 +1,5 @@ +[report] +omit = + */pyshared/* + */python?.?/* + */site-packages/nose/* diff --git a/.travis.yml b/.travis.yml index 0b68eb8f..b793e530 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,14 +5,17 @@ install: - "sudo wget -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list" - "sudo apt-get update || true" - "sudo apt-get install $(apt-cache depends mopidy | awk '$2 !~ /mopidy/ {print $2}')" - - "pip install flake8" + - "pip install coveralls flake8" before_script: - "rm $VIRTUAL_ENV/lib/python$TRAVIS_PYTHON_VERSION/no-global-site-packages.txt" script: - "flake8 $(find . -iname '*.py')" - - "nosetests" + - "nosetests --with-coverage --cover-package=mopidy" + +after_success: + - "coveralls" notifications: irc: diff --git a/AUTHORS b/AUTHORS index 052865b7..28b8ebd2 100644 --- a/AUTHORS +++ b/AUTHORS @@ -24,3 +24,5 @@ - Alli Witheford - Alexandre Petitjean - Pavol Babincak +- Javier Domingo +- Lasse Bigum diff --git a/MANIFEST.in b/MANIFEST.in index 6385e4ff..84122dcc 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,7 +2,6 @@ include *.rst include LICENSE include MANIFEST.in include data/mopidy.desktop -include mopidy/backends/spotify/spotify_appkey.key include pylintrc recursive-include docs * diff --git a/README.rst b/README.rst index c9db495e..515fa3ba 100644 --- a/README.rst +++ b/README.rst @@ -25,5 +25,18 @@ To get started with Mopidy, check out `the docs `_. - Mailing list: `mopidy@googlegroups.com `_ - Twitter: `@mopidy `_ +.. image:: https://pypip.in/v/Mopidy/badge.png + :target: https://crate.io/packages/Mopidy/ + :alt: Latest PyPI version + +.. image:: https://pypip.in/d/Mopidy/badge.png + :target: https://crate.io/packages/Mopidy/ + :alt: Number of PyPI downloads + .. image:: https://travis-ci.org/mopidy/mopidy.png?branch=develop :target: https://travis-ci.org/mopidy/mopidy + :alt: Travis CI build status + +.. image:: https://coveralls.io/repos/mopidy/mopidy/badge.png?branch=develop + :target: https://coveralls.io/r/mopidy/mopidy?branch=develop + :alt: Test coverage diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index 6da5d337..70bd73cf 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -49,5 +49,3 @@ Frontend implementations * :mod:`mopidy.frontends.http` * :mod:`mopidy.frontends.mpd` -* :mod:`mopidy.frontends.mpris` -* :mod:`mopidy.frontends.scrobbler` diff --git a/docs/changelog.rst b/docs/changelog.rst index 9aeadbd1..5bcb29fb 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,59 @@ Changelog This changelog is used to track all major changes to Mopidy. +v0.16.0 (UNRELEASED) +==================== + +**Dependencies** + +Parts of Mopidy have been moved to their own external extensions. If you want +Mopidy to continue to work like it used to, you may have to install one or more +of the following extensions as well: + +- The Spotify backend has been moved to + `Mopidy-Scrobbler `_. + +- The Last.fm scrobbler has been moved to + `Mopidy-Scrobbler `_. + +- The MPRIS frontend has been moved to + `Mopidy-MPRIS `_. + +**Audio** + +- Added support for parsing and playback of playlists in GStreamer. For end + users this basically means that you can now add a radio playlist to Mopidy + and we will automatically download it and play the stream inside it. + Currently we support M3U, PLS, XSPF and ASX files. Also note that we can + currently only play the first stream in the playlist. + +- We now handle the rare case where an audio track has max volume equal to min. + This was causing divide by zero errors when scaling volumes to a zero to + hundred scale. (Fixes: :issue:`525`) + +- Added support for muting audio without setting the volume to 0. This works + both for the software and hardware mixers. (Fixes: :issue:`186`) + +**Core** + +- Added :attr:`mopidy.core.PlaybackController.mute` for muting and unmuting + audio. (Fixes: :issue:`186`) + +- Added :meth:`mopidy.core.CoreListener.mute_changed` event that is triggered + when the mute state changes. + +**MPD frontend** + +- Made the formerly unused commands ``outputs``, ``enableoutput``, and + ``disableoutput`` mute/unmute audio. (Related to: :issue:`186`) + +**Extension support** + +- A cookiecutter project for quickly creating new Mopidy extensions have been + created. You can find it at `cookiecutter-mopidy-ext + `_. (Fixes: :issue:`522`) + + v0.15.0 (2013-09-19) ==================== diff --git a/docs/clients/mpris.rst b/docs/clients/mpris.rst index 141a2371..e1bd4bff 100644 --- a/docs/clients/mpris.rst +++ b/docs/clients/mpris.rst @@ -8,7 +8,8 @@ MPRIS clients Specification. It's a spec that describes a standard D-Bus interface for making media players available to other applications on the same system. -Mopidy's :ref:`MPRIS frontend ` currently implements all required +The MPRIS frontend provided by the `Mopidy-MPRIS extension +`_ currently implements all required parts of the MPRIS spec, plus the optional playlist interface. It does not implement the optional tracklist interface. diff --git a/docs/clients/upnp.rst b/docs/clients/upnp.rst index 9f30bd1c..7f21a6c6 100644 --- a/docs/clients/upnp.rst +++ b/docs/clients/upnp.rst @@ -36,19 +36,21 @@ How to make Mopidy available as an UPnP MediaRenderer ===================================================== With the help of `the Rygel project `_ Mopidy can -be made available as an UPnP MediaRenderer. Rygel will interface with Mopidy's -:ref:`MPRIS frontend `, and make Mopidy available as a MediaRenderer -on the local network. Since this depends on the MPRIS frontend, which again -depends on D-Bus being available, this will only work on Linux, and not OS X. -MPRIS/D-Bus is only available to other applications on the same host, so Rygel -must be running on the same machine as Mopidy. +be made available as an UPnP MediaRenderer. Rygel will interface with the MPRIS +interface provided by the `Mopidy-MPRIS extension +`_, and make Mopidy available as a +MediaRenderer on the local network. Since this depends on the MPRIS frontend, +which again depends on D-Bus being available, this will only work on Linux, and +not OS X. MPRIS/D-Bus is only available to other applications on the same +host, so Rygel must be running on the same machine as Mopidy. -1. Start Mopidy and make sure the :ref:`MPRIS frontend ` is working. - It is activated by default, but you may miss dependencies or be using OS X, - in which case it will not work. Check the console output when Mopidy is - started for any errors related to the MPRIS frontend. If you're unsure it is - working, there are instructions for how to test it on the :ref:`MPRIS - frontend ` page. +1. Start Mopidy and make sure the MPRIS frontend is working. It is activated + by default when the Mopidy-MPRIS extension is installed, but you may miss + dependencies or be using OS X, in which case it will not work. Check the + console output when Mopidy is started for any errors related to the MPRIS + frontend. If you're unsure it is working, there are instructions for how to + test it on in the `Mopidy-MPRIS readme + `_. 2. Install Rygel. On Debian/Ubuntu:: diff --git a/docs/conf.py b/docs/conf.py index f3e4166c..56ddbf92 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,6 +35,8 @@ class Mock(object): elif (name[0] == name[0].upper() # gst.interfaces.MIXER_TRACK_* and not name.startswith('MIXER_TRACK_') + # gst.PadTemplate + and not name.startswith('PadTemplate') # dbus.String() and not name == 'String'): return type(name, (), {}) diff --git a/docs/ext/index.rst b/docs/ext/index.rst index 736f2fb6..a4f376b2 100644 --- a/docs/ext/index.rst +++ b/docs/ext/index.rst @@ -30,96 +30,99 @@ These extensions are maintained outside Mopidy's core, often by other developers. +Mopidy-Arcam +------------ + +https://github.com/mopidy/mopidy-arcam + +Extension for controlling volume using an external Arcam amplifier. Developed +and tested with an Arcam AVR-300. + + Mopidy-Beets ------------ +https://github.com/mopidy/mopidy-beets + Provides a backend for playing music from your `Beets `_ music library through Beets' web extension. -Author: - Janez Troha -PyPI: - `Mopidy-Beets `_ -GitHub: - `dz0ny/mopidy-beets `_ -Issues: - https://github.com/dz0ny/mopidy-beets/issues - Mopidy-GMusic ------------- +https://github.com/hechtus/mopidy-gmusic + Provides a backend for playing music from `Google Play Music `_. -Author: - Ronald Hecht -PyPI: - `Mopidy-GMusic `_ -GitHub: - `hechtus/mopidy-gmusic `_ -Issues: - https://github.com/hechtus/mopidy-gmusic/issues + +Mopidy-MPRIS +------------ + +https://github.com/mopidy/mopidy-mpris + +Extension for controlling Mopidy through the `MPRIS `_ +D-Bus interface, for example using the Ubuntu Sound Menu. Mopidy-NAD ---------- +https://github.com/mopidy/mopidy-nad + Extension for controlling volume using an external NAD amplifier. -Author: - Stein Magnus Jodal -PyPI: - `Mopidy-NAD `_ -GitHub: - `mopidy/mopidy-nad `_ -Issues: - https://github.com/mopidy/mopidy/issues + +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.fr `_, and `radio.at `_. + + +Mopidy-Scrobbler +---------------- + +https://github.com/mopidy/mopidy-scrobbler + +Extension for scrobbling played tracks to Last.fm. Mopidy-SomaFM ------------- +https://github.com/AlexandrePTJ/mopidy-somafm + Provides a backend for playing music from the `SomaFM `_ service. -Author: - Alexandre Petitjean -PyPI: - `Mopidy-SomaFM `_ -GitHub: - `AlexandrePTJ/mopidy-somafm `_ -Issues: - https://github.com/AlexandrePTJ/mopidy-somafm/issues - Mopidy-SoundCloud ----------------- -Provides a backend for playing music from the `SoundCloud +https://github.com/mopidy/mopidy-soundcloud + +rovides a backend for playing music from the `SoundCloud `_ service. -Author: - Janez Troha -PyPI: - `Mopidy-SoundCloud `_ -GitHub: - `dz0ny/mopidy-soundcloud `_ -Issues: - https://github.com/dz0ny/mopidy-soundcloud/issues + +Mopidy-Spotify +-------------- + +https://github.com/mopidy/mopidy-spotify + +Extension for playing music from the `Spotify `_ music +streaming service. Mopidy-Subsonic --------------- +https://github.com/rattboi/mopidy-subsonic + Provides a backend for playing music from a `Subsonic Music Streamer `_ library. - -Author: - Bradon Kanyid -PyPI: - `Mopidy-Subsonic `_ -GitHub: - `rattboi/mopidy-subsonic `_ -Issues: - https://github.com/rattboi/mopidy-subsonic/issues diff --git a/docs/ext/mpris.rst b/docs/ext/mpris.rst deleted file mode 100644 index 125f8fec..00000000 --- a/docs/ext/mpris.rst +++ /dev/null @@ -1,105 +0,0 @@ -.. _ext-mpris: - -************ -Mopidy-MPRIS -************ - -This extension lets you control Mopidy through the Media Player Remote -Interfacing Specification (`MPRIS `_) D-Bus interface. - -An example of an MPRIS client is the :ref:`ubuntu-sound-menu`. - - -Dependencies -============ - -- D-Bus Python bindings. The package is named ``python-dbus`` in - Ubuntu/Debian. - -- ``libindicate`` Python bindings is needed to expose Mopidy in e.g. the - Ubuntu Sound Menu. The package is named ``python-indicate`` in - Ubuntu/Debian. - -- An ``.desktop`` file for Mopidy installed at the path set in the - :confval:`mpris/desktop_file` config value. See usage section below for - details. - - -Default configuration -===================== - -.. literalinclude:: ../../mopidy/frontends/mpris/ext.conf - :language: ini - - -Configuration values -==================== - -.. confval:: mpris/enabled - - If the MPRIS extension should be enabled or not. - -.. confval:: mpris/desktop_file - - Location of the Mopidy ``.desktop`` file. - - -Usage -===== - -The extension is enabled by default if all dependencies are available. - - -Controlling Mopidy through the Ubuntu Sound Menu ------------------------------------------------- - -If you are running Ubuntu and installed Mopidy using the Debian package from -APT you should be able to control Mopidy through the :ref:`ubuntu-sound-menu` -without any changes. - -If you installed Mopidy in any other way and want to control Mopidy through the -Ubuntu Sound Menu, you must install the ``mopidy.desktop`` file which can be -found in the ``data/`` dir of the Mopidy source repo into the -``/usr/share/applications`` dir by hand:: - - cd /path/to/mopidy/source - sudo cp data/mopidy.desktop /usr/share/applications/ - -If the correct path to the installed ``mopidy.desktop`` file on your system -isn't ``/usr/share/applications/mopidy.conf``, you'll need to set the -:confval:`mpris/desktop_file` config value. - -After you have installed the file, start Mopidy in any way, and Mopidy should -appear in the Ubuntu Sound Menu. When you quit Mopidy, it will still be listed -in the Ubuntu Sound Menu, and may be restarted by selecting it there. - -The Ubuntu Sound Menu interacts with Mopidy's MPRIS frontend. The MPRIS -frontend supports the minimum requirements of the `MPRIS specification -`_. The ``TrackList`` interface of the spec is not -supported. - - -Testing the MPRIS API directly ------------------------------- - -To use the MPRIS API directly, start Mopidy, and then run the following in a -Python shell:: - - import dbus - bus = dbus.SessionBus() - player = bus.get_object('org.mpris.MediaPlayer2.mopidy', - '/org/mpris/MediaPlayer2') - -Now you can control Mopidy through the player object. Examples: - -- To get some properties from Mopidy, run:: - - props = player.GetAll('org.mpris.MediaPlayer2', - dbus_interface='org.freedesktop.DBus.Properties') - -- To quit Mopidy through D-Bus, run:: - - player.Quit(dbus_interface='org.mpris.MediaPlayer2') - -For details on the API, please refer to the `MPRIS specification -`_. diff --git a/docs/ext/scrobbler.rst b/docs/ext/scrobbler.rst deleted file mode 100644 index 84188d02..00000000 --- a/docs/ext/scrobbler.rst +++ /dev/null @@ -1,55 +0,0 @@ -.. _ext-scrobbler: - -**************** -Mopidy-Scrobbler -**************** - -This extension scrobbles the music you play to your `Last.fm -`_ profile. - -.. note:: - - This extension requires a free user account at Last.fm. - - -Dependencies -============ - -.. literalinclude:: ../../requirements/scrobbler.txt - - -Default configuration -===================== - -.. literalinclude:: ../../mopidy/frontends/scrobbler/ext.conf - :language: ini - - -Configuration values -==================== - -.. confval:: scrobbler/enabled - - If the scrobbler extension should be enabled or not. - -.. confval:: scrobbler/username - - Your Last.fm username. - -.. confval:: scrobbler/password - - Your Last.fm password. - - -Usage -===== - -The extension is enabled by default if all dependencies are available. You just -need to add your Last.fm username and password to the -``~/.config/mopidy/mopidy.conf`` file: - -.. code-block:: ini - - [scrobbler] - username = myusername - password = mysecret diff --git a/docs/ext/spotify.rst b/docs/ext/spotify.rst deleted file mode 100644 index 4bb5b7a3..00000000 --- a/docs/ext/spotify.rst +++ /dev/null @@ -1,83 +0,0 @@ -.. _ext-spotify: - -************** -Mopidy-Spotify -************** - -An extension for playing music from Spotify. - -`Spotify `_ is a music streaming service. The backend -uses the official `libspotify -`_ library and the -`pyspotify `_ Python bindings for -libspotify. This backend handles URIs starting with ``spotify:``. - -.. note:: - - This product uses SPOTIFY(R) CORE but is not endorsed, certified or - otherwise approved in any way by Spotify. Spotify is the registered - trade mark of the Spotify Group. - - -Known issues -============ - -https://github.com/mopidy/mopidy/issues?labels=Spotify+backend - - -Dependencies -============ - -.. literalinclude:: ../../requirements/spotify.txt - - -Default configuration -===================== - -.. literalinclude:: ../../mopidy/backends/spotify/ext.conf - :language: ini - - -Configuration values -==================== - -.. confval:: spotify/enabled - - If the Spotify extension should be enabled or not. - -.. confval:: spotify/username - - Your Spotify Premium username. - -.. confval:: spotify/password - - Your Spotify Premium password. - -.. confval:: spotify/bitrate - - The preferred audio bitrate. Valid values are 96, 160, 320. - -.. confval:: spotify/timeout - - Max number of seconds to wait for Spotify operations to complete. - -.. confval:: spotify/cache_dir - - Path to the Spotify data cache. Cannot be shared with other Spotify apps. - - -Usage -===== - -If you are using the Spotify backend, which is the default, enter your Spotify -Premium account's username and password into ``~/.config/mopidy/mopidy.conf``, -like this: - -.. code-block:: ini - - [spotify] - username = myusername - password = mysecret - -This will only work if you have the Spotify Premium subscription. Spotify -Unlimited will not work. diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 51168312..428751de 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -62,6 +62,20 @@ extension, Mopidy-Soundspot:: Example content for the most important files follows below. +cookiecutter project template +============================= + +We've also made a `cookiecutter `_ +project template for `creating new Mopidy extensions +`_. If you install +cookiecutter and run a single command, you're asked a few questions about the +name of your extension, etc. This is used to create a folder structure similar +to the above, with all the needed files and most of the details filled in for +you. This saves you a lot of tedious work and copy-pasting from this howto. See +the readme of `cookiecutter-mopidy-ext +`_ for further details. + + Example README.rst ================== @@ -73,24 +87,30 @@ installation using ``pip install Mopidy-Something==dev`` to work. .. code-block:: rst + **************** Mopidy-Soundspot - ================ + **************** `Mopidy `_ extension for playing music from `Soundspot `_. - Usage - ----- - Requires a Soundspot Platina subscription and the pysoundspot library. + + Installation + ============ + Install by running:: sudo pip install Mopidy-Soundspot - Or install the Debian/Ubuntu package from `apt.mopidy.com + Or, if available, install the Debian/Ubuntu package from `apt.mopidy.com `_. + + Configuration + ============= + Before starting Mopidy, you must add your Soundspot username and password to the Mopidy configuration file:: @@ -98,34 +118,46 @@ installation using ``pip install Mopidy-Something==dev`` to work. username = alice password = secret + Project resources - ----------------- + ================= - `Source code `_ - `Issue tracker `_ - - `Download development snapshot `_ + - `Download development snapshot `_ + + + Changelog + ========= + + v0.1.0 (2013-09-17) + ------------------- + + - Initial release. Example setup.py ================ -The ``setup.py`` file must use setuptools/distribute, and not distutils. This -is because Mopidy extensions use setuptools' entry point functionality to -register themselves as available Mopidy extensions when they are installed on -your system. +The ``setup.py`` file must use setuptools, and not distutils. This is because +Mopidy extensions use setuptools' entry point functionality to register +themselves as available Mopidy extensions when they are installed on your +system. The example below also includes a couple of convenient tricks for reading the package version from the source code so that it is defined in a single place, and to reuse the README file as the long description of the package for the PyPI registration. -The package must have ``install_requires`` on ``setuptools`` and ``Mopidy``, in -addition to any other dependencies required by your extension. The -``entry_points`` part must be included. The ``mopidy.ext`` part cannot be -changed, but the innermost string should be changed. It's format is -``ext_name = package_name:Extension``. ``ext_name`` should be a short -name for your extension, typically the part after "Mopidy-" in lowercase. This -name is used e.g. to name the config section for your extension. The +The package must have ``install_requires`` on ``setuptools`` and ``Mopidy >= +0.14`` (or a newer version, if your extension requires it), in addition to any +other dependencies required by your extension. If you implement a Mopidy +frontend or backend, you'll need to include ``Pykka >= 1.1`` in the +requirements. The ``entry_points`` part must be included. The ``mopidy.ext`` +part cannot be changed, but the innermost string should be changed. It's format +is ``ext_name = package_name:Extension``. ``ext_name`` should be a short name +for your extension, typically the part after "Mopidy-" in lowercase. This name +is used e.g. to name the config section for your extension. The ``package_name:Extension`` part is simply the Python path to the extension class that will connect the rest of the dots. @@ -134,7 +166,7 @@ class that will connect the rest of the dots. from __future__ import unicode_literals import re - from setuptools import setup + from setuptools import setup, find_packages def get_version(filename): @@ -146,20 +178,26 @@ class that will connect the rest of the dots. setup( name='Mopidy-Soundspot', version=get_version('mopidy_soundspot/__init__.py'), - url='http://example.com/mopidy-soundspot/', + url='https://github.com/your-account/mopidy-soundspot', license='Apache License, Version 2.0', author='Your Name', author_email='your-email@example.com', description='Very short description', long_description=open('README.rst').read(), - packages=['mopidy_soundspot'], + packages=find_packages(exclude=['tests', 'tests.*']), zip_safe=False, include_package_data=True, install_requires=[ 'setuptools', - 'Mopidy', + 'Mopidy >= 0.14', + 'Pykka >= 1.1', 'pysoundspot', ], + test_suite='nose.collector', + tests_require=[ + 'nose', + 'mock >= 1.0', + ], entry_points={ 'mopidy.ext': [ 'soundspot = mopidy_soundspot:Extension', diff --git a/docs/glossary.rst b/docs/glossary.rst index 2aa63887..2acb9981 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -24,10 +24,9 @@ Glossary frontend A part of Mopidy *using* the :term:`core` API. Existing frontends - include the :ref:`MPD server `, the :ref:`MPRIS/D-Bus - integration `, the :ref:`Last.fm scrobbler `, - and the :ref:`HTTP server ` with JavaScript API. See - :ref:`frontend-api` for details. + include the :ref:`MPD server `, the MPRIS/D-Bus integration, + the Last.fm scrobbler, and the :ref:`HTTP server ` with + JavaScript API. See :ref:`frontend-api` for details. mixer A GStreamer element that controls audio volume. diff --git a/docs/index.rst b/docs/index.rst index ca40c96c..c5183471 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,9 +4,9 @@ Mopidy Mopidy is a music server which can play music both from multiple sources, like your :ref:`local hard drive `, :ref:`radio streams `, -and from :ref:`Spotify ` and SoundCloud. Searches combines results -from all music sources, and you can mix tracks from all sources in your play -queue. Your playlists from Spotify or SoundCloud are also available for use. +and from Spotify and SoundCloud. Searches combines results from all music +sources, and you can mix tracks from all sources in your play queue. Your +playlists from Spotify or SoundCloud are also available for use. To control your Mopidy music server, you can use one of Mopidy's :ref:`web clients `, the :ref:`Ubuntu Sound Menu `, any diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 85e07c9d..238184f4 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -87,8 +87,9 @@ Mopidy Git repo, which always corresponds to the latest release. To upgrade Mopidy to future releases, just rerun ``makepkg``. #. Optional: If you want to scrobble your played tracks to Last.fm, you need to - install `python2-pylast - `_ from AUR. + install `python2-pylast`:: + + sudo pacman -S python2-pylast #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. @@ -250,8 +251,8 @@ can install Mopidy from PyPI using Pip. sudo pip-python install -U cherrypy ws4py -#. Optional: To use MPRIS, e.g. for controlling Mopidy from the Ubuntu Sound - Menu or from an UPnP client via Rygel, you need some additional +#. Optional: To use Mopidy-MPRIS, e.g. for controlling Mopidy from the Ubuntu + Sound Menu or from an UPnP client via Rygel, you need some additional dependencies: the Python bindings for libindicate, and the Python bindings for libdbus, the reference D-Bus library. diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 6ef80b0f..8ba54f4e 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -21,4 +21,4 @@ if (isinstance(pykka.__version__, basestring) warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.15.0' +__version__ = '0.16.0a1' diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 6a1d7f6b..5c931865 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -11,7 +11,7 @@ import pykka from mopidy.utils import process -from . import mixers, utils +from . import mixers, playlists, utils from .constants import PlaybackState from .listener import AudioListener @@ -19,6 +19,9 @@ logger = logging.getLogger('mopidy.audio') mixers.register_mixers() +playlists.register_typefinders() +playlists.register_elements() + MB = 1 << 20 @@ -541,9 +544,42 @@ class Audio(pykka.ThreadingActor): """Convert value between scales.""" new_min, new_max = new old_min, old_max = old + if old_min == old_max: + return old_max scaling = float(new_max - new_min) / (old_max - old_min) return int(round(scaling * (value - old_min) + new_min)) + def get_mute(self): + """ + Get mute status of the installed mixer. + + :rtype: :class:`True` if muted, :class:`False` if unmuted, + :class:`None` if no mixer is installed. + """ + if self._software_mixing: + return self._playbin.get_property('mute') + + if self._mixer_track is None: + return None + + return bool(self._mixer_track.flags & gst.interfaces.MIXER_TRACK_MUTE) + + def set_mute(self, mute): + """ + Mute or unmute of the installed mixer. + + :param mute: Wether to mute the mixer or not. + :type mute: bool + :rtype: :class:`True` if successful, else :class:`False` + """ + if self._software_mixing: + return self._playbin.set_property('mute', bool(mute)) + + if self._mixer_track is None: + return False + + return self._mixer.set_mute(self._mixer_track, bool(mute)) + def set_metadata(self, track): """ Set track metadata for currently playing song. diff --git a/mopidy/audio/playlists.py b/mopidy/audio/playlists.py new file mode 100644 index 00000000..e3f51e41 --- /dev/null +++ b/mopidy/audio/playlists.py @@ -0,0 +1,412 @@ +from __future__ import unicode_literals + +import pygst +pygst.require('0.10') +import gst +import gobject + +import ConfigParser as configparser +import io + +try: + import xml.etree.cElementTree as elementtree +except ImportError: + import xml.etree.ElementTree as elementtree + + +# TODO: make detect_FOO_header reusable in general mopidy code. +# i.e. give it just a "peek" like function. +def detect_m3u_header(typefind): + return typefind.peek(0, 8) == b'#EXTM3U\n' + + +def detect_pls_header(typefind): + return typefind.peek(0, 11).lower() == b'[playlist]\n' + + +def detect_xspf_header(typefind): + data = typefind.peek(0, 150) + if b'xspf' not in data: + return False + + try: + data = io.BytesIO(data) + for event, element in elementtree.iterparse(data, events=(b'start',)): + return element.tag.lower() == '{http://xspf.org/ns/0/}playlist' + except elementtree.ParseError: + pass + return False + + +def detect_asx_header(typefind): + data = typefind.peek(0, 50) + if b'asx' not in data: + return False + + try: + data = io.BytesIO(data) + for event, element in elementtree.iterparse(data, events=(b'start',)): + return element.tag.lower() == 'asx' + except elementtree.ParseError: + pass + return False + + +def parse_m3u(data): + # TODO: convert non URIs to file URIs. + found_header = False + for line in data.readlines(): + if found_header or line.startswith('#EXTM3U'): + found_header = True + else: + continue + if not line.startswith('#') and line.strip(): + yield line.strip() + + +def parse_pls(data): + # TODO: convert non URIs to file URIs. + try: + cp = configparser.RawConfigParser() + cp.readfp(data) + except configparser.Error: + return + + for section in cp.sections(): + if section.lower() != 'playlist': + continue + for i in xrange(cp.getint(section, 'numberofentries')): + yield cp.get(section, 'file%d' % (i+1)) + + +def parse_xspf(data): + try: + for event, element in elementtree.iterparse(data): + element.tag = element.tag.lower() # normalize + except elementtree.ParseError: + return + + ns = 'http://xspf.org/ns/0/' + for track in element.iterfind('{%s}tracklist/{%s}track' % (ns, ns)): + yield track.findtext('{%s}location' % ns) + + +def parse_asx(data): + try: + for event, element in elementtree.iterparse(data): + element.tag = element.tag.lower() # normalize + except elementtree.ParseError: + return + + for ref in element.findall('entry/ref'): + yield ref.get('href', '').strip() + + +def parse_urilist(data): + for line in data.readlines(): + if not line.startswith('#') and gst.uri_is_valid(line.strip()): + yield line + + +def playlist_typefinder(typefind, func, caps): + if func(typefind): + typefind.suggest(gst.TYPE_FIND_MAXIMUM, caps) + + +def register_typefind(mimetype, func, extensions): + caps = gst.caps_from_string(mimetype) + gst.type_find_register(mimetype, gst.RANK_PRIMARY, playlist_typefinder, + extensions, caps, func, caps) + + +def register_typefinders(): + register_typefind('audio/x-mpegurl', detect_m3u_header, [b'm3u', b'm3u8']) + register_typefind('audio/x-scpls', detect_pls_header, [b'pls']) + register_typefind('application/xspf+xml', detect_xspf_header, [b'xspf']) + # NOTE: seems we can't use video/x-ms-asf which is the correct mime for asx + # as it is shared with asf for streaming videos :/ + register_typefind('audio/x-ms-asx', detect_asx_header, [b'asx']) + + +class BasePlaylistElement(gst.Bin): + """Base class for creating GStreamer elements for playlist support. + + This element performs the following steps: + + 1. Initializes src and sink pads for the element. + 2. Collects data from the sink until EOS is reached. + 3. Passes the collected data to :meth:`convert` to get a list of URIs. + 4. Passes the list of URIs to :meth:`handle`, default handling is to pass + the URIs to the src element as a uri-list. + 5. If handle returned true, the EOS consumed and nothing more happens, if + it is not consumed it flows on to the next element downstream, which is + likely our uri-list consumer which needs the EOS to know we are done + sending URIs. + """ + + sinkpad_template = None + """GStreamer pad template to use for sink, must be overriden.""" + + srcpad_template = None + """GStreamer pad template to use for src, must be overriden.""" + + ghost_srcpad = False + """Indicates if src pad should be ghosted or not.""" + + def __init__(self): + """Sets up src and sink pads plus behaviour.""" + super(BasePlaylistElement, self).__init__() + self._data = io.BytesIO() + self._done = False + + self.sinkpad = gst.Pad(self.sinkpad_template) + self.sinkpad.set_chain_function(self._chain) + self.sinkpad.set_event_function(self._event) + self.add_pad(self.sinkpad) + + if self.ghost_srcpad: + self.srcpad = gst.ghost_pad_new_notarget('src', gst.PAD_SRC) + else: + self.srcpad = gst.Pad(self.srcpad_template) + self.add_pad(self.srcpad) + + def convert(self, data): + """Convert the data we have colleted to URIs. + + :param data: collected data buffer + :type data: :class:`io.BytesIO` + :returns: iterable or generator of URIs + """ + raise NotImplementedError + + def handle(self, uris): + """Do something useful with the URIs. + + :param uris: list of URIs + :type uris: :type:`list` + :returns: boolean indicating if EOS should be consumed + """ + # TODO: handle unicode uris which we can get out of elementtree + self.srcpad.push(gst.Buffer('\n'.join(uris))) + return False + + def _chain(self, pad, buf): + if not self._done: + self._data.write(buf.data) + return gst.FLOW_OK + return gst.FLOW_EOS + + def _event(self, pad, event): + if event.type == gst.EVENT_NEWSEGMENT: + return True + + if event.type == gst.EVENT_EOS: + self._done = True + self._data.seek(0) + if self.handle(list(self.convert(self._data))): + return True + + # Ensure we handle remaining events in a sane way. + return pad.event_default(event) + + +class M3uDecoder(BasePlaylistElement): + __gstdetails__ = ('M3U Decoder', + 'Decoder', + 'Convert .m3u to text/uri-list', + 'Mopidy') + + sinkpad_template = gst.PadTemplate( + 'sink', gst.PAD_SINK, gst.PAD_ALWAYS, + gst.caps_from_string('audio/x-mpegurl')) + + srcpad_template = gst.PadTemplate( + 'src', gst.PAD_SRC, gst.PAD_ALWAYS, + gst.caps_from_string('text/uri-list')) + + __gsttemplates__ = (sinkpad_template, srcpad_template) + + def convert(self, data): + return parse_m3u(data) + + +class PlsDecoder(BasePlaylistElement): + __gstdetails__ = ('PLS Decoder', + 'Decoder', + 'Convert .pls to text/uri-list', + 'Mopidy') + + sinkpad_template = gst.PadTemplate( + 'sink', gst.PAD_SINK, gst.PAD_ALWAYS, + gst.caps_from_string('audio/x-scpls')) + + srcpad_template = gst.PadTemplate( + 'src', gst.PAD_SRC, gst.PAD_ALWAYS, + gst.caps_from_string('text/uri-list')) + + __gsttemplates__ = (sinkpad_template, srcpad_template) + + def convert(self, data): + return parse_pls(data) + + +class XspfDecoder(BasePlaylistElement): + __gstdetails__ = ('XSPF Decoder', + 'Decoder', + 'Convert .pls to text/uri-list', + 'Mopidy') + + sinkpad_template = gst.PadTemplate( + 'sink', gst.PAD_SINK, gst.PAD_ALWAYS, + gst.caps_from_string('application/xspf+xml')) + + srcpad_template = gst.PadTemplate( + 'src', gst.PAD_SRC, gst.PAD_ALWAYS, + gst.caps_from_string('text/uri-list')) + + __gsttemplates__ = (sinkpad_template, srcpad_template) + + def convert(self, data): + return parse_xspf(data) + + +class AsxDecoder(BasePlaylistElement): + __gstdetails__ = ('ASX Decoder', + 'Decoder', + 'Convert .asx to text/uri-list', + 'Mopidy') + + sinkpad_template = gst.PadTemplate( + 'sink', gst.PAD_SINK, gst.PAD_ALWAYS, + gst.caps_from_string('audio/x-ms-asx')) + + srcpad_template = gst.PadTemplate( + 'src', gst.PAD_SRC, gst.PAD_ALWAYS, + gst.caps_from_string('text/uri-list')) + + __gsttemplates__ = (sinkpad_template, srcpad_template) + + def convert(self, data): + return parse_asx(data) + + +class UriListElement(BasePlaylistElement): + __gstdetails__ = ('URIListDemuxer', + 'Demuxer', + 'Convert a text/uri-list to a stream', + 'Mopidy') + + sinkpad_template = gst.PadTemplate( + 'sink', gst.PAD_SINK, gst.PAD_ALWAYS, + gst.caps_from_string('text/uri-list')) + + srcpad_template = gst.PadTemplate( + 'src', gst.PAD_SRC, gst.PAD_ALWAYS, + gst.caps_new_any()) + + ghost_srcpad = True # We need to hook this up to our internal decodebin + + __gsttemplates__ = (sinkpad_template, srcpad_template) + + def __init__(self): + super(UriListElement, self).__init__() + self.uridecodebin = gst.element_factory_make('uridecodebin') + self.uridecodebin.connect('pad-added', self.pad_added) + # Limit to anycaps so we get a single stream out, letting other + # elements downstream figure out actual muxing + self.uridecodebin.set_property('caps', gst.caps_new_any()) + + def pad_added(self, src, pad): + self.srcpad.set_target(pad) + pad.add_event_probe(self.pad_event) + + def pad_event(self, pad, event): + if event.has_name('urilist-played'): + error = gst.GError(gst.RESOURCE_ERROR, gst.RESOURCE_ERROR_FAILED, + b'Nested playlists not supported.') + message = b'Playlists pointing to other playlists is not supported' + self.post_message(gst.message_new_error(self, error, message)) + return 1 # GST_PAD_PROBE_OK + + def handle(self, uris): + struct = gst.Structure('urilist-played') + event = gst.event_new_custom(gst.EVENT_CUSTOM_UPSTREAM, struct) + self.sinkpad.push_event(event) + + # TODO: hookup about to finish and errors to rest of URIs so we + # round robin, only giving up once all have been tried. + # TODO: uris could be empty. + self.add(self.uridecodebin) + self.uridecodebin.set_state(gst.STATE_READY) + self.uridecodebin.set_property('uri', uris[0]) + self.uridecodebin.sync_state_with_parent() + return True # Make sure we consume the EOS that triggered us. + + def convert(self, data): + return parse_urilist(data) + + +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_element(element_class): + gobject.type_register(element_class) + gst.element_register( + element_class, element_class.__name__.lower(), gst.RANK_MARGINAL) + + +def register_elements(): + register_element(M3uDecoder) + register_element(PlsDecoder) + register_element(XspfDecoder) + register_element(AsxDecoder) + register_element(UriListElement) + + # 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://'): + register_element(IcySrc) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py deleted file mode 100644 index 3cee609a..00000000 --- a/mopidy/backends/spotify/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import unicode_literals - -import os - -import mopidy -from mopidy import config, exceptions, ext - - -class Extension(ext.Extension): - - dist_name = 'Mopidy-Spotify' - ext_name = 'spotify' - version = mopidy.__version__ - - def get_default_config(self): - conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return config.read(conf_file) - - def get_config_schema(self): - schema = super(Extension, self).get_config_schema() - schema['username'] = config.String() - schema['password'] = config.Secret() - schema['bitrate'] = config.Integer(choices=(96, 160, 320)) - schema['timeout'] = config.Integer(minimum=0) - schema['cache_dir'] = config.Path() - return schema - - def validate_environment(self): - try: - import spotify # noqa - except ImportError as e: - raise exceptions.ExtensionError('pyspotify library not found', e) - - def get_backend_classes(self): - from .actor import SpotifyBackend - return [SpotifyBackend] diff --git a/mopidy/backends/spotify/actor.py b/mopidy/backends/spotify/actor.py deleted file mode 100644 index 1f90ba51..00000000 --- a/mopidy/backends/spotify/actor.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import unicode_literals - -import logging - -import pykka - -from mopidy.backends import base -from mopidy.backends.spotify.library import SpotifyLibraryProvider -from mopidy.backends.spotify.playback import SpotifyPlaybackProvider -from mopidy.backends.spotify.session_manager import SpotifySessionManager -from mopidy.backends.spotify.playlists import SpotifyPlaylistsProvider - -logger = logging.getLogger('mopidy.backends.spotify') - - -class SpotifyBackend(pykka.ThreadingActor, base.Backend): - def __init__(self, config, audio): - super(SpotifyBackend, self).__init__() - - self.config = config - - self.library = SpotifyLibraryProvider(backend=self) - self.playback = SpotifyPlaybackProvider(audio=audio, backend=self) - self.playlists = SpotifyPlaylistsProvider(backend=self) - - self.uri_schemes = ['spotify'] - - self.spotify = SpotifySessionManager( - config, audio=audio, backend_ref=self.actor_ref) - - def on_start(self): - logger.info('Mopidy uses SPOTIFY(R) CORE') - logger.debug('Connecting to Spotify') - self.spotify.start() - - def on_stop(self): - self.spotify.logout() diff --git a/mopidy/backends/spotify/container_manager.py b/mopidy/backends/spotify/container_manager.py deleted file mode 100644 index e8d1ed0b..00000000 --- a/mopidy/backends/spotify/container_manager.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import unicode_literals - -import logging - -from spotify.manager import SpotifyContainerManager as \ - PyspotifyContainerManager - -logger = logging.getLogger('mopidy.backends.spotify') - - -class SpotifyContainerManager(PyspotifyContainerManager): - def __init__(self, session_manager): - PyspotifyContainerManager.__init__(self) - self.session_manager = session_manager - - def container_loaded(self, container, userdata): - """Callback used by pyspotify""" - logger.debug('Callback called: playlist container loaded') - - self.session_manager.refresh_playlists() - - count = 0 - for playlist in self.session_manager.session.playlist_container(): - if playlist.type() == 'playlist': - self.session_manager.playlist_manager.watch(playlist) - count += 1 - logger.debug('Watching %d playlist(s) for changes', count) - - def playlist_added(self, container, playlist, position, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: playlist added at position %d', position) - # container_loaded() is called after this callback, so we do not need - # to handle this callback. - - def playlist_moved(self, container, playlist, old_position, new_position, - userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: playlist "%s" moved from position %d to %d', - playlist.name(), old_position, new_position) - # container_loaded() is called after this callback, so we do not need - # to handle this callback. - - def playlist_removed(self, container, playlist, position, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: playlist "%s" removed from position %d', - playlist.name(), position) - # container_loaded() is called after this callback, so we do not need - # to handle this callback. diff --git a/mopidy/backends/spotify/ext.conf b/mopidy/backends/spotify/ext.conf deleted file mode 100644 index 83bf191a..00000000 --- a/mopidy/backends/spotify/ext.conf +++ /dev/null @@ -1,7 +0,0 @@ -[spotify] -enabled = true -username = -password = -bitrate = 160 -timeout = 10 -cache_dir = $XDG_CACHE_DIR/mopidy/spotify diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py deleted file mode 100644 index 49caa709..00000000 --- a/mopidy/backends/spotify/library.py +++ /dev/null @@ -1,211 +0,0 @@ -from __future__ import unicode_literals - -import logging -import time -import urllib - -import pykka -from spotify import Link, SpotifyError - -from mopidy.backends import base -from mopidy.models import Track, SearchResult - -from . import translator - -logger = logging.getLogger('mopidy.backends.spotify') - -TRACK_AVAILABLE = 1 - - -class SpotifyTrack(Track): - """Proxy object for unloaded Spotify tracks.""" - def __init__(self, uri=None, track=None): - super(SpotifyTrack, self).__init__() - if (uri and track) or (not uri and not track): - raise AttributeError('uri or track must be provided') - elif uri: - self._spotify_track = Link.from_string(uri).as_track() - elif track: - self._spotify_track = track - self._unloaded_track = Track(uri=uri, name='[loading...]') - self._track = None - - @property - def _proxy(self): - if self._track: - return self._track - elif self._spotify_track.is_loaded(): - self._track = translator.to_mopidy_track(self._spotify_track) - return self._track - else: - return self._unloaded_track - - def __getattribute__(self, name): - if name.startswith('_'): - return super(SpotifyTrack, self).__getattribute__(name) - return self._proxy.__getattribute__(name) - - def __repr__(self): - return self._proxy.__repr__() - - def __hash__(self): - return hash(self._proxy.uri) - - def __eq__(self, other): - if not isinstance(other, Track): - return False - return self._proxy.uri == other.uri - - def copy(self, **values): - return self._proxy.copy(**values) - - -class SpotifyLibraryProvider(base.BaseLibraryProvider): - def __init__(self, *args, **kwargs): - super(SpotifyLibraryProvider, self).__init__(*args, **kwargs) - self._timeout = self.backend.config['spotify']['timeout'] - - def find_exact(self, query=None, uris=None): - return self.search(query=query, uris=uris) - - def lookup(self, uri): - try: - link = Link.from_string(uri) - if link.type() == Link.LINK_TRACK: - return self._lookup_track(uri) - if link.type() == Link.LINK_ALBUM: - return self._lookup_album(uri) - elif link.type() == Link.LINK_ARTIST: - return self._lookup_artist(uri) - elif link.type() == Link.LINK_PLAYLIST: - return self._lookup_playlist(uri) - else: - return [] - except SpotifyError as error: - logger.debug(u'Failed to lookup "%s": %s', uri, error) - return [] - - def _lookup_track(self, uri): - track = Link.from_string(uri).as_track() - self._wait_for_object_to_load(track) - if track.is_loaded(): - if track.availability() == TRACK_AVAILABLE: - return [SpotifyTrack(track=track)] - else: - return [] - else: - return [SpotifyTrack(uri=uri)] - - def _lookup_album(self, uri): - album = Link.from_string(uri).as_album() - album_browser = self.backend.spotify.session.browse_album(album) - self._wait_for_object_to_load(album_browser) - return [ - SpotifyTrack(track=t) - for t in album_browser if t.availability() == TRACK_AVAILABLE] - - def _lookup_artist(self, uri): - artist = Link.from_string(uri).as_artist() - artist_browser = self.backend.spotify.session.browse_artist(artist) - self._wait_for_object_to_load(artist_browser) - return [ - SpotifyTrack(track=t) - for t in artist_browser if t.availability() == TRACK_AVAILABLE] - - def _lookup_playlist(self, uri): - playlist = Link.from_string(uri).as_playlist() - self._wait_for_object_to_load(playlist) - return [ - SpotifyTrack(track=t) - for t in playlist if t.availability() == TRACK_AVAILABLE] - - def _wait_for_object_to_load(self, spotify_obj, timeout=None): - # XXX Sleeping to wait for the Spotify object to load is an ugly hack, - # but it works. We should look into other solutions for this. - if timeout is None: - timeout = self._timeout - wait_until = time.time() + timeout - while not spotify_obj.is_loaded(): - time.sleep(0.1) - if time.time() > wait_until: - logger.debug( - 'Timeout: Spotify object did not load in %ds', timeout) - return - - def refresh(self, uri=None): - pass # TODO - - def search(self, query=None, uris=None): - # TODO Only return results within URI roots given by ``uris`` - - if not query: - return self._get_all_tracks() - - uris = query.get('uri', []) - if uris: - tracks = [] - for uri in uris: - tracks += self.lookup(uri) - if len(uris) == 1: - uri = uris[0] - else: - uri = 'spotify:search' - return SearchResult(uri=uri, tracks=tracks) - - spotify_query = self._translate_search_query(query) - logger.debug('Spotify search query: %s' % spotify_query) - - future = pykka.ThreadingFuture() - - def callback(results, userdata=None): - search_result = SearchResult( - uri='spotify:search:%s' % ( - urllib.quote(results.query().encode('utf-8'))), - albums=[ - translator.to_mopidy_album(a) for a in results.albums()], - artists=[ - translator.to_mopidy_artist(a) for a in results.artists()], - tracks=[ - translator.to_mopidy_track(t) for t in results.tracks()]) - future.set(search_result) - - if not self.backend.spotify.connected.is_set(): - logger.debug('Not connected: Spotify search cancelled') - return SearchResult(uri='spotify:search') - - self.backend.spotify.session.search( - spotify_query, callback, - album_count=200, artist_count=200, track_count=200) - - try: - return future.get(timeout=self._timeout) - except pykka.Timeout: - logger.debug( - 'Timeout: Spotify search did not return in %ds', self._timeout) - return SearchResult(uri='spotify:search') - - def _get_all_tracks(self): - # Since we can't search for the entire Spotify library, we return - # all tracks in the playlists when the query is empty. - tracks = [] - for playlist in self.backend.playlists.playlists: - tracks += playlist.tracks - return SearchResult(uri='spotify:search', tracks=tracks) - - def _translate_search_query(self, mopidy_query): - spotify_query = [] - for (field, values) in mopidy_query.iteritems(): - if field == 'date': - field = 'year' - if not hasattr(values, '__iter__'): - values = [values] - for value in values: - if field == 'any': - spotify_query.append(value) - elif field == 'year': - value = int(value.split('-')[0]) # Extract year - spotify_query.append('%s:%d' % (field, value)) - else: - spotify_query.append('%s:"%s"' % (field, value)) - spotify_query = ' '.join(spotify_query) - return spotify_query diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py deleted file mode 100644 index bda17634..00000000 --- a/mopidy/backends/spotify/playback.py +++ /dev/null @@ -1,94 +0,0 @@ -from __future__ import unicode_literals - -import logging -import functools - -from spotify import Link, SpotifyError - -from mopidy import audio -from mopidy.backends import base - - -logger = logging.getLogger('mopidy.backends.spotify') - - -def need_data_callback(spotify_backend, length_hint): - spotify_backend.playback.on_need_data(length_hint) - - -def enough_data_callback(spotify_backend): - spotify_backend.playback.on_enough_data() - - -def seek_data_callback(spotify_backend, time_position): - spotify_backend.playback.on_seek_data(time_position) - - -class SpotifyPlaybackProvider(base.BasePlaybackProvider): - # These GStreamer caps matches the audio data provided by libspotify - _caps = ( - 'audio/x-raw-int, endianness=(int)1234, channels=(int)2, ' - 'width=(int)16, depth=(int)16, signed=(boolean)true, ' - 'rate=(int)44100') - - def __init__(self, *args, **kwargs): - super(SpotifyPlaybackProvider, self).__init__(*args, **kwargs) - self._first_seek = False - - def play(self, track): - if track.uri is None: - return False - - spotify_backend = self.backend.actor_ref.proxy() - need_data_callback_bound = functools.partial( - need_data_callback, spotify_backend) - enough_data_callback_bound = functools.partial( - enough_data_callback, spotify_backend) - seek_data_callback_bound = functools.partial( - seek_data_callback, spotify_backend) - - self._first_seek = True - - try: - self.backend.spotify.session.load( - Link.from_string(track.uri).as_track()) - self.backend.spotify.session.play(1) - self.backend.spotify.buffer_timestamp = 0 - - self.audio.prepare_change() - self.audio.set_appsrc( - self._caps, - need_data=need_data_callback_bound, - enough_data=enough_data_callback_bound, - seek_data=seek_data_callback_bound) - self.audio.start_playback() - self.audio.set_metadata(track) - - return True - except SpotifyError as e: - logger.info('Playback of %s failed: %s', track.uri, e) - return False - - def stop(self): - self.backend.spotify.session.play(0) - return super(SpotifyPlaybackProvider, self).stop() - - def on_need_data(self, length_hint): - logger.debug('playback.on_need_data(%d) called', length_hint) - self.backend.spotify.push_audio_data = True - - def on_enough_data(self): - logger.debug('playback.on_enough_data() called') - self.backend.spotify.push_audio_data = False - - def on_seek_data(self, time_position): - logger.debug('playback.on_seek_data(%d) called', time_position) - - if time_position == 0 and self._first_seek: - self._first_seek = False - logger.debug('Skipping seek due to issue #300') - return - - self.backend.spotify.buffer_timestamp = audio.millisecond_to_clocktime( - time_position) - self.backend.spotify.session.seek(time_position) diff --git a/mopidy/backends/spotify/playlist_manager.py b/mopidy/backends/spotify/playlist_manager.py deleted file mode 100644 index 6cd6d4ed..00000000 --- a/mopidy/backends/spotify/playlist_manager.py +++ /dev/null @@ -1,105 +0,0 @@ -from __future__ import unicode_literals - -import datetime -import logging - -from spotify.manager import SpotifyPlaylistManager as PyspotifyPlaylistManager - -logger = logging.getLogger('mopidy.backends.spotify') - - -class SpotifyPlaylistManager(PyspotifyPlaylistManager): - def __init__(self, session_manager): - PyspotifyPlaylistManager.__init__(self) - self.session_manager = session_manager - - def tracks_added(self, playlist, tracks, position, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: ' - '%d track(s) added to position %d in playlist "%s"', - len(tracks), position, playlist.name()) - self.session_manager.refresh_playlists() - - def tracks_moved(self, playlist, tracks, new_position, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: ' - '%d track(s) moved to position %d in playlist "%s"', - len(tracks), new_position, playlist.name()) - self.session_manager.refresh_playlists() - - def tracks_removed(self, playlist, tracks, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: ' - '%d track(s) removed from playlist "%s"', - len(tracks), playlist.name()) - self.session_manager.refresh_playlists() - - def playlist_renamed(self, playlist, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: Playlist renamed to "%s"', playlist.name()) - self.session_manager.refresh_playlists() - - def playlist_state_changed(self, playlist, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: The state of playlist "%s" changed', - playlist.name()) - - def playlist_update_in_progress(self, playlist, done, userdata): - """Callback used by pyspotify""" - if done: - logger.debug( - 'Callback called: Update of playlist "%s" done', - playlist.name()) - else: - logger.debug( - 'Callback called: Update of playlist "%s" in progress', - playlist.name()) - - def playlist_metadata_updated(self, playlist, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: Metadata updated for playlist "%s"', - playlist.name()) - - def track_created_changed(self, playlist, position, user, when, userdata): - """Callback used by pyspotify""" - when = datetime.datetime.fromtimestamp(when) - logger.debug( - 'Callback called: Created by/when for track %d in playlist ' - '"%s" changed to user "N/A" and time "%s"', - position, playlist.name(), when) - - def track_message_changed(self, playlist, position, message, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: Message for track %d in playlist ' - '"%s" changed to "%s"', position, playlist.name(), message) - - def track_seen_changed(self, playlist, position, seen, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: Seen attribute for track %d in playlist ' - '"%s" changed to "%s"', position, playlist.name(), seen) - - def description_changed(self, playlist, description, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: Description changed for playlist "%s" to "%s"', - playlist.name(), description) - - def subscribers_changed(self, playlist, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: Subscribers changed for playlist "%s"', - playlist.name()) - - def image_changed(self, playlist, image, userdata): - """Callback used by pyspotify""" - logger.debug( - 'Callback called: Image changed for playlist "%s"', - playlist.name()) diff --git a/mopidy/backends/spotify/playlists.py b/mopidy/backends/spotify/playlists.py deleted file mode 100644 index bd201179..00000000 --- a/mopidy/backends/spotify/playlists.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import unicode_literals - -from mopidy.backends import base - - -class SpotifyPlaylistsProvider(base.BasePlaylistsProvider): - def create(self, name): - pass # TODO - - def delete(self, uri): - pass # TODO - - def lookup(self, uri): - for playlist in self._playlists: - if playlist.uri == uri: - return playlist - - def refresh(self): - pass # TODO - - def save(self, playlist): - pass # TODO diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py deleted file mode 100644 index 3ab4498b..00000000 --- a/mopidy/backends/spotify/session_manager.py +++ /dev/null @@ -1,201 +0,0 @@ -from __future__ import unicode_literals - -import logging -import os -import threading - -from spotify.manager import SpotifySessionManager as PyspotifySessionManager - -from mopidy import audio -from mopidy.backends.listener import BackendListener -from mopidy.utils import process, versioning - -from . import translator -from .container_manager import SpotifyContainerManager -from .playlist_manager import SpotifyPlaylistManager - -logger = logging.getLogger('mopidy.backends.spotify') - -BITRATES = {96: 2, 160: 0, 320: 1} - - -class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): - cache_location = None - settings_location = None - appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key') - user_agent = 'Mopidy %s' % versioning.get_version() - - def __init__(self, config, audio, backend_ref): - - self.cache_location = config['spotify']['cache_dir'] - self.settings_location = config['spotify']['cache_dir'] - - full_proxy = '' - if config['proxy']['hostname']: - full_proxy = config['proxy']['hostname'] - if config['proxy']['port']: - full_proxy += ':' + str(config['proxy']['port']) - if config['proxy']['scheme']: - full_proxy = config['proxy']['scheme'] + "://" + full_proxy - - PyspotifySessionManager.__init__( - self, config['spotify']['username'], config['spotify']['password'], - proxy=full_proxy, - proxy_username=config['proxy']['username'], - proxy_password=config['proxy']['password']) - - process.BaseThread.__init__(self) - self.name = 'SpotifyThread' - - self.audio = audio - self.backend = None - self.backend_ref = backend_ref - - self.bitrate = config['spotify']['bitrate'] - - self.connected = threading.Event() - self.push_audio_data = True - self.buffer_timestamp = 0 - - self.container_manager = None - self.playlist_manager = None - - self._initial_data_receive_completed = False - - def run_inside_try(self): - self.backend = self.backend_ref.proxy() - self.connect() - - def logged_in(self, session, error): - """Callback used by pyspotify""" - if error: - logger.error('Spotify login error: %s', error) - return - - logger.info('Connected to Spotify') - - # To work with both pyspotify 1.9 and 1.10 - if not hasattr(self, 'session'): - self.session = session - - logger.debug('Preferred Spotify bitrate is %d kbps', self.bitrate) - session.set_preferred_bitrate(BITRATES[self.bitrate]) - - self.container_manager = SpotifyContainerManager(self) - self.playlist_manager = SpotifyPlaylistManager(self) - - self.container_manager.watch(session.playlist_container()) - - self.connected.set() - - def logged_out(self, session): - """Callback used by pyspotify""" - logger.info('Disconnected from Spotify') - self.connected.clear() - - def metadata_updated(self, session): - """Callback used by pyspotify""" - logger.debug('Callback called: Metadata updated') - - def connection_error(self, session, error): - """Callback used by pyspotify""" - if error is None: - logger.info('Spotify connection OK') - else: - logger.error('Spotify connection error: %s', error) - if self.audio.state.get() == audio.PlaybackState.PLAYING: - self.backend.playback.pause() - - def message_to_user(self, session, message): - """Callback used by pyspotify""" - logger.debug('User message: %s', message.strip()) - - def music_delivery(self, session, frames, frame_size, num_frames, - sample_type, sample_rate, channels): - """Callback used by pyspotify""" - if not self.push_audio_data: - return 0 - - assert sample_type == 0, 'Expects 16-bit signed integer samples' - capabilites = """ - audio/x-raw-int, - endianness=(int)1234, - channels=(int)%(channels)d, - width=(int)16, - depth=(int)16, - signed=(boolean)true, - rate=(int)%(sample_rate)d - """ % { - 'sample_rate': sample_rate, - 'channels': channels, - } - - duration = audio.calculate_duration(num_frames, sample_rate) - buffer_ = audio.create_buffer(bytes(frames), - capabilites=capabilites, - timestamp=self.buffer_timestamp, - duration=duration) - - self.buffer_timestamp += duration - - if self.audio.emit_data(buffer_).get(): - return num_frames - else: - return 0 - - def play_token_lost(self, session): - """Callback used by pyspotify""" - logger.debug('Play token lost') - self.backend.playback.pause() - - def log_message(self, session, data): - """Callback used by pyspotify""" - logger.debug('System message: %s' % data.strip()) - if 'offline-mgr' in data and 'files unlocked' in data: - # XXX This is a very very fragile and ugly hack, but we get no - # proper event when libspotify is done with initial data loading. - # We delay the expensive refresh of Mopidy's playlists until this - # message arrives. This way, we avoid doing the refresh once for - # every playlist or other change. This reduces the time from - # startup until the Spotify backend is ready from 35s to 12s in one - # test with clean Spotify cache. In cases with an outdated cache - # the time improvements should be a lot greater. - if not self._initial_data_receive_completed: - self._initial_data_receive_completed = True - self.refresh_playlists() - - def end_of_track(self, session): - """Callback used by pyspotify""" - logger.debug('End of data stream reached') - self.audio.emit_end_of_stream() - - def refresh_playlists(self): - """Refresh the playlists in the backend with data from Spotify""" - if not self._initial_data_receive_completed: - logger.debug('Still getting data; skipped refresh of playlists') - return - playlists = [] - folders = [] - for spotify_playlist in self.session.playlist_container(): - if spotify_playlist.type() == 'folder_start': - folders.append(spotify_playlist) - if spotify_playlist.type() == 'folder_end': - folders.pop() - playlists.append(translator.to_mopidy_playlist( - spotify_playlist, folders=folders, - bitrate=self.bitrate, username=self.username)) - playlists.append(translator.to_mopidy_playlist( - self.session.starred(), - bitrate=self.bitrate, username=self.username)) - playlists = filter(None, playlists) - self.backend.playlists.playlists = playlists - logger.info('Loaded %d Spotify playlists', len(playlists)) - BackendListener.send('playlists_loaded') - - def logout(self): - """Log out from spotify""" - logger.debug('Logging out from Spotify') - - # To work with both pyspotify 1.9 and 1.10 - if getattr(self, 'session', None): - self.session.logout() diff --git a/mopidy/backends/spotify/spotify_appkey.key b/mopidy/backends/spotify/spotify_appkey.key deleted file mode 100644 index 1f840b96..00000000 Binary files a/mopidy/backends/spotify/spotify_appkey.key and /dev/null differ diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py deleted file mode 100644 index f35cad2e..00000000 --- a/mopidy/backends/spotify/translator.py +++ /dev/null @@ -1,97 +0,0 @@ -from __future__ import unicode_literals - -import logging - -import spotify - -from mopidy.models import Artist, Album, Track, Playlist - -logger = logging.getLogger('mopidy.backends.spotify') - - -artist_cache = {} -album_cache = {} -track_cache = {} - - -def to_mopidy_artist(spotify_artist): - if spotify_artist is None: - return - uri = str(spotify.Link.from_artist(spotify_artist)) - if uri in artist_cache: - return artist_cache[uri] - if not spotify_artist.is_loaded(): - return Artist(uri=uri, name='[loading...]') - artist_cache[uri] = Artist(uri=uri, name=spotify_artist.name()) - return artist_cache[uri] - - -def to_mopidy_album(spotify_album): - if spotify_album is None: - return - uri = str(spotify.Link.from_album(spotify_album)) - if uri in album_cache: - return album_cache[uri] - if not spotify_album.is_loaded(): - return Album(uri=uri, name='[loading...]') - album_cache[uri] = Album( - uri=uri, - name=spotify_album.name(), - artists=[to_mopidy_artist(spotify_album.artist())], - date=spotify_album.year()) - return album_cache[uri] - - -def to_mopidy_track(spotify_track, bitrate=None): - if spotify_track is None: - return - uri = str(spotify.Link.from_track(spotify_track, 0)) - if uri in track_cache: - return track_cache[uri] - if not spotify_track.is_loaded(): - return Track(uri=uri, name='[loading...]') - spotify_album = spotify_track.album() - if spotify_album is not None and spotify_album.is_loaded(): - date = spotify_album.year() - else: - date = None - track_cache[uri] = Track( - uri=uri, - name=spotify_track.name(), - artists=[to_mopidy_artist(a) for a in spotify_track.artists()], - album=to_mopidy_album(spotify_track.album()), - track_no=spotify_track.index(), - date=date, - length=spotify_track.duration(), - bitrate=bitrate) - return track_cache[uri] - - -def to_mopidy_playlist( - spotify_playlist, folders=None, bitrate=None, username=None): - if spotify_playlist is None or spotify_playlist.type() != 'playlist': - return - try: - uri = str(spotify.Link.from_playlist(spotify_playlist)) - except spotify.SpotifyError as e: - logger.debug('Spotify playlist translation error: %s', e) - return - if not spotify_playlist.is_loaded(): - return Playlist(uri=uri, name='[loading...]') - name = spotify_playlist.name() - if folders: - folder_names = '/'.join(folder.name() for folder in folders) - name = folder_names + '/' + name - tracks = [ - to_mopidy_track(spotify_track, bitrate=bitrate) - for spotify_track in spotify_playlist - if not spotify_track.is_local() - ] - if not name: - name = 'Starred' - # Tracks in the Starred playlist are in reverse order from the official - # client. - tracks.reverse() - if spotify_playlist.owner().canonical_name() != username: - name += ' by ' + spotify_playlist.owner().canonical_name() - return Playlist(uri=uri, name=name, tracks=tracks) diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index c93fc39e..40c78540 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -132,11 +132,25 @@ class CoreListener(object): """ pass - def volume_changed(self): + def volume_changed(self, volume): """ Called whenever the volume is changed. *MAY* be implemented by actor. + + :param volume: the new volume in the range [0..100] + :type volume: int + """ + pass + + def mute_changed(self, mute): + """ + Called whenever the mute state is changed. + + *MAY* be implemented by actor. + + :param mute: the new mute state + :type mute: boolean """ pass diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 2a769d0a..7eedb750 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -21,6 +21,7 @@ class PlaybackController(object): self._state = PlaybackState.STOPPED self._volume = None + self._mute = False def _get_backend(self): if self.current_tl_track is None: @@ -106,6 +107,26 @@ class PlaybackController(object): volume = property(get_volume, set_volume) """Volume as int in range [0..100] or :class:`None`""" + def get_mute(self): + if self.audio: + return self.audio.get_mute().get() + else: + # For testing + return self._mute + + def set_mute(self, value): + value = bool(value) + if self.audio: + self.audio.set_mute(value) + else: + # For testing + self._mute = value + + self._trigger_mute_changed(value) + + mute = property(get_mute, set_mute) + """Mute state as a :class:`True` if muted, :class:`False` otherwise""" + ### Methods def change_track(self, tl_track, on_error_step=1): @@ -331,6 +352,10 @@ class PlaybackController(object): logger.debug('Triggering volume changed event') listener.CoreListener.send('volume_changed', volume=volume) + def _trigger_mute_changed(self, mute): + logger.debug('Triggering mute changed event') + listener.CoreListener.send('mute_changed', mute=mute) + def _trigger_seeked(self, time_position): logger.debug('Triggering seeked event') listener.CoreListener.send('seeked', time_position=time_position) diff --git a/mopidy/ext.py b/mopidy/ext.py index 5db7c093..e6cfbb7c 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -83,8 +83,7 @@ class Extension(object): """List of library updater classes :returns: list of - :class:`~mopidy.backends.base.BaseLibraryUpdateProvider` - subclasses + :class:`~mopidy.backends.base.BaseLibraryUpdateProvider` subclasses """ return [] diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index f1fefae4..4d983b73 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -55,3 +55,6 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): def volume_changed(self, volume): self.send_idle('mixer') + + def mute_changed(self, mute): + self.send_idle('output') diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/frontends/mpd/protocol/audio_output.py index 01982a71..65e693ec 100644 --- a/mopidy/frontends/mpd/protocol/audio_output.py +++ b/mopidy/frontends/mpd/protocol/audio_output.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals +from mopidy.frontends.mpd.exceptions import MpdNoExistError from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.exceptions import MpdNotImplemented @handle_request(r'^disableoutput "(?P\d+)"$') @@ -13,7 +13,10 @@ def disableoutput(context, outputid): Turns an output off. """ - raise MpdNotImplemented # TODO + if int(outputid) == 0: + context.core.playback.set_mute(True) + else: + raise MpdNoExistError('No such audio output', command='disableoutput') @handle_request(r'^enableoutput "(?P\d+)"$') @@ -25,7 +28,10 @@ def enableoutput(context, outputid): Turns an output on. """ - raise MpdNotImplemented # TODO + if int(outputid) == 0: + context.core.playback.set_mute(False) + else: + raise MpdNoExistError('No such audio output', command='enableoutput') @handle_request(r'^outputs$') @@ -37,8 +43,9 @@ def outputs(context): Shows information about all outputs. """ + muted = 1 if context.core.playback.get_mute().get() else 0 return [ ('outputid', 0), - ('outputname', 'Default'), - ('outputenabled', 1), + ('outputname', 'Mute'), + ('outputenabled', muted), ] diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py deleted file mode 100644 index 1fd258b5..00000000 --- a/mopidy/frontends/mpris/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import unicode_literals - -import os - -import mopidy -from mopidy import config, exceptions, ext - - -class Extension(ext.Extension): - - dist_name = 'Mopidy-MPRIS' - ext_name = 'mpris' - version = mopidy.__version__ - - def get_default_config(self): - conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return config.read(conf_file) - - def get_config_schema(self): - schema = super(Extension, self).get_config_schema() - schema['desktop_file'] = config.Path() - return schema - - def validate_environment(self): - if 'DISPLAY' not in os.environ: - raise exceptions.ExtensionError( - 'An X11 $DISPLAY is needed to use D-Bus') - - try: - import dbus # noqa - except ImportError as e: - raise exceptions.ExtensionError('dbus library not found', e) - - def get_frontend_classes(self): - from .actor import MprisFrontend - return [MprisFrontend] diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py deleted file mode 100644 index d44e9262..00000000 --- a/mopidy/frontends/mpris/actor.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import unicode_literals - -import logging -import os - -import pykka - -from mopidy.core import CoreListener -from mopidy.frontends.mpris import objects - -logger = logging.getLogger('mopidy.frontends.mpris') - -try: - indicate = None - if 'DISPLAY' in os.environ: - import indicate -except ImportError: - pass - -if indicate is None: - logger.debug('Startup notification will not be sent') - - -class MprisFrontend(pykka.ThreadingActor, CoreListener): - def __init__(self, config, core): - super(MprisFrontend, self).__init__() - self.config = config - self.core = core - self.indicate_server = None - self.mpris_object = None - - def on_start(self): - try: - self.mpris_object = objects.MprisObject(self.config, self.core) - self._send_startup_notification() - except Exception as e: - logger.warning('MPRIS frontend setup failed (%s)', e) - self.stop() - - def on_stop(self): - logger.debug('Removing MPRIS object from D-Bus connection...') - if self.mpris_object: - self.mpris_object.remove_from_connection() - self.mpris_object = None - logger.debug('Removed MPRIS object from D-Bus connection') - - def _send_startup_notification(self): - """ - Send startup notification using libindicate to make Mopidy appear in - e.g. `Ubunt's sound menu `_. - - A reference to the libindicate server is kept for as long as Mopidy is - running. When Mopidy exits, the server will be unreferenced and Mopidy - will automatically be unregistered from e.g. the sound menu. - """ - if not indicate: - return - logger.debug('Sending startup notification...') - self.indicate_server = indicate.Server() - self.indicate_server.set_type('music.mopidy') - self.indicate_server.set_desktop_file( - self.config['mpris']['desktop_file']) - self.indicate_server.show() - logger.debug('Startup notification sent') - - def _emit_properties_changed(self, interface, changed_properties): - if self.mpris_object is None: - return - props_with_new_values = [ - (p, self.mpris_object.Get(interface, p)) - for p in changed_properties] - self.mpris_object.PropertiesChanged( - interface, dict(props_with_new_values), []) - - def track_playback_paused(self, tl_track, time_position): - logger.debug('Received track_playback_paused event') - self._emit_properties_changed(objects.PLAYER_IFACE, ['PlaybackStatus']) - - def track_playback_resumed(self, tl_track, time_position): - logger.debug('Received track_playback_resumed event') - self._emit_properties_changed(objects.PLAYER_IFACE, ['PlaybackStatus']) - - def track_playback_started(self, tl_track): - logger.debug('Received track_playback_started event') - self._emit_properties_changed( - objects.PLAYER_IFACE, ['PlaybackStatus', 'Metadata']) - - def track_playback_ended(self, tl_track, time_position): - logger.debug('Received track_playback_ended event') - self._emit_properties_changed( - objects.PLAYER_IFACE, ['PlaybackStatus', 'Metadata']) - - def volume_changed(self, volume): - logger.debug('Received volume_changed event') - self._emit_properties_changed(objects.PLAYER_IFACE, ['Volume']) - - def seeked(self, time_position_in_ms): - logger.debug('Received seeked event') - self.mpris_object.Seeked(time_position_in_ms * 1000) - - def playlists_loaded(self): - logger.debug('Received playlists_loaded event') - self._emit_properties_changed( - objects.PLAYLISTS_IFACE, ['PlaylistCount']) - - def playlist_changed(self, playlist): - logger.debug('Received playlist_changed event') - playlist_id = self.mpris_object.get_playlist_id(playlist.uri) - playlist = (playlist_id, playlist.name, '') - self.mpris_object.PlaylistChanged(playlist) diff --git a/mopidy/frontends/mpris/ext.conf b/mopidy/frontends/mpris/ext.conf deleted file mode 100644 index b83411c2..00000000 --- a/mopidy/frontends/mpris/ext.conf +++ /dev/null @@ -1,3 +0,0 @@ -[mpris] -enabled = true -desktop_file = /usr/share/applications/mopidy.desktop diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py deleted file mode 100644 index 624caa99..00000000 --- a/mopidy/frontends/mpris/objects.py +++ /dev/null @@ -1,501 +0,0 @@ -from __future__ import unicode_literals - -import base64 -import logging -import os - -import dbus -import dbus.mainloop.glib -import dbus.service -import gobject - -from mopidy.core import PlaybackState -from mopidy.utils.process import exit_process - - -logger = logging.getLogger('mopidy.frontends.mpris') - -# Must be done before dbus.SessionBus() is called -gobject.threads_init() -dbus.mainloop.glib.threads_init() - -BUS_NAME = 'org.mpris.MediaPlayer2.mopidy' -OBJECT_PATH = '/org/mpris/MediaPlayer2' -ROOT_IFACE = 'org.mpris.MediaPlayer2' -PLAYER_IFACE = 'org.mpris.MediaPlayer2.Player' -PLAYLISTS_IFACE = 'org.mpris.MediaPlayer2.Playlists' - - -class MprisObject(dbus.service.Object): - """Implements http://www.mpris.org/2.2/spec/""" - - properties = None - - def __init__(self, config, core): - self.config = config - self.core = core - self.properties = { - ROOT_IFACE: self._get_root_iface_properties(), - PLAYER_IFACE: self._get_player_iface_properties(), - PLAYLISTS_IFACE: self._get_playlists_iface_properties(), - } - bus_name = self._connect_to_dbus() - dbus.service.Object.__init__(self, bus_name, OBJECT_PATH) - - def _get_root_iface_properties(self): - return { - 'CanQuit': (True, None), - 'Fullscreen': (False, None), - 'CanSetFullscreen': (False, None), - 'CanRaise': (False, None), - # NOTE Change if adding optional track list support - 'HasTrackList': (False, None), - 'Identity': ('Mopidy', None), - 'DesktopEntry': (self.get_DesktopEntry, None), - 'SupportedUriSchemes': (self.get_SupportedUriSchemes, None), - # NOTE Return MIME types supported by local backend if support for - # reporting supported MIME types is added - 'SupportedMimeTypes': (dbus.Array([], signature='s'), None), - } - - def _get_player_iface_properties(self): - return { - 'PlaybackStatus': (self.get_PlaybackStatus, None), - 'LoopStatus': (self.get_LoopStatus, self.set_LoopStatus), - 'Rate': (1.0, self.set_Rate), - 'Shuffle': (self.get_Shuffle, self.set_Shuffle), - 'Metadata': (self.get_Metadata, None), - 'Volume': (self.get_Volume, self.set_Volume), - 'Position': (self.get_Position, None), - 'MinimumRate': (1.0, None), - 'MaximumRate': (1.0, None), - 'CanGoNext': (self.get_CanGoNext, None), - 'CanGoPrevious': (self.get_CanGoPrevious, None), - 'CanPlay': (self.get_CanPlay, None), - 'CanPause': (self.get_CanPause, None), - 'CanSeek': (self.get_CanSeek, None), - 'CanControl': (self.get_CanControl, None), - } - - def _get_playlists_iface_properties(self): - return { - 'PlaylistCount': (self.get_PlaylistCount, None), - 'Orderings': (self.get_Orderings, None), - 'ActivePlaylist': (self.get_ActivePlaylist, None), - } - - def _connect_to_dbus(self): - logger.debug('Connecting to D-Bus...') - mainloop = dbus.mainloop.glib.DBusGMainLoop() - bus_name = dbus.service.BusName( - BUS_NAME, dbus.SessionBus(mainloop=mainloop)) - logger.info('MPRIS server connected to D-Bus') - return bus_name - - def get_playlist_id(self, playlist_uri): - # Only A-Za-z0-9_ is allowed, which is 63 chars, so we can't use - # base64. Luckily, D-Bus does not limit the length of object paths. - # Since base32 pads trailing bytes with "=" chars, we need to replace - # them with an allowed character such as "_". - encoded_uri = base64.b32encode(playlist_uri).replace('=', '_') - return '/com/mopidy/playlist/%s' % encoded_uri - - def get_playlist_uri(self, playlist_id): - encoded_uri = playlist_id.split('/')[-1].replace('_', '=') - return base64.b32decode(encoded_uri) - - def get_track_id(self, tl_track): - return '/com/mopidy/track/%d' % tl_track.tlid - - def get_track_tlid(self, track_id): - assert track_id.startswith('/com/mopidy/track/') - return track_id.split('/')[-1] - - ### Properties interface - - @dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE, - in_signature='ss', out_signature='v') - def Get(self, interface, prop): - logger.debug( - '%s.Get(%s, %s) called', - dbus.PROPERTIES_IFACE, repr(interface), repr(prop)) - (getter, _) = self.properties[interface][prop] - if callable(getter): - return getter() - else: - return getter - - @dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE, - in_signature='s', out_signature='a{sv}') - def GetAll(self, interface): - logger.debug( - '%s.GetAll(%s) called', dbus.PROPERTIES_IFACE, repr(interface)) - getters = {} - for key, (getter, _) in self.properties[interface].iteritems(): - getters[key] = getter() if callable(getter) else getter - return getters - - @dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE, - in_signature='ssv', out_signature='') - def Set(self, interface, prop, value): - logger.debug( - '%s.Set(%s, %s, %s) called', - dbus.PROPERTIES_IFACE, repr(interface), repr(prop), repr(value)) - _, setter = self.properties[interface][prop] - if setter is not None: - setter(value) - self.PropertiesChanged( - interface, {prop: self.Get(interface, prop)}, []) - - @dbus.service.signal(dbus_interface=dbus.PROPERTIES_IFACE, - signature='sa{sv}as') - def PropertiesChanged(self, interface, changed_properties, - invalidated_properties): - logger.debug( - '%s.PropertiesChanged(%s, %s, %s) signaled', - dbus.PROPERTIES_IFACE, interface, changed_properties, - invalidated_properties) - - ### Root interface methods - - @dbus.service.method(dbus_interface=ROOT_IFACE) - def Raise(self): - logger.debug('%s.Raise called', ROOT_IFACE) - # Do nothing, as we do not have a GUI - - @dbus.service.method(dbus_interface=ROOT_IFACE) - def Quit(self): - logger.debug('%s.Quit called', ROOT_IFACE) - exit_process() - - ### Root interface properties - - def get_DesktopEntry(self): - return os.path.splitext(os.path.basename( - self.config['mpris']['desktop_file']))[0] - - def get_SupportedUriSchemes(self): - return dbus.Array(self.core.uri_schemes.get(), signature='s') - - ### Player interface methods - - @dbus.service.method(dbus_interface=PLAYER_IFACE) - def Next(self): - logger.debug('%s.Next called', PLAYER_IFACE) - if not self.get_CanGoNext(): - logger.debug('%s.Next not allowed', PLAYER_IFACE) - return - self.core.playback.next().get() - - @dbus.service.method(dbus_interface=PLAYER_IFACE) - def Previous(self): - logger.debug('%s.Previous called', PLAYER_IFACE) - if not self.get_CanGoPrevious(): - logger.debug('%s.Previous not allowed', PLAYER_IFACE) - return - self.core.playback.previous().get() - - @dbus.service.method(dbus_interface=PLAYER_IFACE) - def Pause(self): - logger.debug('%s.Pause called', PLAYER_IFACE) - if not self.get_CanPause(): - logger.debug('%s.Pause not allowed', PLAYER_IFACE) - return - self.core.playback.pause().get() - - @dbus.service.method(dbus_interface=PLAYER_IFACE) - def PlayPause(self): - logger.debug('%s.PlayPause called', PLAYER_IFACE) - if not self.get_CanPause(): - logger.debug('%s.PlayPause not allowed', PLAYER_IFACE) - return - state = self.core.playback.state.get() - if state == PlaybackState.PLAYING: - self.core.playback.pause().get() - elif state == PlaybackState.PAUSED: - self.core.playback.resume().get() - elif state == PlaybackState.STOPPED: - self.core.playback.play().get() - - @dbus.service.method(dbus_interface=PLAYER_IFACE) - def Stop(self): - logger.debug('%s.Stop called', PLAYER_IFACE) - if not self.get_CanControl(): - logger.debug('%s.Stop not allowed', PLAYER_IFACE) - return - self.core.playback.stop().get() - - @dbus.service.method(dbus_interface=PLAYER_IFACE) - def Play(self): - logger.debug('%s.Play called', PLAYER_IFACE) - if not self.get_CanPlay(): - logger.debug('%s.Play not allowed', PLAYER_IFACE) - return - state = self.core.playback.state.get() - if state == PlaybackState.PAUSED: - self.core.playback.resume().get() - else: - self.core.playback.play().get() - - @dbus.service.method(dbus_interface=PLAYER_IFACE) - def Seek(self, offset): - logger.debug('%s.Seek called', PLAYER_IFACE) - if not self.get_CanSeek(): - logger.debug('%s.Seek not allowed', PLAYER_IFACE) - return - offset_in_milliseconds = offset // 1000 - current_position = self.core.playback.time_position.get() - new_position = current_position + offset_in_milliseconds - self.core.playback.seek(new_position) - - @dbus.service.method(dbus_interface=PLAYER_IFACE) - def SetPosition(self, track_id, position): - logger.debug('%s.SetPosition called', PLAYER_IFACE) - if not self.get_CanSeek(): - logger.debug('%s.SetPosition not allowed', PLAYER_IFACE) - return - position = position // 1000 - current_tl_track = self.core.playback.current_tl_track.get() - if current_tl_track is None: - return - if track_id != self.get_track_id(current_tl_track): - return - if position < 0: - return - if current_tl_track.track.length < position: - return - self.core.playback.seek(position) - - @dbus.service.method(dbus_interface=PLAYER_IFACE) - def OpenUri(self, uri): - logger.debug('%s.OpenUri called', PLAYER_IFACE) - if not self.get_CanPlay(): - # NOTE The spec does not explictly require this check, but guarding - # the other methods doesn't help much if OpenUri is open for use. - logger.debug('%s.Play not allowed', PLAYER_IFACE) - return - # NOTE Check if URI has MIME type known to the backend, if MIME support - # is added to the backend. - tl_tracks = self.core.tracklist.add(uri=uri).get() - if tl_tracks: - self.core.playback.play(tl_tracks[0]) - else: - logger.debug('Track with URI "%s" not found in library.', uri) - - ### Player interface signals - - @dbus.service.signal(dbus_interface=PLAYER_IFACE, signature='x') - def Seeked(self, position): - logger.debug('%s.Seeked signaled', PLAYER_IFACE) - # Do nothing, as just calling the method is enough to emit the signal. - - ### Player interface properties - - def get_PlaybackStatus(self): - state = self.core.playback.state.get() - if state == PlaybackState.PLAYING: - return 'Playing' - elif state == PlaybackState.PAUSED: - return 'Paused' - elif state == PlaybackState.STOPPED: - return 'Stopped' - - def get_LoopStatus(self): - repeat = self.core.tracklist.repeat.get() - single = self.core.tracklist.single.get() - if not repeat: - return 'None' - else: - if single: - return 'Track' - else: - return 'Playlist' - - def set_LoopStatus(self, value): - if not self.get_CanControl(): - logger.debug('Setting %s.LoopStatus not allowed', PLAYER_IFACE) - return - if value == 'None': - self.core.tracklist.repeat = False - self.core.tracklist.single = False - elif value == 'Track': - self.core.tracklist.repeat = True - self.core.tracklist.single = True - elif value == 'Playlist': - self.core.tracklist.repeat = True - self.core.tracklist.single = False - - def set_Rate(self, value): - if not self.get_CanControl(): - # NOTE The spec does not explictly require this check, but it was - # added to be consistent with all the other property setters. - logger.debug('Setting %s.Rate not allowed', PLAYER_IFACE) - return - if value == 0: - self.Pause() - - def get_Shuffle(self): - return self.core.tracklist.random.get() - - def set_Shuffle(self, value): - if not self.get_CanControl(): - logger.debug('Setting %s.Shuffle not allowed', PLAYER_IFACE) - return - if value: - self.core.tracklist.random = True - else: - self.core.tracklist.random = False - - def get_Metadata(self): - current_tl_track = self.core.playback.current_tl_track.get() - if current_tl_track is None: - return {'mpris:trackid': ''} - else: - (_, track) = current_tl_track - metadata = {'mpris:trackid': self.get_track_id(current_tl_track)} - if track.length: - metadata['mpris:length'] = track.length * 1000 - if track.uri: - metadata['xesam:url'] = track.uri - if track.name: - metadata['xesam:title'] = track.name - if track.artists: - artists = list(track.artists) - artists.sort(key=lambda a: a.name) - metadata['xesam:artist'] = dbus.Array( - [a.name for a in artists if a.name], signature='s') - if track.album and track.album.name: - metadata['xesam:album'] = track.album.name - if track.album and track.album.artists: - artists = list(track.album.artists) - artists.sort(key=lambda a: a.name) - metadata['xesam:albumArtist'] = dbus.Array( - [a.name for a in artists if a.name], signature='s') - if track.album and track.album.images: - url = list(track.album.images)[0] - if url: - metadata['mpris:artUrl'] = url - if track.disc_no: - metadata['xesam:discNumber'] = track.disc_no - if track.track_no: - metadata['xesam:trackNumber'] = track.track_no - return dbus.Dictionary(metadata, signature='sv') - - def get_Volume(self): - volume = self.core.playback.volume.get() - if volume is None: - return 0 - return volume / 100.0 - - def set_Volume(self, value): - if not self.get_CanControl(): - logger.debug('Setting %s.Volume not allowed', PLAYER_IFACE) - return - if value is None: - return - elif value < 0: - self.core.playback.volume = 0 - elif value > 1: - self.core.playback.volume = 100 - elif 0 <= value <= 1: - self.core.playback.volume = int(value * 100) - - def get_Position(self): - return self.core.playback.time_position.get() * 1000 - - def get_CanGoNext(self): - if not self.get_CanControl(): - return False - tl_track = self.core.playback.current_tl_track.get() - return ( - self.core.tracklist.next_track(tl_track).get() != - self.core.playback.current_tl_track.get()) - - def get_CanGoPrevious(self): - if not self.get_CanControl(): - return False - tl_track = self.core.playback.current_tl_track.get() - return ( - self.core.tracklist.previous_track(tl_track).get() != - tl_track) - - def get_CanPlay(self): - if not self.get_CanControl(): - return False - tl_track = self.core.playback.current_tl_track.get() - return ( - self.core.playback.current_tl_track.get() is not None or - self.core.tracklist.next_track(tl_track).get() is not None) - - def get_CanPause(self): - if not self.get_CanControl(): - return False - # NOTE Should be changed to vary based on capabilities of the current - # track if Mopidy starts supporting non-seekable media, like streams. - return True - - def get_CanSeek(self): - if not self.get_CanControl(): - return False - # NOTE Should be changed to vary based on capabilities of the current - # track if Mopidy starts supporting non-seekable media, like streams. - return True - - def get_CanControl(self): - # NOTE This could be a setting for the end user to change. - return True - - ### Playlists interface methods - - @dbus.service.method(dbus_interface=PLAYLISTS_IFACE) - def ActivatePlaylist(self, playlist_id): - logger.debug( - '%s.ActivatePlaylist(%r) called', PLAYLISTS_IFACE, playlist_id) - playlist_uri = self.get_playlist_uri(playlist_id) - playlist = self.core.playlists.lookup(playlist_uri).get() - if playlist and playlist.tracks: - tl_tracks = self.core.tracklist.add(playlist.tracks).get() - self.core.playback.play(tl_tracks[0]) - - @dbus.service.method(dbus_interface=PLAYLISTS_IFACE) - def GetPlaylists(self, index, max_count, order, reverse): - logger.debug( - '%s.GetPlaylists(%r, %r, %r, %r) called', - PLAYLISTS_IFACE, index, max_count, order, reverse) - playlists = self.core.playlists.playlists.get() - if order == 'Alphabetical': - playlists.sort(key=lambda p: p.name, reverse=reverse) - elif order == 'Modified': - playlists.sort(key=lambda p: p.last_modified, reverse=reverse) - elif order == 'User' and reverse: - playlists.reverse() - slice_end = index + max_count - playlists = playlists[index:slice_end] - results = [ - (self.get_playlist_id(p.uri), p.name, '') - for p in playlists] - return dbus.Array(results, signature='(oss)') - - ### Playlists interface signals - - @dbus.service.signal(dbus_interface=PLAYLISTS_IFACE, signature='(oss)') - def PlaylistChanged(self, playlist): - logger.debug('%s.PlaylistChanged signaled', PLAYLISTS_IFACE) - # Do nothing, as just calling the method is enough to emit the signal. - - ### Playlists interface properties - - def get_PlaylistCount(self): - return len(self.core.playlists.playlists.get()) - - def get_Orderings(self): - return [ - 'Alphabetical', # Order by playlist.name - 'Modified', # Order by playlist.last_modified - 'User', # Don't change order - ] - - def get_ActivePlaylist(self): - playlist_is_valid = False - playlist = ('/', 'None', '') - return (playlist_is_valid, playlist) diff --git a/mopidy/frontends/scrobbler/__init__.py b/mopidy/frontends/scrobbler/__init__.py deleted file mode 100644 index c08bc15e..00000000 --- a/mopidy/frontends/scrobbler/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import unicode_literals - -import os - -import mopidy -from mopidy import config, exceptions, ext - - -class Extension(ext.Extension): - - dist_name = 'Mopidy-Scrobbler' - ext_name = 'scrobbler' - version = mopidy.__version__ - - def get_default_config(self): - conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return config.read(conf_file) - - def get_config_schema(self): - schema = super(Extension, self).get_config_schema() - schema['username'] = config.String() - schema['password'] = config.Secret() - return schema - - def validate_environment(self): - try: - import pylast # noqa - except ImportError as e: - raise exceptions.ExtensionError('pylast library not found', e) - - def get_frontend_classes(self): - from .actor import ScrobblerFrontend - return [ScrobblerFrontend] diff --git a/mopidy/frontends/scrobbler/actor.py b/mopidy/frontends/scrobbler/actor.py deleted file mode 100644 index 2343e0cb..00000000 --- a/mopidy/frontends/scrobbler/actor.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import unicode_literals - -import logging -import time - -import pykka -import pylast - -from mopidy.core import CoreListener - - -logger = logging.getLogger('mopidy.frontends.scrobbler') - -API_KEY = '2236babefa8ebb3d93ea467560d00d04' -API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd' - - -class ScrobblerFrontend(pykka.ThreadingActor, CoreListener): - def __init__(self, config, core): - super(ScrobblerFrontend, self).__init__() - self.config = config - self.lastfm = None - self.last_start_time = None - - def on_start(self): - try: - self.lastfm = pylast.LastFMNetwork( - api_key=API_KEY, api_secret=API_SECRET, - username=self.config['scrobbler']['username'], - password_hash=pylast.md5(self.config['scrobbler']['password'])) - logger.info('Scrobbler connected to Last.fm') - except (pylast.NetworkError, pylast.MalformedResponseError, - pylast.WSError) as e: - logger.error('Error during Last.fm setup: %s', e) - self.stop() - - def track_playback_started(self, tl_track): - track = tl_track.track - artists = ', '.join([a.name for a in track.artists]) - duration = track.length and track.length // 1000 or 0 - self.last_start_time = int(time.time()) - logger.debug('Now playing track: %s - %s', artists, track.name) - try: - self.lastfm.update_now_playing( - artists, - (track.name or ''), - album=(track.album and track.album.name or ''), - duration=str(duration), - track_number=str(track.track_no), - mbid=(track.musicbrainz_id or '')) - except (pylast.ScrobblingError, pylast.NetworkError, - pylast.MalformedResponseError, pylast.WSError) as e: - logger.warning('Error submitting playing track to Last.fm: %s', e) - - def track_playback_ended(self, tl_track, time_position): - track = tl_track.track - artists = ', '.join([a.name for a in track.artists]) - duration = track.length and track.length // 1000 or 0 - time_position = time_position // 1000 - if duration < 30: - logger.debug('Track too short to scrobble. (30s)') - return - if time_position < duration // 2 and time_position < 240: - logger.debug( - 'Track not played long enough to scrobble. (50% or 240s)') - return - if self.last_start_time is None: - self.last_start_time = int(time.time()) - duration - logger.debug('Scrobbling track: %s - %s', artists, track.name) - try: - self.lastfm.scrobble( - artists, - (track.name or ''), - str(self.last_start_time), - album=(track.album and track.album.name or ''), - track_number=str(track.track_no), - duration=str(duration), - mbid=(track.musicbrainz_id or '')) - except (pylast.ScrobblingError, pylast.NetworkError, - pylast.MalformedResponseError, pylast.WSError) as e: - logger.warning('Error submitting played track to Last.fm: %s', e) diff --git a/mopidy/frontends/scrobbler/ext.conf b/mopidy/frontends/scrobbler/ext.conf deleted file mode 100644 index 4fded92f..00000000 --- a/mopidy/frontends/scrobbler/ext.conf +++ /dev/null @@ -1,4 +0,0 @@ -[scrobbler] -enabled = true -username = -password = diff --git a/requirements/scrobbler.txt b/requirements/scrobbler.txt deleted file mode 100644 index c52256c3..00000000 --- a/requirements/scrobbler.txt +++ /dev/null @@ -1,3 +0,0 @@ -pylast >= 0.5.7 -# Available as python-pylast in newer Debian/Ubuntu and from apt.mopidy.com for -# older releases of Debian/Ubuntu diff --git a/requirements/spotify.txt b/requirements/spotify.txt deleted file mode 100644 index d11a5c04..00000000 --- a/requirements/spotify.txt +++ /dev/null @@ -1,8 +0,0 @@ -pyspotify >= 1.9, < 2 -# The libspotify Python wrapper -# Available as the python-spotify package from apt.mopidy.com - -# libspotify >= 12, < 13 -# The libspotify C library from -# https://developer.spotify.com/technologies/libspotify/ -# Available as the libspotify12 package from apt.mopidy.com diff --git a/setup.py b/setup.py index c5eea724..a448a029 100644 --- a/setup.py +++ b/setup.py @@ -28,8 +28,6 @@ setup( 'Pykka >= 1.1', ], extras_require={ - 'spotify': ['pyspotify >= 1.9, < 2'], - 'scrobbler': ['pylast >= 0.5.7'], 'http': ['cherrypy >= 3.2.2', 'ws4py >= 0.2.3'], }, test_suite='nose.collector', @@ -45,11 +43,8 @@ setup( ], 'mopidy.ext': [ 'http = mopidy.frontends.http:Extension [http]', - 'scrobbler = mopidy.frontends.scrobbler:Extension [scrobbler]', 'local = mopidy.backends.local:Extension', 'mpd = mopidy.frontends.mpd:Extension', - 'mpris = mopidy.frontends.mpris:Extension', - 'spotify = mopidy.backends.spotify:Extension [spotify]', 'stream = mopidy.backends.stream:Extension', ], }, diff --git a/tests/audio/actor_test.py b/tests/audio/actor_test.py index 617131cc..eac299cf 100644 --- a/tests/audio/actor_test.py +++ b/tests/audio/actor_test.py @@ -6,6 +6,9 @@ import pygst pygst.require('0.10') import gst +import gobject +gobject.threads_init() + import pykka from mopidy import audio @@ -80,6 +83,22 @@ class AudioTest(unittest.TestCase): self.assertTrue(self.audio.set_volume(value).get()) self.assertEqual(value, self.audio.get_volume().get()) + def test_set_volume_with_mixer_min_equal_max(self): + config = { + 'audio': { + 'mixer': 'fakemixer track_max_volume=0', + 'mixer_track': None, + 'output': 'fakesink', + 'visualizer': None, + } + } + self.audio = audio.Audio.start(config=config).proxy() + self.assertEqual(0, self.audio.get_volume().get()) + + @unittest.SkipTest + def test_set_mute(self): + pass # TODO Probably needs a fakemixer with a mixer track + @unittest.SkipTest def test_set_state_encapsulation(self): pass # TODO diff --git a/tests/audio/playlists_test.py b/tests/audio/playlists_test.py new file mode 100644 index 00000000..0f031736 --- /dev/null +++ b/tests/audio/playlists_test.py @@ -0,0 +1,128 @@ +#encoding: utf-8 + +from __future__ import unicode_literals + +import io +import unittest + +from mopidy.audio import playlists + + +BAD = b'foobarbaz' + +M3U = b"""#EXTM3U +#EXTINF:123, Sample artist - Sample title +file:///tmp/foo +#EXTINF:321,Example Artist - Example title +file:///tmp/bar +#EXTINF:213,Some Artist - Other title +file:///tmp/baz +""" + +PLS = b"""[Playlist] +NumberOfEntries=3 +File1=file:///tmp/foo +Title1=Sample Title +Length1=123 +File2=file:///tmp/bar +Title2=Example title +Length2=321 +File3=file:///tmp/baz +Title3=Other title +Length3=213 +Version=2 +""" + +ASX = b""" + Example + + Sample Title + + + + Example title + + + + Other title + + + +""" + +XSPF = b""" + + + + Sample Title + file:///tmp/foo + + + Example title + file:///tmp/bar + + + Other title + file:///tmp/baz + + + +""" + + +class TypeFind(object): + def __init__(self, data): + self.data = data + + def peek(self, start, end): + return self.data[start:end] + + +class BasePlaylistTest(object): + valid = None + invalid = None + detect = None + parse = None + + def test_detect_valid_header(self): + self.assertTrue(self.detect(TypeFind(self.valid))) + + def test_detect_invalid_header(self): + self.assertFalse(self.detect(TypeFind(self.invalid))) + + def test_parse_valid_playlist(self): + uris = list(self.parse(io.BytesIO(self.valid))) + expected = [b'file:///tmp/foo', b'file:///tmp/bar', b'file:///tmp/baz'] + self.assertEqual(uris, expected) + + def test_parse_invalid_playlist(self): + uris = list(self.parse(io.BytesIO(self.invalid))) + self.assertEqual(uris, []) + + +class M3uPlaylistTest(BasePlaylistTest, unittest.TestCase): + valid = M3U + invalid = BAD + detect = staticmethod(playlists.detect_m3u_header) + parse = staticmethod(playlists.parse_m3u) + + +class PlsPlaylistTest(BasePlaylistTest, unittest.TestCase): + valid = PLS + invalid = BAD + detect = staticmethod(playlists.detect_pls_header) + parse = staticmethod(playlists.parse_pls) + + +class AsxPlsPlaylistTest(BasePlaylistTest, unittest.TestCase): + valid = ASX + invalid = BAD + detect = staticmethod(playlists.detect_asx_header) + parse = staticmethod(playlists.parse_asx) + + +class XspfPlaylistTest(BasePlaylistTest, unittest.TestCase): + valid = XSPF + invalid = BAD + detect = staticmethod(playlists.detect_xspf_header) + parse = staticmethod(playlists.parse_xspf) diff --git a/tests/core/listener_test.py b/tests/core/listener_test.py index bf3a235d..3678451d 100644 --- a/tests/core/listener_test.py +++ b/tests/core/listener_test.py @@ -49,7 +49,10 @@ class CoreListenerTest(unittest.TestCase): self.listener.options_changed() def test_listener_has_default_impl_for_volume_changed(self): - self.listener.volume_changed() + self.listener.volume_changed(70) + + def test_listener_has_default_impl_for_mute_changed(self): + self.listener.mute_changed(True) def test_listener_has_default_impl_for_seeked(self): self.listener.seeked(0) diff --git a/tests/core/playback_test.py b/tests/core/playback_test.py index 74f8a105..f3374547 100644 --- a/tests/core/playback_test.py +++ b/tests/core/playback_test.py @@ -177,3 +177,10 @@ class CorePlaybackTest(unittest.TestCase): self.assertEqual(result, 0) self.assertFalse(self.playback1.get_time_position.called) self.assertFalse(self.playback2.get_time_position.called) + + def test_mute(self): + self.assertEqual(self.core.playback.mute, False) + + self.core.playback.mute = True + + self.assertEqual(self.core.playback.mute, True) diff --git a/tests/frontends/mpd/protocol/audio_output_test.py b/tests/frontends/mpd/protocol/audio_output_test.py index 560e935f..cbfb5043 100644 --- a/tests/frontends/mpd/protocol/audio_output_test.py +++ b/tests/frontends/mpd/protocol/audio_output_test.py @@ -5,16 +5,48 @@ from tests.frontends.mpd import protocol class AudioOutputHandlerTest(protocol.BaseTestCase): def test_enableoutput(self): + self.core.playback.mute = True + self.sendRequest('enableoutput "0"') - self.assertInResponse('ACK [0@0] {} Not implemented') + + self.assertInResponse('OK') + self.assertEqual(self.core.playback.mute.get(), False) + + def test_enableoutput_unknown_outputid(self): + self.sendRequest('enableoutput "7"') + + self.assertInResponse('ACK [50@0] {enableoutput} No such audio output') def test_disableoutput(self): - self.sendRequest('disableoutput "0"') - self.assertInResponse('ACK [0@0] {} Not implemented') + self.core.playback.mute = False + + self.sendRequest('disableoutput "0"') + + self.assertInResponse('OK') + self.assertEqual(self.core.playback.mute.get(), True) + + def test_disableoutput_unknown_outputid(self): + self.sendRequest('disableoutput "7"') + + self.assertInResponse( + 'ACK [50@0] {disableoutput} No such audio output') + + def test_outputs_when_unmuted(self): + self.core.playback.mute = False - def test_outputs(self): self.sendRequest('outputs') + self.assertInResponse('outputid: 0') - self.assertInResponse('outputname: Default') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') + + def test_outputs_when_muted(self): + self.core.playback.mute = True + + self.sendRequest('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') self.assertInResponse('outputenabled: 1') self.assertInResponse('OK') diff --git a/tests/frontends/mpris/__init__.py b/tests/frontends/mpris/__init__.py deleted file mode 100644 index baffc488..00000000 --- a/tests/frontends/mpris/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import unicode_literals diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py deleted file mode 100644 index 0a4bc79f..00000000 --- a/tests/frontends/mpris/events_test.py +++ /dev/null @@ -1,92 +0,0 @@ -from __future__ import unicode_literals - -import mock -import unittest - -try: - import dbus -except ImportError: - dbus = False - -from mopidy.models import Playlist, TlTrack - -if dbus: - from mopidy.frontends.mpris import actor, objects - - -@unittest.skipUnless(dbus, 'dbus not found') -class BackendEventsTest(unittest.TestCase): - def setUp(self): - # As a plain class, not an actor: - self.mpris_frontend = actor.MprisFrontend(config=None, core=None) - self.mpris_object = mock.Mock(spec=objects.MprisObject) - self.mpris_frontend.mpris_object = self.mpris_object - - def test_track_playback_paused_event_changes_playback_status(self): - self.mpris_object.Get.return_value = 'Paused' - self.mpris_frontend.track_playback_paused(TlTrack(), 0) - self.assertListEqual(self.mpris_object.Get.call_args_list, [ - ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), - ]) - self.mpris_object.PropertiesChanged.assert_called_with( - objects.PLAYER_IFACE, {'PlaybackStatus': 'Paused'}, []) - - def test_track_playback_resumed_event_changes_playback_status(self): - self.mpris_object.Get.return_value = 'Playing' - self.mpris_frontend.track_playback_resumed(TlTrack(), 0) - self.assertListEqual(self.mpris_object.Get.call_args_list, [ - ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), - ]) - self.mpris_object.PropertiesChanged.assert_called_with( - objects.PLAYER_IFACE, {'PlaybackStatus': 'Playing'}, []) - - def test_track_playback_started_changes_playback_status_and_metadata(self): - self.mpris_object.Get.return_value = '...' - self.mpris_frontend.track_playback_started(TlTrack()) - self.assertListEqual(self.mpris_object.Get.call_args_list, [ - ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), - ((objects.PLAYER_IFACE, 'Metadata'), {}), - ]) - self.mpris_object.PropertiesChanged.assert_called_with( - objects.PLAYER_IFACE, - {'Metadata': '...', 'PlaybackStatus': '...'}, []) - - def test_track_playback_ended_changes_playback_status_and_metadata(self): - self.mpris_object.Get.return_value = '...' - self.mpris_frontend.track_playback_ended(TlTrack(), 0) - self.assertListEqual(self.mpris_object.Get.call_args_list, [ - ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), - ((objects.PLAYER_IFACE, 'Metadata'), {}), - ]) - self.mpris_object.PropertiesChanged.assert_called_with( - objects.PLAYER_IFACE, - {'Metadata': '...', 'PlaybackStatus': '...'}, []) - - def test_volume_changed_event_changes_volume(self): - self.mpris_object.Get.return_value = 1.0 - self.mpris_frontend.volume_changed(volume=100) - self.assertListEqual(self.mpris_object.Get.call_args_list, [ - ((objects.PLAYER_IFACE, 'Volume'), {}), - ]) - self.mpris_object.PropertiesChanged.assert_called_with( - objects.PLAYER_IFACE, {'Volume': 1.0}, []) - - def test_seeked_event_causes_mpris_seeked_event(self): - self.mpris_frontend.seeked(31000) - self.mpris_object.Seeked.assert_called_with(31000000) - - def test_playlists_loaded_event_changes_playlist_count(self): - self.mpris_object.Get.return_value = 17 - self.mpris_frontend.playlists_loaded() - self.assertListEqual(self.mpris_object.Get.call_args_list, [ - ((objects.PLAYLISTS_IFACE, 'PlaylistCount'), {}), - ]) - self.mpris_object.PropertiesChanged.assert_called_with( - objects.PLAYLISTS_IFACE, {'PlaylistCount': 17}, []) - - def test_playlist_changed_event_causes_mpris_playlist_changed_event(self): - self.mpris_object.get_playlist_id.return_value = 'id-for-dummy:foo' - playlist = Playlist(uri='dummy:foo', name='foo') - self.mpris_frontend.playlist_changed(playlist) - self.mpris_object.PlaylistChanged.assert_called_with( - ('id-for-dummy:foo', 'foo', '')) diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py deleted file mode 100644 index 4cd903e6..00000000 --- a/tests/frontends/mpris/player_interface_test.py +++ /dev/null @@ -1,869 +0,0 @@ -from __future__ import unicode_literals - -import mock -import unittest - -import pykka - -try: - import dbus -except ImportError: - dbus = False - -from mopidy import core -from mopidy.backends import dummy -from mopidy.core import PlaybackState -from mopidy.models import Album, Artist, Track - -if dbus: - from mopidy.frontends.mpris import objects - -PLAYING = PlaybackState.PLAYING -PAUSED = PlaybackState.PAUSED -STOPPED = PlaybackState.STOPPED - - -@unittest.skipUnless(dbus, 'dbus not found') -class PlayerInterfaceTest(unittest.TestCase): - def setUp(self): - objects.MprisObject._connect_to_dbus = mock.Mock() - self.backend = dummy.create_dummy_backend_proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() - self.mpris = objects.MprisObject(config={}, core=self.core) - - def tearDown(self): - pykka.ActorRegistry.stop_all() - - def test_get_playback_status_is_playing_when_playing(self): - self.core.playback.state = PLAYING - result = self.mpris.Get(objects.PLAYER_IFACE, 'PlaybackStatus') - self.assertEqual('Playing', result) - - def test_get_playback_status_is_paused_when_paused(self): - self.core.playback.state = PAUSED - result = self.mpris.Get(objects.PLAYER_IFACE, 'PlaybackStatus') - self.assertEqual('Paused', result) - - def test_get_playback_status_is_stopped_when_stopped(self): - self.core.playback.state = STOPPED - result = self.mpris.Get(objects.PLAYER_IFACE, 'PlaybackStatus') - self.assertEqual('Stopped', result) - - def test_get_loop_status_is_none_when_not_looping(self): - self.core.tracklist.repeat = False - self.core.tracklist.single = False - result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') - self.assertEqual('None', result) - - def test_get_loop_status_is_track_when_looping_a_single_track(self): - self.core.tracklist.repeat = True - self.core.tracklist.single = True - result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') - self.assertEqual('Track', result) - - def test_get_loop_status_is_playlist_when_looping_tracklist(self): - self.core.tracklist.repeat = True - self.core.tracklist.single = False - result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') - self.assertEqual('Playlist', result) - - def test_set_loop_status_is_ignored_if_can_control_is_false(self): - self.mpris.get_CanControl = lambda *_: False - self.core.tracklist.repeat = True - self.core.tracklist.single = True - self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'None') - self.assertEqual(self.core.tracklist.repeat.get(), True) - self.assertEqual(self.core.tracklist.single.get(), True) - - def test_set_loop_status_to_none_unsets_repeat_and_single(self): - self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'None') - self.assertEqual(self.core.tracklist.repeat.get(), False) - self.assertEqual(self.core.tracklist.single.get(), False) - - def test_set_loop_status_to_track_sets_repeat_and_single(self): - self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'Track') - self.assertEqual(self.core.tracklist.repeat.get(), True) - self.assertEqual(self.core.tracklist.single.get(), True) - - def test_set_loop_status_to_playlists_sets_repeat_and_not_single(self): - self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'Playlist') - self.assertEqual(self.core.tracklist.repeat.get(), True) - self.assertEqual(self.core.tracklist.single.get(), False) - - def test_get_rate_is_greater_or_equal_than_minimum_rate(self): - rate = self.mpris.Get(objects.PLAYER_IFACE, 'Rate') - minimum_rate = self.mpris.Get(objects.PLAYER_IFACE, 'MinimumRate') - self.assertGreaterEqual(rate, minimum_rate) - - def test_get_rate_is_less_or_equal_than_maximum_rate(self): - rate = self.mpris.Get(objects.PLAYER_IFACE, 'Rate') - maximum_rate = self.mpris.Get(objects.PLAYER_IFACE, 'MaximumRate') - self.assertGreaterEqual(rate, maximum_rate) - - def test_set_rate_is_ignored_if_can_control_is_false(self): - self.mpris.get_CanControl = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.Set(objects.PLAYER_IFACE, 'Rate', 0) - self.assertEqual(self.core.playback.state.get(), PLAYING) - - def test_set_rate_to_zero_pauses_playback(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.Set(objects.PLAYER_IFACE, 'Rate', 0) - self.assertEqual(self.core.playback.state.get(), PAUSED) - - def test_get_shuffle_returns_true_if_random_is_active(self): - self.core.tracklist.random = True - result = self.mpris.Get(objects.PLAYER_IFACE, 'Shuffle') - self.assertTrue(result) - - def test_get_shuffle_returns_false_if_random_is_inactive(self): - self.core.tracklist.random = False - result = self.mpris.Get(objects.PLAYER_IFACE, 'Shuffle') - self.assertFalse(result) - - def test_set_shuffle_is_ignored_if_can_control_is_false(self): - self.mpris.get_CanControl = lambda *_: False - self.core.tracklist.random = False - self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) - self.assertFalse(self.core.tracklist.random.get()) - - def test_set_shuffle_to_true_activates_random_mode(self): - self.core.tracklist.random = False - self.assertFalse(self.core.tracklist.random.get()) - self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) - self.assertTrue(self.core.tracklist.random.get()) - - def test_set_shuffle_to_false_deactivates_random_mode(self): - self.core.tracklist.random = True - self.assertTrue(self.core.tracklist.random.get()) - self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', False) - self.assertFalse(self.core.tracklist.random.get()) - - def test_get_metadata_has_trackid_even_when_no_current_track(self): - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('mpris:trackid', result.keys()) - self.assertEqual(result['mpris:trackid'], '') - - def test_get_metadata_has_trackid_based_on_tlid(self): - self.core.tracklist.add([Track(uri='dummy:a')]) - self.core.playback.play() - (tlid, track) = self.core.playback.current_tl_track.get() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('mpris:trackid', result.keys()) - self.assertEqual( - result['mpris:trackid'], '/com/mopidy/track/%d' % tlid) - - def test_get_metadata_has_track_length(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('mpris:length', result.keys()) - self.assertEqual(result['mpris:length'], 40000000) - - def test_get_metadata_has_track_uri(self): - self.core.tracklist.add([Track(uri='dummy:a')]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('xesam:url', result.keys()) - self.assertEqual(result['xesam:url'], 'dummy:a') - - def test_get_metadata_has_track_title(self): - self.core.tracklist.add([Track(name='a')]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('xesam:title', result.keys()) - self.assertEqual(result['xesam:title'], 'a') - - def test_get_metadata_has_track_artists(self): - self.core.tracklist.add([Track(artists=[ - Artist(name='a'), Artist(name='b'), Artist(name=None)])]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('xesam:artist', result.keys()) - self.assertEqual(result['xesam:artist'], ['a', 'b']) - - def test_get_metadata_has_track_album(self): - self.core.tracklist.add([Track(album=Album(name='a'))]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('xesam:album', result.keys()) - self.assertEqual(result['xesam:album'], 'a') - - def test_get_metadata_has_track_album_artists(self): - self.core.tracklist.add([Track(album=Album(artists=[ - Artist(name='a'), Artist(name='b'), Artist(name=None)]))]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('xesam:albumArtist', result.keys()) - self.assertEqual(result['xesam:albumArtist'], ['a', 'b']) - - def test_get_metadata_use_first_album_image_as_art_url(self): - # XXX Currently, the album image order isn't preserved because they - # are stored as a frozenset(). We pick the first in the set, which is - # sorted alphabetically, thus we get 'bar.jpg', not 'foo.jpg', which - # would probably make more sense. - self.core.tracklist.add([Track(album=Album(images=[ - 'http://example.com/foo.jpg', 'http://example.com/bar.jpg']))]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('mpris:artUrl', result.keys()) - self.assertEqual(result['mpris:artUrl'], 'http://example.com/bar.jpg') - - def test_get_metadata_has_no_art_url_if_no_album(self): - self.core.tracklist.add([Track()]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertNotIn('mpris:artUrl', result.keys()) - - def test_get_metadata_has_no_art_url_if_no_album_images(self): - self.core.tracklist.add([Track(Album(images=[]))]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertNotIn('mpris:artUrl', result.keys()) - - def test_get_metadata_has_disc_number_in_album(self): - self.core.tracklist.add([Track(disc_no=2)]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('xesam:discNumber', result.keys()) - self.assertEqual(result['xesam:discNumber'], 2) - - def test_get_metadata_has_track_number_in_album(self): - self.core.tracklist.add([Track(track_no=7)]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assertIn('xesam:trackNumber', result.keys()) - self.assertEqual(result['xesam:trackNumber'], 7) - - def test_get_volume_should_return_volume_between_zero_and_one(self): - self.core.playback.volume = None - result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') - self.assertEqual(result, 0) - - self.core.playback.volume = 0 - result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') - self.assertEqual(result, 0) - - self.core.playback.volume = 50 - result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') - self.assertEqual(result, 0.5) - - self.core.playback.volume = 100 - result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') - self.assertEqual(result, 1) - - def test_set_volume_is_ignored_if_can_control_is_false(self): - self.mpris.get_CanControl = lambda *_: False - self.core.playback.volume = 0 - self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) - self.assertEqual(self.core.playback.volume.get(), 0) - - def test_set_volume_to_one_should_set_mixer_volume_to_100(self): - self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) - self.assertEqual(self.core.playback.volume.get(), 100) - - def test_set_volume_to_anything_above_one_sets_mixer_volume_to_100(self): - self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 2.0) - self.assertEqual(self.core.playback.volume.get(), 100) - - def test_set_volume_to_anything_not_a_number_does_not_change_volume(self): - self.core.playback.volume = 10 - self.mpris.Set(objects.PLAYER_IFACE, 'Volume', None) - self.assertEqual(self.core.playback.volume.get(), 10) - - def test_get_position_returns_time_position_in_microseconds(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - self.core.playback.seek(10000) - result_in_microseconds = self.mpris.Get( - objects.PLAYER_IFACE, 'Position') - result_in_milliseconds = result_in_microseconds // 1000 - self.assertGreaterEqual(result_in_milliseconds, 10000) - - def test_get_position_when_no_current_track_should_be_zero(self): - result_in_microseconds = self.mpris.Get( - objects.PLAYER_IFACE, 'Position') - result_in_milliseconds = result_in_microseconds // 1000 - self.assertEqual(result_in_milliseconds, 0) - - def test_get_minimum_rate_is_one_or_less(self): - result = self.mpris.Get(objects.PLAYER_IFACE, 'MinimumRate') - self.assertLessEqual(result, 1.0) - - def test_get_maximum_rate_is_one_or_more(self): - result = self.mpris.Get(objects.PLAYER_IFACE, 'MaximumRate') - self.assertGreaterEqual(result, 1.0) - - def test_can_go_next_is_true_if_can_control_and_other_next_track(self): - self.mpris.get_CanControl = lambda *_: True - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') - self.assertTrue(result) - - def test_can_go_next_is_false_if_next_track_is_the_same(self): - self.mpris.get_CanControl = lambda *_: True - self.core.tracklist.add([Track(uri='dummy:a')]) - self.core.tracklist.repeat = True - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') - self.assertFalse(result) - - def test_can_go_next_is_false_if_can_control_is_false(self): - self.mpris.get_CanControl = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') - self.assertFalse(result) - - def test_can_go_previous_is_true_if_can_control_and_previous_track(self): - self.mpris.get_CanControl = lambda *_: True - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.next() - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') - self.assertTrue(result) - - def test_can_go_previous_is_false_if_previous_track_is_the_same(self): - self.mpris.get_CanControl = lambda *_: True - self.core.tracklist.add([Track(uri='dummy:a')]) - self.core.tracklist.repeat = True - self.core.playback.play() - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') - self.assertFalse(result) - - def test_can_go_previous_is_false_if_can_control_is_false(self): - self.mpris.get_CanControl = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.next() - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') - self.assertFalse(result) - - def test_can_play_is_true_if_can_control_and_current_track(self): - self.mpris.get_CanControl = lambda *_: True - self.core.tracklist.add([Track(uri='dummy:a')]) - self.core.playback.play() - self.assertTrue(self.core.playback.current_track.get()) - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') - self.assertTrue(result) - - def test_can_play_is_false_if_no_current_track(self): - self.mpris.get_CanControl = lambda *_: True - self.assertFalse(self.core.playback.current_track.get()) - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') - self.assertFalse(result) - - def test_can_play_if_false_if_can_control_is_false(self): - self.mpris.get_CanControl = lambda *_: False - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') - self.assertFalse(result) - - def test_can_pause_is_true_if_can_control_and_track_can_be_paused(self): - self.mpris.get_CanControl = lambda *_: True - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPause') - self.assertTrue(result) - - def test_can_pause_if_false_if_can_control_is_false(self): - self.mpris.get_CanControl = lambda *_: False - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPause') - self.assertFalse(result) - - def test_can_seek_is_true_if_can_control_is_true(self): - self.mpris.get_CanControl = lambda *_: True - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanSeek') - self.assertTrue(result) - - def test_can_seek_is_false_if_can_control_is_false(self): - self.mpris.get_CanControl = lambda *_: False - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanSeek') - self.assertFalse(result) - - def test_can_control_is_true(self): - result = self.mpris.Get(objects.PLAYER_IFACE, 'CanControl') - self.assertTrue(result) - - def test_next_is_ignored_if_can_go_next_is_false(self): - self.mpris.get_CanGoNext = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - self.mpris.Next() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - - def test_next_when_playing_skips_to_next_track_and_keep_playing(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.Next() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - self.assertEqual(self.core.playback.state.get(), PLAYING) - - def test_next_when_at_end_of_list_should_stop_playback(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.next() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.Next() - self.assertEqual(self.core.playback.state.get(), STOPPED) - - def test_next_when_paused_should_skip_to_next_track_and_stay_paused(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.pause() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - self.assertEqual(self.core.playback.state.get(), PAUSED) - self.mpris.Next() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - self.assertEqual(self.core.playback.state.get(), PAUSED) - - def test_next_when_stopped_skips_to_next_track_and_stay_stopped(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.stop() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - self.assertEqual(self.core.playback.state.get(), STOPPED) - self.mpris.Next() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - self.assertEqual(self.core.playback.state.get(), STOPPED) - - def test_previous_is_ignored_if_can_go_previous_is_false(self): - self.mpris.get_CanGoPrevious = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.next() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - self.mpris.Previous() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - - def test_previous_when_playing_skips_to_prev_track_and_keep_playing(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.next() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.Previous() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - self.assertEqual(self.core.playback.state.get(), PLAYING) - - def test_previous_when_at_start_of_list_should_stop_playback(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.Previous() - self.assertEqual(self.core.playback.state.get(), STOPPED) - - def test_previous_when_paused_skips_to_previous_track_and_pause(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.next() - self.core.playback.pause() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - self.assertEqual(self.core.playback.state.get(), PAUSED) - self.mpris.Previous() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - self.assertEqual(self.core.playback.state.get(), PAUSED) - - def test_previous_when_stopped_skips_to_previous_track_and_stops(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.next() - self.core.playback.stop() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - self.assertEqual(self.core.playback.state.get(), STOPPED) - self.mpris.Previous() - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - self.assertEqual(self.core.playback.state.get(), STOPPED) - - def test_pause_is_ignored_if_can_pause_is_false(self): - self.mpris.get_CanPause = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.Pause() - self.assertEqual(self.core.playback.state.get(), PLAYING) - - def test_pause_when_playing_should_pause_playback(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.Pause() - self.assertEqual(self.core.playback.state.get(), PAUSED) - - def test_pause_when_paused_has_no_effect(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.pause() - self.assertEqual(self.core.playback.state.get(), PAUSED) - self.mpris.Pause() - self.assertEqual(self.core.playback.state.get(), PAUSED) - - def test_playpause_is_ignored_if_can_pause_is_false(self): - self.mpris.get_CanPause = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.PlayPause() - self.assertEqual(self.core.playback.state.get(), PLAYING) - - def test_playpause_when_playing_should_pause_playback(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.PlayPause() - self.assertEqual(self.core.playback.state.get(), PAUSED) - - def test_playpause_when_paused_should_resume_playback(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.pause() - - self.assertEqual(self.core.playback.state.get(), PAUSED) - at_pause = self.core.playback.time_position.get() - self.assertGreaterEqual(at_pause, 0) - - self.mpris.PlayPause() - - self.assertEqual(self.core.playback.state.get(), PLAYING) - after_pause = self.core.playback.time_position.get() - self.assertGreaterEqual(after_pause, at_pause) - - def test_playpause_when_stopped_should_start_playback(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.assertEqual(self.core.playback.state.get(), STOPPED) - self.mpris.PlayPause() - self.assertEqual(self.core.playback.state.get(), PLAYING) - - def test_stop_is_ignored_if_can_control_is_false(self): - self.mpris.get_CanControl = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.Stop() - self.assertEqual(self.core.playback.state.get(), PLAYING) - - def test_stop_when_playing_should_stop_playback(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.mpris.Stop() - self.assertEqual(self.core.playback.state.get(), STOPPED) - - def test_stop_when_paused_should_stop_playback(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.pause() - self.assertEqual(self.core.playback.state.get(), PAUSED) - self.mpris.Stop() - self.assertEqual(self.core.playback.state.get(), STOPPED) - - def test_play_is_ignored_if_can_play_is_false(self): - self.mpris.get_CanPlay = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.assertEqual(self.core.playback.state.get(), STOPPED) - self.mpris.Play() - self.assertEqual(self.core.playback.state.get(), STOPPED) - - def test_play_when_stopped_starts_playback(self): - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.assertEqual(self.core.playback.state.get(), STOPPED) - self.mpris.Play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - - def test_play_after_pause_resumes_from_same_position(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - - before_pause = self.core.playback.time_position.get() - self.assertGreaterEqual(before_pause, 0) - - self.mpris.Pause() - self.assertEqual(self.core.playback.state.get(), PAUSED) - at_pause = self.core.playback.time_position.get() - self.assertGreaterEqual(at_pause, before_pause) - - self.mpris.Play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - after_pause = self.core.playback.time_position.get() - self.assertGreaterEqual(after_pause, at_pause) - - def test_play_when_there_is_no_track_has_no_effect(self): - self.core.tracklist.clear() - self.assertEqual(self.core.playback.state.get(), STOPPED) - self.mpris.Play() - self.assertEqual(self.core.playback.state.get(), STOPPED) - - def test_seek_is_ignored_if_can_seek_is_false(self): - self.mpris.get_CanSeek = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - - before_seek = self.core.playback.time_position.get() - self.assertGreaterEqual(before_seek, 0) - - milliseconds_to_seek = 10000 - microseconds_to_seek = milliseconds_to_seek * 1000 - - self.mpris.Seek(microseconds_to_seek) - - after_seek = self.core.playback.time_position.get() - self.assertLessEqual(before_seek, after_seek) - self.assertLess(after_seek, before_seek + milliseconds_to_seek) - - def test_seek_seeks_given_microseconds_forward_in_the_current_track(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - - before_seek = self.core.playback.time_position.get() - self.assertGreaterEqual(before_seek, 0) - - milliseconds_to_seek = 10000 - microseconds_to_seek = milliseconds_to_seek * 1000 - - self.mpris.Seek(microseconds_to_seek) - - self.assertEqual(self.core.playback.state.get(), PLAYING) - - after_seek = self.core.playback.time_position.get() - self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) - - def test_seek_seeks_given_microseconds_backward_if_negative(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - self.core.playback.seek(20000) - - before_seek = self.core.playback.time_position.get() - self.assertGreaterEqual(before_seek, 20000) - - milliseconds_to_seek = -10000 - microseconds_to_seek = milliseconds_to_seek * 1000 - - self.mpris.Seek(microseconds_to_seek) - - self.assertEqual(self.core.playback.state.get(), PLAYING) - - after_seek = self.core.playback.time_position.get() - self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) - self.assertLess(after_seek, before_seek) - - def test_seek_seeks_to_start_of_track_if_new_position_is_negative(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - self.core.playback.seek(20000) - - before_seek = self.core.playback.time_position.get() - self.assertGreaterEqual(before_seek, 20000) - - milliseconds_to_seek = -30000 - microseconds_to_seek = milliseconds_to_seek * 1000 - - self.mpris.Seek(microseconds_to_seek) - - self.assertEqual(self.core.playback.state.get(), PLAYING) - - after_seek = self.core.playback.time_position.get() - self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) - self.assertLess(after_seek, before_seek) - self.assertGreaterEqual(after_seek, 0) - - def test_seek_skips_to_next_track_if_new_position_gt_track_length(self): - self.core.tracklist.add([ - Track(uri='dummy:a', length=40000), - Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.seek(20000) - - before_seek = self.core.playback.time_position.get() - self.assertGreaterEqual(before_seek, 20000) - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - - milliseconds_to_seek = 50000 - microseconds_to_seek = milliseconds_to_seek * 1000 - - self.mpris.Seek(microseconds_to_seek) - - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - - after_seek = self.core.playback.time_position.get() - self.assertGreaterEqual(after_seek, 0) - self.assertLess(after_seek, before_seek) - - def test_set_position_is_ignored_if_can_seek_is_false(self): - self.mpris.get_CanSeek = lambda *_: False - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - - before_set_position = self.core.playback.time_position.get() - self.assertLessEqual(before_set_position, 5000) - - track_id = 'a' - - position_to_set_in_millisec = 20000 - position_to_set_in_microsec = position_to_set_in_millisec * 1000 - - self.mpris.SetPosition(track_id, position_to_set_in_microsec) - - after_set_position = self.core.playback.time_position.get() - self.assertLessEqual(before_set_position, after_set_position) - self.assertLess(after_set_position, position_to_set_in_millisec) - - def test_set_position_sets_the_current_track_position_in_microsecs(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - - before_set_position = self.core.playback.time_position.get() - self.assertLessEqual(before_set_position, 5000) - self.assertEqual(self.core.playback.state.get(), PLAYING) - - track_id = '/com/mopidy/track/0' - - position_to_set_in_millisec = 20000 - position_to_set_in_microsec = position_to_set_in_millisec * 1000 - - self.mpris.SetPosition(track_id, position_to_set_in_microsec) - - self.assertEqual(self.core.playback.state.get(), PLAYING) - - after_set_position = self.core.playback.time_position.get() - self.assertGreaterEqual( - after_set_position, position_to_set_in_millisec) - - def test_set_position_does_nothing_if_the_position_is_negative(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - self.core.playback.seek(20000) - - before_set_position = self.core.playback.time_position.get() - self.assertGreaterEqual(before_set_position, 20000) - self.assertLessEqual(before_set_position, 25000) - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - - track_id = '/com/mopidy/track/0' - - position_to_set_in_millisec = -1000 - position_to_set_in_microsec = position_to_set_in_millisec * 1000 - - self.mpris.SetPosition(track_id, position_to_set_in_microsec) - - after_set_position = self.core.playback.time_position.get() - self.assertGreaterEqual(after_set_position, before_set_position) - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - - def test_set_position_does_nothing_if_position_is_gt_track_length(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - self.core.playback.seek(20000) - - before_set_position = self.core.playback.time_position.get() - self.assertGreaterEqual(before_set_position, 20000) - self.assertLessEqual(before_set_position, 25000) - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - - track_id = 'a' - - position_to_set_in_millisec = 50000 - position_to_set_in_microsec = position_to_set_in_millisec * 1000 - - self.mpris.SetPosition(track_id, position_to_set_in_microsec) - - after_set_position = self.core.playback.time_position.get() - self.assertGreaterEqual(after_set_position, before_set_position) - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - - def test_set_position_is_noop_if_track_id_isnt_current_track(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) - self.core.playback.play() - self.core.playback.seek(20000) - - before_set_position = self.core.playback.time_position.get() - self.assertGreaterEqual(before_set_position, 20000) - self.assertLessEqual(before_set_position, 25000) - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - - track_id = 'b' - - position_to_set_in_millisec = 0 - position_to_set_in_microsec = position_to_set_in_millisec * 1000 - - self.mpris.SetPosition(track_id, position_to_set_in_microsec) - - after_set_position = self.core.playback.time_position.get() - self.assertGreaterEqual(after_set_position, before_set_position) - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - - def test_open_uri_is_ignored_if_can_play_is_false(self): - self.mpris.get_CanPlay = lambda *_: False - self.backend.library.dummy_library = [ - Track(uri='dummy:/test/uri')] - self.mpris.OpenUri('dummy:/test/uri') - self.assertEqual(len(self.core.tracklist.tracks.get()), 0) - - def test_open_uri_ignores_uris_with_unknown_uri_scheme(self): - self.assertListEqual(self.core.uri_schemes.get(), ['dummy']) - self.mpris.get_CanPlay = lambda *_: True - self.backend.library.dummy_library = [Track(uri='notdummy:/test/uri')] - self.mpris.OpenUri('notdummy:/test/uri') - self.assertEqual(len(self.core.tracklist.tracks.get()), 0) - - def test_open_uri_adds_uri_to_tracklist(self): - self.mpris.get_CanPlay = lambda *_: True - self.backend.library.dummy_library = [Track(uri='dummy:/test/uri')] - self.mpris.OpenUri('dummy:/test/uri') - self.assertEqual( - self.core.tracklist.tracks.get()[0].uri, 'dummy:/test/uri') - - def test_open_uri_starts_playback_of_new_track_if_stopped(self): - self.mpris.get_CanPlay = lambda *_: True - self.backend.library.dummy_library = [Track(uri='dummy:/test/uri')] - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.assertEqual(self.core.playback.state.get(), STOPPED) - - self.mpris.OpenUri('dummy:/test/uri') - - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual( - self.core.playback.current_track.get().uri, 'dummy:/test/uri') - - def test_open_uri_starts_playback_of_new_track_if_paused(self): - self.mpris.get_CanPlay = lambda *_: True - self.backend.library.dummy_library = [Track(uri='dummy:/test/uri')] - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.core.playback.pause() - self.assertEqual(self.core.playback.state.get(), PAUSED) - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - - self.mpris.OpenUri('dummy:/test/uri') - - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual( - self.core.playback.current_track.get().uri, 'dummy:/test/uri') - - def test_open_uri_starts_playback_of_new_track_if_playing(self): - self.mpris.get_CanPlay = lambda *_: True - self.backend.library.dummy_library = [Track(uri='dummy:/test/uri')] - self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) - self.core.playback.play() - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - - self.mpris.OpenUri('dummy:/test/uri') - - self.assertEqual(self.core.playback.state.get(), PLAYING) - self.assertEqual( - self.core.playback.current_track.get().uri, 'dummy:/test/uri') diff --git a/tests/frontends/mpris/playlists_interface_test.py b/tests/frontends/mpris/playlists_interface_test.py deleted file mode 100644 index f8e2cf3e..00000000 --- a/tests/frontends/mpris/playlists_interface_test.py +++ /dev/null @@ -1,172 +0,0 @@ -from __future__ import unicode_literals - -import datetime -import mock -import unittest - -import pykka - -try: - import dbus -except ImportError: - dbus = False - -from mopidy import core -from mopidy.audio import PlaybackState -from mopidy.backends import dummy -from mopidy.models import Track - -if dbus: - from mopidy.frontends.mpris import objects - - -@unittest.skipUnless(dbus, 'dbus not found') -class PlayerInterfaceTest(unittest.TestCase): - def setUp(self): - objects.MprisObject._connect_to_dbus = mock.Mock() - self.backend = dummy.create_dummy_backend_proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() - self.mpris = objects.MprisObject(config={}, core=self.core) - - foo = self.core.playlists.create('foo').get() - foo = foo.copy(last_modified=datetime.datetime(2012, 3, 1, 6, 0, 0)) - foo = self.core.playlists.save(foo).get() - - bar = self.core.playlists.create('bar').get() - bar = bar.copy(last_modified=datetime.datetime(2012, 2, 1, 6, 0, 0)) - bar = self.core.playlists.save(bar).get() - - baz = self.core.playlists.create('baz').get() - baz = baz.copy(last_modified=datetime.datetime(2012, 1, 1, 6, 0, 0)) - baz = self.core.playlists.save(baz).get() - self.playlist = baz - - def tearDown(self): - pykka.ActorRegistry.stop_all() - - def test_activate_playlist_appends_tracks_to_tracklist(self): - self.core.tracklist.add([ - Track(uri='dummy:old-a'), - Track(uri='dummy:old-b'), - ]) - self.playlist = self.playlist.copy(tracks=[ - Track(uri='dummy:baz-a'), - Track(uri='dummy:baz-b'), - Track(uri='dummy:baz-c'), - ]) - self.playlist = self.core.playlists.save(self.playlist).get() - - self.assertEqual(2, self.core.tracklist.length.get()) - - playlists = self.mpris.GetPlaylists(0, 100, 'User', False) - playlist_id = playlists[2][0] - self.mpris.ActivatePlaylist(playlist_id) - - self.assertEqual(5, self.core.tracklist.length.get()) - self.assertEqual( - PlaybackState.PLAYING, self.core.playback.state.get()) - self.assertEqual( - self.playlist.tracks[0], self.core.playback.current_track.get()) - - def test_activate_empty_playlist_is_harmless(self): - self.assertEqual(0, self.core.tracklist.length.get()) - - playlists = self.mpris.GetPlaylists(0, 100, 'User', False) - playlist_id = playlists[2][0] - self.mpris.ActivatePlaylist(playlist_id) - - self.assertEqual(0, self.core.tracklist.length.get()) - self.assertEqual( - PlaybackState.STOPPED, self.core.playback.state.get()) - self.assertIsNone(self.core.playback.current_track.get()) - - def test_get_playlists_in_alphabetical_order(self): - result = self.mpris.GetPlaylists(0, 100, 'Alphabetical', False) - - self.assertEqual(3, len(result)) - - self.assertEqual('/com/mopidy/playlist/MR2W23LZHJRGC4Q_', result[0][0]) - self.assertEqual('bar', result[0][1]) - - self.assertEqual('/com/mopidy/playlist/MR2W23LZHJRGC6Q_', result[1][0]) - self.assertEqual('baz', result[1][1]) - - self.assertEqual('/com/mopidy/playlist/MR2W23LZHJTG63Y_', result[2][0]) - self.assertEqual('foo', result[2][1]) - - def test_get_playlists_in_reverse_alphabetical_order(self): - result = self.mpris.GetPlaylists(0, 100, 'Alphabetical', True) - - self.assertEqual(3, len(result)) - self.assertEqual('foo', result[0][1]) - self.assertEqual('baz', result[1][1]) - self.assertEqual('bar', result[2][1]) - - def test_get_playlists_in_modified_order(self): - result = self.mpris.GetPlaylists(0, 100, 'Modified', False) - - self.assertEqual(3, len(result)) - self.assertEqual('baz', result[0][1]) - self.assertEqual('bar', result[1][1]) - self.assertEqual('foo', result[2][1]) - - def test_get_playlists_in_reverse_modified_order(self): - result = self.mpris.GetPlaylists(0, 100, 'Modified', True) - - self.assertEqual(3, len(result)) - self.assertEqual('foo', result[0][1]) - self.assertEqual('bar', result[1][1]) - self.assertEqual('baz', result[2][1]) - - def test_get_playlists_in_user_order(self): - result = self.mpris.GetPlaylists(0, 100, 'User', False) - - self.assertEqual(3, len(result)) - self.assertEqual('foo', result[0][1]) - self.assertEqual('bar', result[1][1]) - self.assertEqual('baz', result[2][1]) - - def test_get_playlists_in_reverse_user_order(self): - result = self.mpris.GetPlaylists(0, 100, 'User', True) - - self.assertEqual(3, len(result)) - self.assertEqual('baz', result[0][1]) - self.assertEqual('bar', result[1][1]) - self.assertEqual('foo', result[2][1]) - - def test_get_playlists_slice_on_start_of_list(self): - result = self.mpris.GetPlaylists(0, 2, 'User', False) - - self.assertEqual(2, len(result)) - self.assertEqual('foo', result[0][1]) - self.assertEqual('bar', result[1][1]) - - def test_get_playlists_slice_later_in_list(self): - result = self.mpris.GetPlaylists(2, 2, 'User', False) - - self.assertEqual(1, len(result)) - self.assertEqual('baz', result[0][1]) - - def test_get_playlist_count_returns_number_of_playlists(self): - result = self.mpris.Get(objects.PLAYLISTS_IFACE, 'PlaylistCount') - - self.assertEqual(3, result) - - def test_get_orderings_includes_alpha_modified_and_user(self): - result = self.mpris.Get(objects.PLAYLISTS_IFACE, 'Orderings') - - self.assertIn('Alphabetical', result) - self.assertNotIn('Created', result) - self.assertIn('Modified', result) - self.assertNotIn('Played', result) - self.assertIn('User', result) - - def test_get_active_playlist_does_not_return_a_playlist(self): - result = self.mpris.Get(objects.PLAYLISTS_IFACE, 'ActivePlaylist') - valid, playlist = result - playlist_id, playlist_name, playlist_icon_uri = playlist - - self.assertEqual(False, valid) - self.assertEqual('/', playlist_id) - self.assertEqual('None', playlist_name) - self.assertEqual('', playlist_icon_uri) diff --git a/tests/frontends/mpris/root_interface_test.py b/tests/frontends/mpris/root_interface_test.py deleted file mode 100644 index f95f0969..00000000 --- a/tests/frontends/mpris/root_interface_test.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import unicode_literals - -import mock -import unittest - -import pykka - -try: - import dbus -except ImportError: - dbus = False - -from mopidy import core -from mopidy.backends import dummy - -if dbus: - from mopidy.frontends.mpris import objects - - -@unittest.skipUnless(dbus, 'dbus not found') -class RootInterfaceTest(unittest.TestCase): - def setUp(self): - config = { - 'mpris': { - 'desktop_file': '/tmp/foo.desktop', - } - } - - objects.exit_process = mock.Mock() - objects.MprisObject._connect_to_dbus = mock.Mock() - self.backend = dummy.create_dummy_backend_proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() - self.mpris = objects.MprisObject(config=config, core=self.core) - - def tearDown(self): - pykka.ActorRegistry.stop_all() - - def test_constructor_connects_to_dbus(self): - self.assert_(self.mpris._connect_to_dbus.called) - - def test_fullscreen_returns_false(self): - result = self.mpris.Get(objects.ROOT_IFACE, 'Fullscreen') - self.assertFalse(result) - - def test_setting_fullscreen_fails_and_returns_none(self): - result = self.mpris.Set(objects.ROOT_IFACE, 'Fullscreen', 'True') - self.assertIsNone(result) - - def test_can_set_fullscreen_returns_false(self): - result = self.mpris.Get(objects.ROOT_IFACE, 'CanSetFullscreen') - self.assertFalse(result) - - def test_can_raise_returns_false(self): - result = self.mpris.Get(objects.ROOT_IFACE, 'CanRaise') - self.assertFalse(result) - - def test_raise_does_nothing(self): - self.mpris.Raise() - - def test_can_quit_returns_true(self): - result = self.mpris.Get(objects.ROOT_IFACE, 'CanQuit') - self.assertTrue(result) - - def test_quit_should_stop_all_actors(self): - self.mpris.Quit() - self.assert_(objects.exit_process.called) - - def test_has_track_list_returns_false(self): - result = self.mpris.Get(objects.ROOT_IFACE, 'HasTrackList') - self.assertFalse(result) - - def test_identify_is_mopidy(self): - result = self.mpris.Get(objects.ROOT_IFACE, 'Identity') - self.assertEquals(result, 'Mopidy') - - def test_desktop_entry_is_based_on_DESKTOP_FILE_setting(self): - result = self.mpris.Get(objects.ROOT_IFACE, 'DesktopEntry') - self.assertEquals(result, 'foo') - - def test_supported_uri_schemes_includes_backend_uri_schemes(self): - result = self.mpris.Get(objects.ROOT_IFACE, 'SupportedUriSchemes') - self.assertEquals(len(result), 1) - self.assertEquals(result[0], 'dummy') - - def test_supported_mime_types_is_empty(self): - result = self.mpris.Get(objects.ROOT_IFACE, 'SupportedMimeTypes') - self.assertEquals(len(result), 0) diff --git a/tests/version_test.py b/tests/version_test.py index 6503ef39..94fe4544 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -39,5 +39,6 @@ class VersionTest(unittest.TestCase): self.assertLess(SV('0.13.0'), SV('0.14.0')) self.assertLess(SV('0.14.0'), SV('0.14.1')) self.assertLess(SV('0.14.1'), SV('0.14.2')) - self.assertLess(SV('0.14.2'), SV(__version__)) - self.assertLess(SV(__version__), SV('0.15.1')) + self.assertLess(SV('0.14.2'), SV('0.15.0')) + self.assertLess(SV('0.15.0'), SV(__version__)) + self.assertLess(SV(__version__), SV('0.16.1'))