diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000000..e77617cb15 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,5 @@ +[report] +omit = + */pyshared/* + */python?.?/* + */site-packages/nose/* diff --git a/.gitignore b/.gitignore index 6ef1ff32d6..7923011093 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ docs/_build/ mopidy.log* node_modules/ nosetests.xml +*~ +*.orig diff --git a/.mailmap b/.mailmap index 2ff779fc4d..260770b86a 100644 --- a/.mailmap +++ b/.mailmap @@ -8,3 +8,5 @@ John Bäckstrand Alli Witheford Alexandre Petitjean Alexandre Petitjean +Javier Domingo Cansino +Lasse Bigum diff --git a/.travis.yml b/.travis.yml index 0b68eb8f02..b793e530b1 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 052865b7f1..e59b92e214 100644 --- a/AUTHORS +++ b/AUTHORS @@ -23,4 +23,7 @@ - Tobias Sauerwein - Alli Witheford - Alexandre Petitjean +- Terje Larsen +- Javier Domingo Cansino - Pavol Babincak +- Lasse Bigum diff --git a/MANIFEST.in b/MANIFEST.in index 6385e4ffdf..f1968205cb 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,8 +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 * prune docs/_build diff --git a/README.rst b/README.rst index c9db495e3b..515fa3ba88 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/backends.rst b/docs/api/backends.rst index 45315b276c..ec78f2500a 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -54,5 +54,4 @@ Backend implementations * :mod:`mopidy.backends.dummy` * :mod:`mopidy.backends.local` -* :mod:`mopidy.backends.spotify` * :mod:`mopidy.backends.stream` diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index 6da5d3370f..70bd73cfa5 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 0fb05f8caf..e45381a780 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,159 @@ Changelog This changelog is used to track all major changes to Mopidy. +v0.16.0 (2013-10-27) +==================== + +The goals for 0.16 were to add support for queuing playlists of e.g. radio +streams directly to Mopidy, without manually extracting the stream URLs from +the playlist first, and to move the Spotify, Last.fm, and MPRIS support out to +independent Mopidy extensions, living outside the main Mopidy repo. In +addition, we've seen some cleanup to the playback vs tracklist part of the core +API, which will require some changes for users of the HTTP/JavaScript APIs, as +well as the addition of audio muting to the core API. To speed up the +:ref:`development of new extensions `, we've added a cookiecutter +project to get the skeleton of a Mopidy extension up and running in a matter of +minutes. Read below for all the details and for links to issues with even more +details. + +Since the release of 0.15, we've closed or merged 31 issues and pull requests +through about 200 commits by :ref:`five people `, including three new +contributors. + +**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-Spotify `_. + +- The Last.fm scrobbler has been moved to + `Mopidy-Scrobbler `_. + +- The MPRIS frontend has been moved to + `Mopidy-MPRIS `_. + +**Core** + +- Parts of the functionality in :class:`mopidy.core.PlaybackController` have + been moved to :class:`mopidy.core.TracklistController`: + + =================================== ================================== + Old location New location + =================================== ================================== + playback.get_consume() tracklist.get_consume() + playback.set_consume(v) tracklist.set_consume(v) + playback.consume tracklist.consume + + playback.get_random() tracklist.get_random() + playback.set_random(v) tracklist.set_random(v) + playback.random tracklist.random + + playback.get_repeat() tracklist.get_repeat() + playback.set_repeat(v) tracklist.set_repeat(v) + playback.repeat tracklist.repeat + + playback.get_single() tracklist.get_single() + playback.set_single(v) tracklist.set_single(v) + playback.single tracklist.single + + playback.get_tracklist_position() tracklist.index(tl_track) + playback.tracklist_position tracklist.index(tl_track) + + playback.get_tl_track_at_eot() tracklist.eot_track(tl_track) + playback.tl_track_at_eot tracklist.eot_track(tl_track) + + playback.get_tl_track_at_next() tracklist.next_track(tl_track) + playback.tl_track_at_next tracklist.next_track(tl_track) + + playback.get_tl_track_at_previous() tracklist.previous_track(tl_track) + playback.tl_track_at_previous tracklist.previous_track(tl_track) + =================================== ================================== + + The ``tl_track`` argument to the last four new functions are used as the + reference ``tl_track`` in the tracklist to find e.g. the next track. Usually, + this will be :attr:`~mopidy.core.PlaybackController.current_tl_track`. + +- 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. + +- In "random" mode, after a full playthrough of the tracklist, playback + continued from the last track played to the end of the playlist in non-random + order. It now stops when all tracks have been played once, unless "repeat" + mode is enabled. (Fixes: :issue:`453`) + +- In "single" mode, after a track ended, playback continued with the next track + in the tracklist. It now stops after playing a single track, unless "repeat" + mode is enabled. (Fixes: :issue:`496`) + +**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`) + +**Local backend** + +- Replaced our custom media library scanner with GStreamer's builtin scanner. + This should make scanning less error prone and faster as timeouts should be + infrequent. (Fixes: :issue:`198`) + +- Media files with less than 100ms duration are now excluded from the library. + +- Media files with the file extensions ``.jpeg``, ``.jpg``, ``.png``, ``.txt``, + and ``.log`` are now skipped by the media library scanner. You can change the + list of excluded file extensions by setting the + :confval:`local/excluded_file_extensions` config value. (Fixes: :issue:`516`) + +- Unknown URIs found in playlists are now made into track objects with the URI + set instead of being ignored. This makes it possible to have playlists with + e.g. HTTP radio streams and not just ``local:track:...`` URIs. This used to + work, but was broken in Mopidy 0.15.0. (Fixes: :issue:`527`) + +- Fixed crash when playing ``local:track:...`` URIs which contained non-ASCII + chars after uridecode. + +- Removed media files are now also removed from the in-memory media library + when the media library is reloaded from disk. (Fixes: :issue:`500`) + +**MPD frontend** + +- Made the formerly unused commands ``outputs``, ``enableoutput``, and + ``disableoutput`` mute/unmute audio. (Related to: :issue:`186`) + +- The MPD command ``list`` now works with ``"albumartist"`` as its second + argument, e.g. ``list "album" "albumartist" "anartist"``. (Fixes: + :issue:`468`) + +- The MPD commands ``find`` and ``search`` now accepts ``albumartist`` and + ``track`` (this is the track number, not the track name) as field types to + limit the search result with. + +- The MPD command ``count`` is now implemented. It accepts the same type of + arguments as ``find`` and ``search``, but returns the number of tracks and + their total playtime instead. + +**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 141a2371b2..e1bd4bffca 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 9f30bd1c95..7f21a6c6b0 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. - -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. +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 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 f3e4166c9b..5a75b7d4d5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,6 +35,8 @@ def __getattr__(self, name): 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, (), {}) @@ -76,6 +78,9 @@ def __getattr__(self, name): # the string True. on_rtd = os.environ.get('READTHEDOCS', None) == 'True' +# Enable Read the Docs' new theme +RTD_NEW_THEME = True + # -- General configuration ---------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be diff --git a/docs/config.rst b/docs/config.rst index 6fd7579d36..c381ef7089 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -147,24 +147,6 @@ Core configuration values .. _the Python logging docs: http://docs.python.org/2/library/logging.config.html -Extension configuration -======================= - -Mopidy's extensions have their own config values that you may want to tweak. -For the available config values, please refer to the docs for each extension. -Most, if not all, can be found at :ref:`ext`. - -Mopidy extensions are enabled by default when they are installed. If you want -to disable an extension without uninstalling it, all extensions support the -``enabled`` config value even if it isn't explicitly documented by all -extensions. If the ``enabled`` config value is set to ``false`` the extension -will not be started. For example, to disable the Spotify extension, add the -following to your ``mopidy.conf``:: - - [spotify] - enabled = false - - Extension configuration ======================= @@ -227,6 +209,13 @@ this work first:: Streaming through SHOUTcast/Icecast ----------------------------------- +.. warning:: Known issue + + Currently, Mopidy does not handle end-of-track vs end-of-stream signalling + in GStreamer correctly. This causes the SHOUTcast stream to be disconnected + at the end of each track, rendering it quite useless. For further details, + see :issue:`492`. + If you want to play the audio on another computer than the one running Mopidy, you can stream the audio from Mopidy through an SHOUTcast or Icecast audio streaming server. Multiple media players can then be connected to the streaming diff --git a/docs/ext/index.rst b/docs/ext/index.rst index 736f2fb67b..27fe3b457a 100644 --- a/docs/ext/index.rst +++ b/docs/ext/index.rst @@ -30,96 +30,107 @@ These extensions are maintained outside Mopidy's core, often by other developers. +Mopidy-Arcam +------------ + +https://github.com/TooDizzy/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-Notifier +--------------- + +https://github.com/sauberfred/mopidy-notifier + +Extension for displaying track info as User Notifications in Mac OS X. + + +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 ----------------- +https://github.com/mopidy/mopidy-soundcloud + Provides 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/local.rst b/docs/ext/local.rst index 1abebb1d5a..f6b281bdb9 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -52,6 +52,10 @@ Configuration values Number of milliseconds before giving up scanning a file and moving on to the next file. +.. confval:: local/excluded_file_extensions + + File extensions to exclude when scanning the media directory. + Usage ===== diff --git a/docs/ext/mpris.rst b/docs/ext/mpris.rst deleted file mode 100644 index 125f8fec15..0000000000 --- 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 84188d02a9..0000000000 --- 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 4bb5b7a3ec..0000000000 --- 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 5116831268..428751de91 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 2aa63887f4..2acb998112 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 ca40c96cb7..732c9f321a 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 @@ -30,7 +30,7 @@ Usage ===== .. toctree:: - :maxdepth: 3 + :maxdepth: 2 installation/index installation/raspberrypi @@ -81,4 +81,3 @@ Indices and tables * :ref:`genindex` * :ref:`modindex` -* :ref:`search` diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 85e07c9d8c..cd4ad983cb 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 `. @@ -174,10 +175,10 @@ can install Mopidy from PyPI using Pip. #. Then you'll need to install all of Mopidy's hard non-Python dependencies: - - GStreamer 0.10.x, with Python bindings. GStreamer is packaged for most - popular Linux distributions. Search for GStreamer in your package manager, - and make sure to install the Python bindings, and the "good" and "ugly" - plugin sets. + - GStreamer 0.10 (>= 0.10.31, < 0.11), with Python bindings. GStreamer is + packaged for most popular Linux distributions. Search for GStreamer in + your package manager, and make sure to install the Python bindings, and + the "good" and "ugly" plugin sets. If you use Debian/Ubuntu you can install GStreamer like this:: @@ -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 6ef80b0fab..10ecd71881 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -21,4 +21,4 @@ warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.15.0' +__version__ = '0.16.0' diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 6a1d7f6b65..5c931865b1 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -11,7 +11,7 @@ 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 @@ mixers.register_mixers() +playlists.register_typefinders() +playlists.register_elements() + MB = 1 << 20 @@ -541,9 +544,42 @@ def _rescale(self, value, old=None, new=None): """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 0000000000..e3f51e4139 --- /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/local/__init__.py b/mopidy/backends/local/__init__.py index 5c6fec47b3..6c66c70d2a 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -21,7 +21,9 @@ def get_config_schema(self): schema['media_dir'] = config.Path() schema['playlists_dir'] = config.Path() schema['tag_cache_file'] = config.Path() - schema['scan_timeout'] = config.Integer(minimum=0) + schema['scan_timeout'] = config.Integer( + minimum=1000, maximum=1000*60*60) + schema['excluded_file_extensions'] = config.List(optional=True) return schema def validate_environment(self): diff --git a/mopidy/backends/local/ext.conf b/mopidy/backends/local/ext.conf index 7e0f0f2b0f..f05a09c050 100644 --- a/mopidy/backends/local/ext.conf +++ b/mopidy/backends/local/ext.conf @@ -4,3 +4,9 @@ media_dir = $XDG_MUSIC_DIR playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists tag_cache_file = $XDG_DATA_DIR/mopidy/local/tag_cache scan_timeout = 1000 +excluded_file_extensions = + .jpeg + .jpg + .png + .txt + .log diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 9dd112e9a7..2ff0e6d10b 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -27,9 +27,14 @@ def refresh(self, uri=None): self._media_dir, self._tag_cache_file) tracks = parse_mpd_tag_cache(self._tag_cache_file, self._media_dir) + uris_to_remove = set(self._uri_mapping) for track in tracks: self._uri_mapping[track.uri] = track + uris_to_remove.discard(track.uri) + + for uri in uris_to_remove: + del self._uri_mapping[uri] logger.info( 'Loaded %d local tracks from %s using %s', @@ -55,17 +60,29 @@ def find_exact(self, query=None, uris=None): values = [values] # FIXME this is bound to be slow for large libraries for value in values: - q = value.strip() + if field == 'track_no': + q = value + else: + q = value.strip() uri_filter = lambda t: q == t.uri track_filter = lambda t: q == t.name album_filter = lambda t: q == getattr(t, 'album', Album()).name artist_filter = lambda t: filter( lambda a: q == a.name, t.artists) + albumartist_filter = lambda t: any([ + q == a.name + for a in getattr(t.album, 'artists', [])]) + track_no_filter = lambda t: q == t.track_no date_filter = lambda t: q == t.date any_filter = lambda t: ( - track_filter(t) or album_filter(t) or - artist_filter(t) or uri_filter(t)) + uri_filter(t) or + track_filter(t) or + album_filter(t) or + artist_filter(t) or + albumartist_filter(t) or + track_no_filter(t) or + date_filter(t)) if field == 'uri': result_tracks = filter(uri_filter, result_tracks) @@ -75,6 +92,10 @@ def find_exact(self, query=None, uris=None): result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) + elif field == 'albumartist': + result_tracks = filter(albumartist_filter, result_tracks) + elif field == 'track_no': + result_tracks = filter(track_no_filter, result_tracks) elif field == 'date': result_tracks = filter(date_filter, result_tracks) elif field == 'any': @@ -97,7 +118,10 @@ def search(self, query=None, uris=None): values = [values] # FIXME this is bound to be slow for large libraries for value in values: - q = value.strip().lower() + if field == 'track_no': + q = value + else: + q = value.strip().lower() uri_filter = lambda t: q in t.uri.lower() track_filter = lambda t: q in t.name.lower() @@ -105,9 +129,19 @@ def search(self, query=None, uris=None): t, 'album', Album()).name.lower() artist_filter = lambda t: filter( lambda a: q in a.name.lower(), t.artists) + albumartist_filter = lambda t: any([ + q in a.name.lower() + for a in getattr(t.album, 'artists', [])]) + track_no_filter = lambda t: q == t.track_no date_filter = lambda t: t.date and t.date.startswith(q) - any_filter = lambda t: track_filter(t) or album_filter(t) or \ - artist_filter(t) or uri_filter(t) + any_filter = lambda t: ( + uri_filter(t) or + track_filter(t) or + album_filter(t) or + artist_filter(t) or + albumartist_filter(t) or + track_no_filter(t) or + date_filter(t)) if field == 'uri': result_tracks = filter(uri_filter, result_tracks) @@ -117,6 +151,10 @@ def search(self, query=None, uris=None): result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) + elif field == 'albumartist': + result_tracks = filter(albumartist_filter, result_tracks) + elif field == 'track_no': + result_tracks = filter(track_no_filter, result_tracks) elif field == 'date': result_tracks = filter(date_filter, result_tracks) elif field == 'any': diff --git a/mopidy/backends/local/playback.py b/mopidy/backends/local/playback.py index eda06ff799..98c92a85b9 100644 --- a/mopidy/backends/local/playback.py +++ b/mopidy/backends/local/playback.py @@ -13,7 +13,7 @@ class LocalPlaybackProvider(base.BasePlaybackProvider): def change_track(self, track): media_dir = self.backend.config['local']['media_dir'] # TODO: check that type is correct. - file_path = path.uri_to_path(track.uri).split(':', 1)[1] + file_path = path.uri_to_path(track.uri).split(b':', 1)[1] file_path = os.path.join(media_dir, file_path) track = track.copy(uri=path.path_to_uri(file_path)) return super(LocalPlaybackProvider, self).change_track(track) diff --git a/mopidy/backends/local/playlists.py b/mopidy/backends/local/playlists.py index af3814aeb2..081bc335e1 100644 --- a/mopidy/backends/local/playlists.py +++ b/mopidy/backends/local/playlists.py @@ -6,7 +6,7 @@ import shutil from mopidy.backends import base, listener -from mopidy.models import Playlist +from mopidy.models import Playlist, Track from mopidy.utils import formatting, path from .translator import parse_m3u @@ -51,12 +51,11 @@ def refresh(self): tracks = [] for track_uri in parse_m3u(m3u, self._media_dir): - try: - # TODO We must use core.library.lookup() to support tracks - # from other backends + result = self.backend.library.lookup(track_uri) + if result: tracks += self.backend.library.lookup(track_uri) - except LookupError as ex: - logger.warning('Playlist item could not be added: %s', ex) + else: + tracks.append(Track(uri=track_uri)) playlist = Playlist(uri=uri, name=name, tracks=tracks) playlists.append(playlist) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py deleted file mode 100644 index 3cee609a7f..0000000000 --- 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 1f90ba515c..0000000000 --- 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 e8d1ed0b82..0000000000 --- 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 83bf191ae1..0000000000 --- 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 49caa709e7..0000000000 --- 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 bda1763492..0000000000 --- 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 6cd6d4ed99..0000000000 --- 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 bd201179ce..0000000000 --- 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 3ab4498be0..0000000000 --- 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 1f840b962d..0000000000 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 f35cad2ed4..0000000000 --- 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/config/__init__.py b/mopidy/config/__init__.py index 0767b50c7a..6d66e25306 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -2,8 +2,10 @@ import ConfigParser as configparser import io +import itertools import logging import os.path +import re from mopidy.config import keyring from mopidy.config.schemas import * # noqa @@ -145,6 +147,53 @@ def _format(config, comments, schemas, display): return b'\n'.join(output) +def _preprocess(config_string): + """Convert a raw config into a form that preserves comments etc.""" + results = ['[__COMMENTS__]'] + counter = itertools.count(0) + + section_re = re.compile(r'^(\[[^\]]+\])\s*(.+)$') + blank_line_re = re.compile(r'^\s*$') + comment_re = re.compile(r'^(#|;)') + inline_comment_re = re.compile(r' ;') + + def newlines(match): + return '__BLANK%d__ =' % next(counter) + + def comments(match): + if match.group(1) == '#': + return '__HASH%d__ =' % next(counter) + elif match.group(1) == ';': + return '__SEMICOLON%d__ =' % next(counter) + + def inlinecomments(match): + return '\n__INLINE%d__ =' % next(counter) + + def sections(match): + return '%s\n__SECTION%d__ = %s' % ( + match.group(1), next(counter), match.group(2)) + + for line in config_string.splitlines(): + line = blank_line_re.sub(newlines, line) + line = section_re.sub(sections, line) + line = comment_re.sub(comments, line) + line = inline_comment_re.sub(inlinecomments, line) + results.append(line) + return '\n'.join(results) + + +def _postprocess(config_string): + """Converts a preprocessed config back to original form.""" + flags = re.IGNORECASE | re.MULTILINE + result = re.sub(r'^\[__COMMENTS__\](\n|$)', '', config_string, flags=flags) + result = re.sub(r'\n__INLINE\d+__ =(.*)$', ' ;\g<1>', result, flags=flags) + result = re.sub(r'^__HASH\d+__ =(.*)$', '#\g<1>', result, flags=flags) + result = re.sub(r'^__SEMICOLON\d+__ =(.*)$', ';\g<1>', result, flags=flags) + result = re.sub(r'\n__SECTION\d+__ =(.*)$', '\g<1>', result, flags=flags) + result = re.sub(r'^__BLANK\d+__ =$', '', result, flags=flags) + return result + + class Proxy(collections.Mapping): def __init__(self, data): self._data = data diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index c93fc39ec6..40c78540dd 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -132,11 +132,25 @@ def options_changed(self): """ 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 ea849dbf3c..d127fbbe69 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -1,7 +1,6 @@ from __future__ import unicode_literals import logging -import random import urlparse from mopidy.audio import PlaybackState @@ -21,9 +20,8 @@ def __init__(self, audio, backends, core): self.core = core self._state = PlaybackState.STOPPED - self._shuffled = [] - self._first_shuffle = True self._volume = None + self._mute = False def _get_backend(self): if self.current_tl_track is None: @@ -34,22 +32,6 @@ def _get_backend(self): ### Properties - def get_consume(self): - return getattr(self, '_consume', False) - - def set_consume(self, value): - if self.get_consume() != value: - self._trigger_options_changed() - return setattr(self, '_consume', value) - - consume = property(get_consume, set_consume) - """ - :class:`True` - Tracks are removed from the playlist when they have been played. - :class:`False` - Tracks are not removed from the playlist. - """ - def get_current_tl_track(self): return self.current_tl_track @@ -69,56 +51,6 @@ def get_current_track(self): Read-only. Extracted from :attr:`current_tl_track` for convenience. """ - def get_random(self): - return getattr(self, '_random', False) - - def set_random(self, value): - if self.get_random() != value: - self._trigger_options_changed() - return setattr(self, '_random', value) - - random = property(get_random, set_random) - """ - :class:`True` - Tracks are selected at random from the playlist. - :class:`False` - Tracks are played in the order of the playlist. - """ - - def get_repeat(self): - return getattr(self, '_repeat', False) - - def set_repeat(self, value): - if self.get_repeat() != value: - self._trigger_options_changed() - return setattr(self, '_repeat', value) - - repeat = property(get_repeat, set_repeat) - """ - :class:`True` - The current playlist is played repeatedly. To repeat a single track, - select both :attr:`repeat` and :attr:`single`. - :class:`False` - The current playlist is played once. - """ - - def get_single(self): - return getattr(self, '_single', False) - - def set_single(self, value): - if self.get_single() != value: - self._trigger_options_changed() - return setattr(self, '_single', value) - - single = property(get_single, set_single) - """ - :class:`True` - Playback is stopped after current song, unless in :attr:`repeat` - mode. - :class:`False` - Playback continues after current song. - """ - def get_state(self): return self._state @@ -156,119 +88,6 @@ def get_time_position(self): time_position = property(get_time_position) """Time position in milliseconds.""" - def get_tracklist_position(self): - if self.current_tl_track is None: - return None - try: - return self.core.tracklist.tl_tracks.index(self.current_tl_track) - except ValueError: - return None - - tracklist_position = property(get_tracklist_position) - """ - The position of the current track in the tracklist. - - Read-only. - """ - - def get_tl_track_at_eot(self): - tl_tracks = self.core.tracklist.tl_tracks - - if not tl_tracks: - return None - - if self.random and not self._shuffled: - if self.repeat or self._first_shuffle: - logger.debug('Shuffling tracks') - self._shuffled = tl_tracks - random.shuffle(self._shuffled) - self._first_shuffle = False - - if self.random and self._shuffled: - return self._shuffled[0] - - if self.current_tl_track is None: - return tl_tracks[0] - - if self.repeat and self.single: - return tl_tracks[self.tracklist_position] - - if self.repeat and not self.single: - return tl_tracks[(self.tracklist_position + 1) % len(tl_tracks)] - - try: - return tl_tracks[self.tracklist_position + 1] - except IndexError: - return None - - tl_track_at_eot = property(get_tl_track_at_eot) - """ - The track that will be played at the end of the current track. - - Read-only. A :class:`mopidy.models.TlTrack`. - - Not necessarily the same track as :attr:`tl_track_at_next`. - """ - - def get_tl_track_at_next(self): - tl_tracks = self.core.tracklist.tl_tracks - - if not tl_tracks: - return None - - if self.random and not self._shuffled: - if self.repeat or self._first_shuffle: - logger.debug('Shuffling tracks') - self._shuffled = tl_tracks - random.shuffle(self._shuffled) - self._first_shuffle = False - - if self.random and self._shuffled: - return self._shuffled[0] - - if self.current_tl_track is None: - return tl_tracks[0] - - if self.repeat: - return tl_tracks[(self.tracklist_position + 1) % len(tl_tracks)] - - try: - return tl_tracks[self.tracklist_position + 1] - except IndexError: - return None - - tl_track_at_next = property(get_tl_track_at_next) - """ - The track that will be played if calling :meth:`next()`. - - Read-only. A :class:`mopidy.models.TlTrack`. - - For normal playback this is the next track in the playlist. If repeat - is enabled the next track can loop around the playlist. When random is - enabled this should be a random track, all tracks should be played once - before the list repeats. - """ - - def get_tl_track_at_previous(self): - if self.repeat or self.consume or self.random: - return self.current_tl_track - - if self.tracklist_position in (None, 0): - return None - - return self.core.tracklist.tl_tracks[self.tracklist_position - 1] - - tl_track_at_previous = property(get_tl_track_at_previous) - """ - The track that will be played if calling :meth:`previous()`. - - A :class:`mopidy.models.TlTrack`. - - For normal playback this is the previous track in the playlist. If - random and/or consume is enabled it should return the current track - instead. - """ - def get_volume(self): if self.audio: return self.audio.get_volume().get() @@ -288,6 +107,26 @@ def set_volume(self, volume): 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): @@ -318,15 +157,15 @@ def on_end_of_track(self): return original_tl_track = self.current_tl_track + next_tl_track = self.core.tracklist.eot_track(original_tl_track) - if self.tl_track_at_eot: + if next_tl_track: self._trigger_track_playback_ended() - self.play(self.tl_track_at_eot) + self.play(next_tl_track) else: self.stop(clear_current_track=True) - if self.consume: - self.core.tracklist.remove(tlid=original_tl_track.tlid) + self.core.tracklist.mark_played(original_tl_track) def on_tracklist_change(self): """ @@ -334,12 +173,7 @@ def on_tracklist_change(self): Used by :class:`mopidy.core.TracklistController`. """ - self._first_shuffle = True - self._shuffled = [] - - if (not self.core.tracklist.tl_tracks or - self.current_tl_track not in - self.core.tracklist.tl_tracks): + if self.current_tl_track not in self.core.tracklist.tl_tracks: self.stop(clear_current_track=True) def next(self): @@ -349,9 +183,10 @@ def next(self): The current playback state will be kept. If it was playing, playing will continue. If it was paused, it will still be paused, etc. """ - if self.tl_track_at_next: + tl_track = self.core.tracklist.next_track(self.current_tl_track) + if tl_track: self._trigger_track_playback_ended() - self.change_track(self.tl_track_at_next) + self.change_track(tl_track) else: self.stop(clear_current_track=True) @@ -374,37 +209,40 @@ def play(self, tl_track=None, on_error_step=1): :type on_error_step: int, -1 or 1 """ - if tl_track is not None: - assert tl_track in self.core.tracklist.tl_tracks - elif tl_track is None: + assert on_error_step in (-1, 1) + + if tl_track is None: if self.state == PlaybackState.PAUSED: return self.resume() - elif self.current_tl_track is not None: - tl_track = self.current_tl_track - elif self.current_tl_track is None and on_error_step == 1: - tl_track = self.tl_track_at_next - elif self.current_tl_track is None and on_error_step == -1: - tl_track = self.tl_track_at_previous - if tl_track is not None: - self.current_tl_track = tl_track - self.state = PlaybackState.PLAYING - backend = self._get_backend() - if not backend or not backend.playback.play(tl_track.track).get(): - logger.warning('Track is not playable: %s', tl_track.track.uri) - if self.random and self._shuffled: - self._shuffled.remove(tl_track) + if self.current_tl_track is not None: + tl_track = self.current_tl_track + else: if on_error_step == 1: - # TODO: can cause an endless loop for single track repeat. - self.next() + tl_track = self.core.tracklist.next_track(tl_track) elif on_error_step == -1: - self.previous() + tl_track = self.core.tracklist.previous_track(tl_track) + + if tl_track is None: return - if self.random and self.current_tl_track in self._shuffled: - self._shuffled.remove(self.current_tl_track) + assert tl_track in self.core.tracklist.tl_tracks - self._trigger_track_playback_started() + self.current_tl_track = tl_track + self.state = PlaybackState.PLAYING + backend = self._get_backend() + success = backend and backend.playback.play(tl_track.track).get() + + if success: + self.core.tracklist.mark_playing(tl_track) + self._trigger_track_playback_started() + else: + self.core.tracklist.mark_unplayable(tl_track) + if on_error_step == 1: + # TODO: can cause an endless loop for single track repeat. + self.next() + elif on_error_step == -1: + self.previous() def previous(self): """ @@ -414,7 +252,9 @@ def previous(self): will continue. If it was paused, it will still be paused, etc. """ self._trigger_track_playback_ended() - self.change_track(self.tl_track_at_previous, on_error_step=-1) + tl_track = self.current_tl_track + self.change_track( + self.core.tracklist.previous_track(tl_track), on_error_step=-1) def resume(self): """If paused, resume playing the current track.""" @@ -510,14 +350,14 @@ def _trigger_playback_state_changed(self, old_state, new_state): 'playback_state_changed', old_state=old_state, new_state=new_state) - def _trigger_options_changed(self): - logger.debug('Triggering options changed event') - listener.CoreListener.send('options_changed') - def _trigger_volume_changed(self, volume): 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/core/tracklist.py b/mopidy/core/tracklist.py index 1c8f437fcd..dbc819456f 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -15,11 +15,15 @@ class TracklistController(object): pykka_traversable = True def __init__(self, core): - self._core = core + self.core = core self._next_tlid = 0 self._tl_tracks = [] self._version = 0 + self._shuffled = [] + + ### Properties + def get_tl_tracks(self): return self._tl_tracks[:] @@ -51,7 +55,7 @@ def get_version(self): def _increase_version(self): self._version += 1 - self._core.playback.on_tracklist_change() + self.core.playback.on_tracklist_change() self._trigger_tracklist_changed() version = property(get_version) @@ -62,6 +66,175 @@ def _increase_version(self): Is not reset before Mopidy is restarted. """ + def get_consume(self): + return getattr(self, '_consume', False) + + def set_consume(self, value): + if self.get_consume() != value: + self._trigger_options_changed() + return setattr(self, '_consume', value) + + consume = property(get_consume, set_consume) + """ + :class:`True` + Tracks are removed from the playlist when they have been played. + :class:`False` + Tracks are not removed from the playlist. + """ + + def get_random(self): + return getattr(self, '_random', False) + + def set_random(self, value): + if self.get_random() != value: + self._trigger_options_changed() + if value: + self._shuffled = self.tl_tracks + random.shuffle(self._shuffled) + return setattr(self, '_random', value) + + random = property(get_random, set_random) + """ + :class:`True` + Tracks are selected at random from the playlist. + :class:`False` + Tracks are played in the order of the playlist. + """ + + def get_repeat(self): + return getattr(self, '_repeat', False) + + def set_repeat(self, value): + if self.get_repeat() != value: + self._trigger_options_changed() + return setattr(self, '_repeat', value) + + repeat = property(get_repeat, set_repeat) + """ + :class:`True` + The current playlist is played repeatedly. To repeat a single track, + select both :attr:`repeat` and :attr:`single`. + :class:`False` + The current playlist is played once. + """ + + def get_single(self): + return getattr(self, '_single', False) + + def set_single(self, value): + if self.get_single() != value: + self._trigger_options_changed() + return setattr(self, '_single', value) + + single = property(get_single, set_single) + """ + :class:`True` + Playback is stopped after current song, unless in :attr:`repeat` + mode. + :class:`False` + Playback continues after current song. + """ + + ### Methods + + def index(self, tl_track): + """ + The position of the given track in the tracklist. + + :param tl_track: the track to find the index of + :type tl_track: :class:`mopidy.models.TlTrack` + :rtype: :class:`int` or :class:`None` + """ + try: + return self._tl_tracks.index(tl_track) + except ValueError: + return None + + def eot_track(self, tl_track): + """ + The track that will be played after the given track. + + Not necessarily the same track as :meth:`next_track`. + + :param tl_track: the reference track + :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` + :rtype: :class:`mopidy.models.TlTrack` or :class:`None` + """ + if self.single and self.repeat: + return tl_track + elif self.single: + return None + + # Current difference between next and EOT handling is that EOT needs to + # handle "single", with that out of the way the rest of the logic is + # shared. + return self.next_track(tl_track) + + def next_track(self, tl_track): + """ + The track that will be played if calling + :meth:`mopidy.core.PlaybackController.next()`. + + For normal playback this is the next track in the playlist. If repeat + is enabled the next track can loop around the playlist. When random is + enabled this should be a random track, all tracks should be played once + before the list repeats. + + :param tl_track: the reference track + :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` + :rtype: :class:`mopidy.models.TlTrack` or :class:`None` + """ + + if not self.tl_tracks: + return None + + if self.random and not self._shuffled: + if self.repeat or not tl_track: + logger.debug('Shuffling tracks') + self._shuffled = self.tl_tracks + random.shuffle(self._shuffled) + + if self.random: + try: + return self._shuffled[0] + except IndexError: + return None + + if tl_track is None: + return self.tl_tracks[0] + + next_index = self.index(tl_track) + 1 + if self.repeat: + next_index %= len(self.tl_tracks) + + try: + return self.tl_tracks[next_index] + except IndexError: + return None + + def previous_track(self, tl_track): + """ + Returns the track that will be played if calling + :meth:`mopidy.core.PlaybackController.previous()`. + + For normal playback this is the previous track in the playlist. If + random and/or consume is enabled it should return the current track + instead. + + :param tl_track: the reference track + :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` + :rtype: :class:`mopidy.models.TlTrack` or :class:`None` + """ + if self.repeat or self.consume or self.random: + return tl_track + + position = self.index(tl_track) + + if position in (None, 0): + return None + + return self.tl_tracks[position - 1] + def add(self, tracks=None, at_position=None, uri=None): """ Add the track or list of tracks to the tracklist. @@ -87,7 +260,7 @@ def add(self, tracks=None, at_position=None, uri=None): 'tracks or uri must be provided' if tracks is None and uri is not None: - tracks = self._core.library.lookup(uri) + tracks = self.core.library.lookup(uri) tl_tracks = [] @@ -151,18 +324,6 @@ def filter(self, criteria=None, **kwargs): lambda ct: getattr(ct.track, key) == value, matches) return matches - def index(self, tl_track): - """ - Get index of the given :class:`mopidy.models.TlTrack` in the tracklist. - - Raises :exc:`ValueError` if not found. - - :param tl_track: track to find the index of - :type tl_track: :class:`mopidy.models.TlTrack` - :rtype: int - """ - return self._tl_tracks.index(tl_track) - def move(self, start, end, to_position): """ Move the tracks in the slice ``[start:end]`` to ``to_position``. @@ -259,6 +420,34 @@ def slice(self, start, end): """ return self._tl_tracks[start:end] + def mark_playing(self, tl_track): + """Private method used by :class:`mopidy.core.PlaybackController`.""" + if self.random and tl_track in self._shuffled: + self._shuffled.remove(tl_track) + + def mark_unplayable(self, tl_track): + """Private method used by :class:`mopidy.core.PlaybackController`.""" + logger.warning('Track is not playable: %s', tl_track.track.uri) + if self.random and tl_track in self._shuffled: + self._shuffled.remove(tl_track) + + def mark_played(self, tl_track): + """Private method used by :class:`mopidy.core.PlaybackController`.""" + if not self.consume: + return False + self.remove(tlid=tl_track.tlid) + return True + def _trigger_tracklist_changed(self): + if self.random: + self._shuffled = self.tl_tracks + random.shuffle(self._shuffled) + else: + self._shuffled = [] + logger.debug('Triggering event: tracklist_changed()') listener.CoreListener.send('tracklist_changed') + + def _trigger_options_changed(self): + logger.debug('Triggering options changed event') + listener.CoreListener.send('options_changed') diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index 2c53e3e48d..025d8fad04 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -18,3 +18,7 @@ def message(self, message): class ExtensionError(MopidyException): pass + + +class ScannerError(MopidyException): + pass diff --git a/mopidy/ext.py b/mopidy/ext.py index 5db7c093a1..e6cfbb7cc4 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -83,8 +83,7 @@ def get_library_updaters(self): """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 f1fefae455..4d983b73f9 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -55,3 +55,6 @@ def options_changed(self): 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 01982a711f..17cf4ac435 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(False) + 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(True) + 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/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index f81d57ee56..6dd43d68a6 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -5,13 +5,13 @@ from mopidy.models import Track from mopidy.frontends.mpd import translator -from mopidy.frontends.mpd.exceptions import MpdNotImplemented +from mopidy.frontends.mpd.exceptions import MpdArgError, MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request, stored_playlists QUERY_RE = ( - r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile|[Ff]ilename|' - r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') + r'(?P("?([Aa]lbum|[Aa]rtist|[Aa]lbumartist|[Dd]ate|[Ff]ile|' + r'[Ff]ilename|[Tt]itle|[Tt]rack|[Aa]ny)"? "[^"]*"\s?)+)$') def _get_field(field, search_results): @@ -54,7 +54,16 @@ def count(context, mpd_query): - does not add quotes around the tag argument. - use multiple tag-needle pairs to make more specific searches. """ - return [('songs', 0), ('playtime', 0)] # TODO + try: + query = translator.query_from_mpd_search_format(mpd_query) + except ValueError: + raise MpdArgError('incorrect arguments', command='count') + results = context.core.library.find_exact(**query).get() + result_tracks = _get_tracks(results) + return [ + ('songs', len(result_tracks)), + ('playtime', sum(track.length for track in result_tracks) / 1000), + ] @handle_request(r'^find ' + QUERY_RE) @@ -91,7 +100,7 @@ def find(context, mpd_query): return results = context.core.library.find_exact(**query).get() result_tracks = [] - if 'artist' not in query: + if 'artist' not in query and 'albumartist' not in query: result_tracks += [_artist_as_track(a) for a in _get_artists(results)] if 'album' not in query: result_tracks += [_album_as_track(a) for a in _get_albums(results)] diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 8e08585f84..b9289d8a3f 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -19,9 +19,9 @@ def consume(context, state): playlist. """ if int(state): - context.core.playback.consume = True + context.core.tracklist.consume = True else: - context.core.playback.consume = False + context.core.tracklist.consume = False @handle_request(r'^crossfade "(?P\d+)"$') @@ -263,9 +263,9 @@ def random(context, state): Sets random state to ``STATE``, ``STATE`` should be 0 or 1. """ if int(state): - context.core.playback.random = True + context.core.tracklist.random = True else: - context.core.playback.random = False + context.core.tracklist.random = False @handle_request(r'^repeat (?P[01])$') @@ -279,9 +279,9 @@ def repeat(context, state): Sets repeat state to ``STATE``, ``STATE`` should be 0 or 1. """ if int(state): - context.core.playback.repeat = True + context.core.tracklist.repeat = True else: - context.core.playback.repeat = False + context.core.tracklist.repeat = False @handle_request(r'^replay_gain_mode "(?P(off|track|album))"$') @@ -329,7 +329,8 @@ def seek(context, songpos, seconds): - issues ``seek 1 120`` without quotes around the arguments. """ - if context.core.playback.tracklist_position.get() != int(songpos): + tl_track = context.core.playback.current_tl_track.get() + if context.core.tracklist.index(tl_track).get() != int(songpos): playpos(context, songpos) context.core.playback.seek(int(seconds) * 1000).get() @@ -404,9 +405,9 @@ def single(context, state): song is repeated if the ``repeat`` mode is enabled. """ if int(state): - context.core.playback.single = True + context.core.tracklist.single = True else: - context.core.playback.single = False + context.core.tracklist.single = False @handle_request(r'^stop$') diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index 34e2fa64e7..49e08ee8ed 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -36,10 +36,10 @@ def currentsong(context): Displays the song info of the current song (same song that is identified in status). """ - current_tl_track = context.core.playback.current_tl_track.get() - if current_tl_track is not None: - position = context.core.playback.tracklist_position.get() - return track_to_mpd_format(current_tl_track, position=position) + tl_track = context.core.playback.current_tl_track.get() + if tl_track is not None: + position = context.core.tracklist.index(tl_track).get() + return track_to_mpd_format(tl_track, position=position) @handle_request(r'^idle$') @@ -178,14 +178,15 @@ def status(context): 'tracklist.length': context.core.tracklist.length, 'tracklist.version': context.core.tracklist.version, 'playback.volume': context.core.playback.volume, - 'playback.consume': context.core.playback.consume, - 'playback.random': context.core.playback.random, - 'playback.repeat': context.core.playback.repeat, - 'playback.single': context.core.playback.single, + 'tracklist.consume': context.core.tracklist.consume, + 'tracklist.random': context.core.tracklist.random, + 'tracklist.repeat': context.core.tracklist.repeat, + 'tracklist.single': context.core.tracklist.single, 'playback.state': context.core.playback.state, 'playback.current_tl_track': context.core.playback.current_tl_track, - 'playback.tracklist_position': ( - context.core.playback.tracklist_position), + 'tracklist.index': ( + context.core.tracklist.index( + context.core.playback.current_tl_track.get())), 'playback.time_position': context.core.playback.time_position, } pykka.get_all(futures.values()) @@ -218,7 +219,7 @@ def _status_bitrate(futures): def _status_consume(futures): - if futures['playback.consume'].get(): + if futures['tracklist.consume'].get(): return 1 else: return 0 @@ -233,15 +234,15 @@ def _status_playlist_version(futures): def _status_random(futures): - return int(futures['playback.random'].get()) + return int(futures['tracklist.random'].get()) def _status_repeat(futures): - return int(futures['playback.repeat'].get()) + return int(futures['tracklist.repeat'].get()) def _status_single(futures): - return int(futures['playback.single'].get()) + return int(futures['tracklist.single'].get()) def _status_songid(futures): @@ -253,7 +254,7 @@ def _status_songid(futures): def _status_songpos(futures): - return futures['playback.tracklist_position'].get() + return futures['tracklist.index'].get() def _status_state(futures): diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 804f693a68..9b3313950f 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -166,7 +166,7 @@ def query_from_mpd_list_format(field, mpd_query): key = tokens[0].lower() value = tokens[1] tokens = tokens[2:] - if key not in ('artist', 'album', 'date', 'genre'): + if key not in ('artist', 'album', 'albumartist', 'date', 'genre'): raise MpdArgError('not able to parse args', command='list') if not value: raise ValueError @@ -179,6 +179,48 @@ def query_from_mpd_list_format(field, mpd_query): raise MpdArgError('not able to parse args', command='list') +# XXX The regexps below should be refactored to reuse common patterns here +# and in mopidy.frontends.mpd.protocol.music_db.QUERY_RE. + +MPD_SEARCH_QUERY_RE = re.compile(r""" + \b # Only begin matching at word bundaries + "? # Optional quote around the field type + (?: # A non-capturing group for the field type + [Aa]lbum + | [Aa]rtist + | [Aa]lbumartist + | [Dd]ate + | [Ff]ile + | [Ff]ilename + | [Tt]itle + | [Tt]rack + | [Aa]ny + ) + "? # End of optional quote around the field type + \s # A single space + "[^"]+" # Matching a quoted search string +""", re.VERBOSE) + +MPD_SEARCH_QUERY_PART_RE = re.compile(r""" + \b # Only begin matching at word bundaries + "? # Optional quote around the field type + (?P( # A capturing group for the field type + [Aa]lbum + | [Aa]rtist + | [Aa]lbumartist + | [Dd]ate + | [Ff]ile + | [Ff]ilename + | [Tt]itle + | [Tt]rack + | [Aa]ny + )) + "? # End of optional quote around the field type + \s # A single space + "(?P[^"]+)" # Capturing a quoted search string +""", re.VERBOSE) + + def query_from_mpd_search_format(mpd_query): """ Parses an MPD ``search`` or ``find`` query and converts it to the Mopidy @@ -187,24 +229,17 @@ def query_from_mpd_search_format(mpd_query): :param mpd_query: the MPD search query :type mpd_query: string """ - # XXX The regexps below should be refactored to reuse common patterns here - # and in mopidy.frontends.mpd.protocol.music_db. - query_pattern = ( - r'"?(?:[Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile|[Ff]ilename|' - r'[Tt]itle|[Aa]ny)"? "[^"]+"') - query_parts = re.findall(query_pattern, mpd_query) - query_part_pattern = ( - r'"?(?P([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile|[Ff]ilename|' - r'[Tt]itle|[Aa]ny))"? "(?P[^"]+)"') + query_parts = MPD_SEARCH_QUERY_RE.findall(mpd_query) query = {} for query_part in query_parts: - m = re.match(query_part_pattern, query_part) + m = MPD_SEARCH_QUERY_PART_RE.match(query_part) field = m.groupdict()['field'].lower() if field == 'title': field = 'track' + elif field == 'track': + field = 'track_no' elif field in ('file', 'filename'): field = 'uri' - field = str(field) # Needed for kwargs keys on OS X and Windows what = m.groupdict()['what'] if not what: raise ValueError diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py deleted file mode 100644 index 1fd258b5ab..0000000000 --- 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 d44e926208..0000000000 --- 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 b83411c2cb..0000000000 --- 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 15be1eea2f..0000000000 --- a/mopidy/frontends/mpris/objects.py +++ /dev/null @@ -1,498 +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.playback.repeat.get() - single = self.core.playback.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.playback.repeat = False - self.core.playback.single = False - elif value == 'Track': - self.core.playback.repeat = True - self.core.playback.single = True - elif value == 'Playlist': - self.core.playback.repeat = True - self.core.playback.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.playback.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.playback.random = True - else: - self.core.playback.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 - return ( - self.core.playback.tl_track_at_next.get() != - self.core.playback.current_tl_track.get()) - - def get_CanGoPrevious(self): - if not self.get_CanControl(): - return False - return ( - self.core.playback.tl_track_at_previous.get() != - self.core.playback.current_tl_track.get()) - - def get_CanPlay(self): - if not self.get_CanControl(): - return False - return ( - self.core.playback.current_tl_track.get() is not None or - self.core.playback.tl_track_at_next.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 c08bc15ea2..0000000000 --- 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 2343e0cb67..0000000000 --- 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 4fded92fd7..0000000000 --- a/mopidy/frontends/scrobbler/ext.conf +++ /dev/null @@ -1,4 +0,0 @@ -[scrobbler] -enabled = true -username = -password = diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 81ac5c535d..dd21fdb472 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -16,17 +16,12 @@ sys.argv[1:] = [] -# Add ../ to the path so we can run Mopidy from a Git checkout without -# installing it on the system. -sys.path.insert( - 0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) - - import pygst pygst.require('0.10') import gst +import gst.pbutils -from mopidy import config as config_lib, ext +from mopidy import config as config_lib, exceptions, ext from mopidy.models import Track, Artist, Album from mopidy.utils import log, path, versioning @@ -76,6 +71,7 @@ def main(): local_updater = updaters.values()[0](config) # TODO: switch to actor? media_dir = config['local']['media_dir'] + excluded_extensions = config['local']['excluded_file_extensions'] uris_library = set() uris_update = set() @@ -97,30 +93,26 @@ def main(): logging.info('Checking %s for new or modified tracks.', media_dir) for uri in path.find_uris(config['local']['media_dir']): + if os.path.splitext(path.uri_to_path(uri))[1] in excluded_extensions: + logging.debug('Skipped %s: File extension excluded.', uri) + continue + if uri not in uris_library: uris_update.add(uri) logging.info('Found %d new or modified tracks.', len(uris_update)) - - def store(data): - track = translator(data) - local_updater.add(track) - logging.debug('Added %s', track.uri) - - def debug(uri, error, debug): - logging.warning('Failed %s: %s', uri, error) - logging.debug('Debug info for %s: %s', uri, debug) - - scan_timeout = config['local']['scan_timeout'] - logging.info('Scanning new and modified tracks.') - # TODO: just pass the library in instead? - scanner = Scanner(uris_update, store, debug, scan_timeout) - try: - scanner.start() - except KeyboardInterrupt: - scanner.stop() - raise + + scanner = Scanner(config['local']['scan_timeout']) + for uri in uris_update: + try: + data = scanner.scan(uri) + data[b'mtime'] = os.path.getmtime(path.uri_to_path(uri)) + track = translator(data) + local_updater.add(track) + logging.debug('Added %s', track.uri) + except exceptions.ScannerError as error: + logging.warning('Failed %s: %s', uri, error) logging.info('Done scanning; commiting changes.') local_updater.commit() @@ -192,125 +184,44 @@ def _retrieve(source_key, target_key, target): class Scanner(object): - def __init__( - self, uris, data_callback, error_callback=None, scan_timeout=1000): - self.data = {} - self.uris = iter(uris) - self.data_callback = data_callback - self.error_callback = error_callback - self.scan_timeout = scan_timeout - self.loop = gobject.MainLoop() - self.timeout_id = None - - self.fakesink = gst.element_factory_make('fakesink') - self.fakesink.set_property('signal-handoffs', True) - self.fakesink.connect('handoff', self.process_handoff) - - self.uribin = gst.element_factory_make('uridecodebin') - self.uribin.set_property( - 'caps', gst.Caps(b'audio/x-raw-int; audio/x-raw-float')) - self.uribin.connect('pad-added', self.process_new_pad) - - self.pipe = gst.element_factory_make('pipeline') - self.pipe.add(self.uribin) - self.pipe.add(self.fakesink) - - bus = self.pipe.get_bus() - bus.add_signal_watch() - bus.connect('message::application', self.process_application) - bus.connect('message::tag', self.process_tags) - bus.connect('message::error', self.process_error) - - def process_handoff(self, fakesink, buffer_, pad): - # When this function is called the first buffer has reached the end of - # the pipeline, and we can continue with the next track. Since we're - # in another thread, we send a message back to the main thread using - # the bus. - structure = gst.Structure('handoff') - message = gst.message_new_application(fakesink, structure) - bus = self.pipe.get_bus() - bus.post(message) - - def process_new_pad(self, source, pad): - pad.link(self.fakesink.get_pad('sink')) - - def process_application(self, bus, message): - if message.src != self.fakesink: - return - - if message.structure.get_name() != 'handoff': - return - - uri = unicode(self.uribin.get_property('uri')) - self.data['uri'] = uri - self.data['mtime'] = os.path.getmtime(path.uri_to_path(uri)) - self.data[gst.TAG_DURATION] = self.get_duration() + def __init__(self, timeout=1000): + self.discoverer = gst.pbutils.Discoverer(timeout * 1000000) + def scan(self, uri): try: - self.data_callback(self.data) - self.next_uri() - except KeyboardInterrupt: - self.stop() - - def process_tags(self, bus, message): - taglist = message.parse_tag() - - for key in taglist.keys(): - # XXX: For some crazy reason some wma files spit out lists here, - # not sure if this is due to better data in headers or wma being - # stupid. So ugly hack for now :/ - if type(taglist[key]) is list: - self.data[key] = taglist[key][0] - else: - self.data[key] = taglist[key] - - def process_error(self, bus, message): - if self.error_callback: - uri = self.uribin.get_property('uri') - error, debug = message.parse_error() - self.error_callback(uri, error, debug) - self.next_uri() - - def process_timeout(self): - if self.error_callback: - uri = self.uribin.get_property('uri') - self.error_callback( - uri, 'Scan timed out after %d ms' % self.scan_timeout, None) - self.next_uri() - return False - - def get_duration(self): - self.pipe.get_state() # Block until state change is done. - try: - return self.pipe.query_duration( - gst.FORMAT_TIME, None)[0] // gst.MSECOND - except gst.QueryError: - return None - - def next_uri(self): - self.data = {} - if self.timeout_id: - gobject.source_remove(self.timeout_id) - self.timeout_id = None - try: - uri = next(self.uris) - except StopIteration: - self.stop() - return False - self.pipe.set_state(gst.STATE_NULL) - self.uribin.set_property('uri', uri) - self.timeout_id = gobject.timeout_add( - self.scan_timeout, self.process_timeout) - self.pipe.set_state(gst.STATE_PLAYING) - return True - - def start(self): - if self.next_uri(): - self.loop.run() - - def stop(self): - self.pipe.set_state(gst.STATE_NULL) - self.loop.quit() + info = self.discoverer.discover_uri(uri) + except gobject.GError as e: + # Loosing traceback is non-issue since this is from C code. + raise exceptions.ScannerError(e) + + data = {} + audio_streams = info.get_audio_streams() + + if not audio_streams: + raise exceptions.ScannerError('Did not find any audio streams.') + + for stream in audio_streams: + taglist = stream.get_tags() + if not taglist: + continue + for key in taglist.keys(): + # XXX: For some crazy reason some wma files spit out lists + # here, not sure if this is due to better data in headers or + # wma being stupid. So ugly hack for now :/ + if type(taglist[key]) is list: + data[key] = taglist[key][0] + else: + data[key] = taglist[key] + + # Never trust metadata for these fields: + data[b'uri'] = uri + data[b'duration'] = info.get_duration() // gst.MSECOND + + if data[b'duration'] < 100: + raise exceptions.ScannerError( + 'Rejecting file with less than 100ms audio data.') + + return data if __name__ == '__main__': diff --git a/requirements/scrobbler.txt b/requirements/scrobbler.txt deleted file mode 100644 index c52256c345..0000000000 --- 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 d11a5c0447..0000000000 --- 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 c5eea724c8..a448a029f3 100644 --- a/setup.py +++ b/setup.py @@ -28,8 +28,6 @@ def get_version(filename): '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 @@ def get_version(filename): ], '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 617131cc87..eac299cf80 100644 --- a/tests/audio/actor_test.py +++ b/tests/audio/actor_test.py @@ -6,6 +6,9 @@ pygst.require('0.10') import gst +import gobject +gobject.threads_init() + import pykka from mopidy import audio @@ -80,6 +83,22 @@ def test_set_volume_with_mixer_max_below_100(self): 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 0000000000..0f031736cf --- /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/backends/base/__init__.py b/tests/backends/base/__init__.py deleted file mode 100644 index 7dc4bcf6c4..0000000000 --- a/tests/backends/base/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import unicode_literals - - -def populate_tracklist(func): - def wrapper(self): - self.tl_tracks = self.core.tracklist.add(self.tracks) - return func(self) - - wrapper.__name__ = func.__name__ - wrapper.__doc__ = func.__doc__ - return wrapper diff --git a/tests/backends/base/events.py b/tests/backends/base/events.py deleted file mode 100644 index a5d9fa7b87..0000000000 --- a/tests/backends/base/events.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import unicode_literals - -import mock -import pykka - -from mopidy import core, audio -from mopidy.backends import listener - - -@mock.patch.object(listener.BackendListener, 'send') -class BackendEventsTest(object): - config = {} - - def setUp(self): - self.audio = audio.DummyAudio.start().proxy() - self.backend = self.backend_class.start( - config=self.config, audio=self.audio).proxy() - self.core = core.Core.start(backends=[self.backend]).proxy() - - def tearDown(self): - pykka.ActorRegistry.stop_all() - - def test_playlists_refresh_sends_playlists_loaded_event(self, send): - send.reset_mock() - self.core.playlists.refresh().get() - self.assertEqual(send.call_args[0][0], 'playlists_loaded') diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py deleted file mode 100644 index 23c76f38ee..0000000000 --- a/tests/backends/base/library.py +++ /dev/null @@ -1,202 +0,0 @@ -from __future__ import unicode_literals - -import unittest - -import pykka - -from mopidy import core -from mopidy.models import Track, Album, Artist - - -class LibraryControllerTest(object): - artists = [Artist(name='artist1'), Artist(name='artist2'), Artist()] - albums = [ - Album(name='album1', artists=artists[:1]), - Album(name='album2', artists=artists[1:2]), - Album()] - tracks = [ - Track(uri='local:track:path1', name='track1', artists=artists[:1], - album=albums[0], date='2001-02-03', length=4000), - Track(uri='local:track:path2', name='track2', artists=artists[1:2], - album=albums[1], date='2002', length=4000), - Track()] - config = {} - - def setUp(self): - self.backend = self.backend_class.start( - config=self.config, audio=None).proxy() - self.core = core.Core(backends=[self.backend]) - self.library = self.core.library - - def tearDown(self): - pykka.ActorRegistry.stop_all() - - def test_refresh(self): - self.library.refresh() - - @unittest.SkipTest - def test_refresh_uri(self): - pass - - @unittest.SkipTest - def test_refresh_missing_uri(self): - pass - - def test_lookup(self): - tracks = self.library.lookup(self.tracks[0].uri) - self.assertEqual(tracks, self.tracks[0:1]) - - def test_lookup_unknown_track(self): - tracks = self.library.lookup('fake uri') - self.assertEqual(tracks, []) - - def test_find_exact_no_hits(self): - result = self.library.find_exact(track=['unknown track']) - self.assertEqual(list(result[0].tracks), []) - - result = self.library.find_exact(artist=['unknown artist']) - self.assertEqual(list(result[0].tracks), []) - - result = self.library.find_exact(album=['unknown artist']) - self.assertEqual(list(result[0].tracks), []) - - def test_find_exact_uri(self): - track_1_uri = 'local:track:path1' - result = self.library.find_exact(uri=track_1_uri) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - track_2_uri = 'local:track:path2' - result = self.library.find_exact(uri=track_2_uri) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - def test_find_exact_track(self): - result = self.library.find_exact(track=['track1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.find_exact(track=['track2']) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - def test_find_exact_artist(self): - result = self.library.find_exact(artist=['artist1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.find_exact(artist=['artist2']) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - def test_find_exact_album(self): - result = self.library.find_exact(album=['album1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.find_exact(album=['album2']) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - def test_find_exact_date(self): - result = self.library.find_exact(date=['2001']) - self.assertEqual(list(result[0].tracks), []) - - result = self.library.find_exact(date=['2001-02-03']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.find_exact(date=['2002']) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - def test_find_exact_wrong_type(self): - test = lambda: self.library.find_exact(wrong=['test']) - self.assertRaises(LookupError, test) - - def test_find_exact_with_empty_query(self): - test = lambda: self.library.find_exact(artist=['']) - self.assertRaises(LookupError, test) - - test = lambda: self.library.find_exact(track=['']) - self.assertRaises(LookupError, test) - - test = lambda: self.library.find_exact(album=['']) - self.assertRaises(LookupError, test) - - def test_search_no_hits(self): - result = self.library.search(track=['unknown track']) - self.assertEqual(list(result[0].tracks), []) - - result = self.library.search(artist=['unknown artist']) - self.assertEqual(list(result[0].tracks), []) - - result = self.library.search(album=['unknown artist']) - self.assertEqual(list(result[0].tracks), []) - - result = self.library.search(uri=['unknown']) - self.assertEqual(list(result[0].tracks), []) - - result = self.library.search(any=['unknown']) - self.assertEqual(list(result[0].tracks), []) - - def test_search_uri(self): - result = self.library.search(uri=['TH1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.search(uri=['TH2']) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - def test_search_track(self): - result = self.library.search(track=['Rack1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.search(track=['Rack2']) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - def test_search_artist(self): - result = self.library.search(artist=['Tist1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.search(artist=['Tist2']) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - def test_search_album(self): - result = self.library.search(album=['Bum1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.search(album=['Bum2']) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - def test_search_date(self): - result = self.library.search(date=['2001']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.search(date=['2001-02-03']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - result = self.library.search(date=['2001-02-04']) - self.assertEqual(list(result[0].tracks), []) - - result = self.library.search(date=['2002']) - self.assertEqual(list(result[0].tracks), self.tracks[1:2]) - - def test_search_any(self): - result = self.library.search(any=['Tist1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(any=['Rack1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(any=['Bum1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(any=['TH1']) - self.assertEqual(list(result[0].tracks), self.tracks[:1]) - - def test_search_wrong_type(self): - test = lambda: self.library.search(wrong=['test']) - self.assertRaises(LookupError, test) - - def test_search_with_empty_query(self): - test = lambda: self.library.search(artist=['']) - self.assertRaises(LookupError, test) - - test = lambda: self.library.search(track=['']) - self.assertRaises(LookupError, test) - - test = lambda: self.library.search(album=['']) - self.assertRaises(LookupError, test) - - test = lambda: self.library.search(uri=['']) - self.assertRaises(LookupError, test) - - test = lambda: self.library.search(any=['']) - self.assertRaises(LookupError, test) diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py deleted file mode 100644 index 44ae40f95e..0000000000 --- a/tests/backends/base/playback.py +++ /dev/null @@ -1,876 +0,0 @@ -from __future__ import unicode_literals - -import mock -import random -import time -import unittest - -import pykka - -from mopidy import audio, core -from mopidy.core import PlaybackState -from mopidy.models import Track - -from tests.backends.base import populate_tracklist - -# TODO Test 'playlist repeat', e.g. repeat=1,single=0 - - -class PlaybackControllerTest(object): - tracks = [] - config = {} - - def setUp(self): - self.audio = audio.DummyAudio.start().proxy() - self.backend = self.backend_class.start( - config=self.config, audio=self.audio).proxy() - self.core = core.Core(backends=[self.backend]) - self.playback = self.core.playback - self.tracklist = self.core.tracklist - - assert len(self.tracks) >= 3, \ - 'Need at least three tracks to run tests.' - assert self.tracks[0].length >= 2000, \ - 'First song needs to be at least 2000 miliseconds' - - def tearDown(self): - pykka.ActorRegistry.stop_all() - - def test_initial_state_is_stopped(self): - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - def test_play_with_empty_playlist(self): - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - def test_play_with_empty_playlist_return_value(self): - self.assertEqual(self.playback.play(), None) - - @populate_tracklist - def test_play_state(self): - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - - @populate_tracklist - def test_play_return_value(self): - self.assertEqual(self.playback.play(), None) - - @populate_tracklist - def test_play_track_state(self): - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.playback.play(self.tracklist.tl_tracks[-1]) - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - - @populate_tracklist - def test_play_track_return_value(self): - self.assertEqual(self.playback.play( - self.tracklist.tl_tracks[-1]), None) - - @populate_tracklist - def test_play_when_playing(self): - self.playback.play() - track = self.playback.current_track - self.playback.play() - self.assertEqual(track, self.playback.current_track) - - @populate_tracklist - def test_play_when_paused(self): - self.playback.play() - track = self.playback.current_track - self.playback.pause() - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(track, self.playback.current_track) - - @populate_tracklist - def test_play_when_pause_after_next(self): - self.playback.play() - self.playback.next() - self.playback.next() - track = self.playback.current_track - self.playback.pause() - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(track, self.playback.current_track) - - @populate_tracklist - def test_play_sets_current_track(self): - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[0]) - - @populate_tracklist - def test_play_track_sets_current_track(self): - self.playback.play(self.tracklist.tl_tracks[-1]) - self.assertEqual(self.playback.current_track, self.tracks[-1]) - - @populate_tracklist - def test_play_skips_to_next_track_on_failure(self): - # If backend's play() returns False, it is a failure. - self.backend.playback.play = lambda track: track != self.tracks[0] - self.playback.play() - self.assertNotEqual(self.playback.current_track, self.tracks[0]) - self.assertEqual(self.playback.current_track, self.tracks[1]) - - @populate_tracklist - def test_current_track_after_completed_playlist(self): - self.playback.play(self.tracklist.tl_tracks[-1]) - self.playback.on_end_of_track() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.assertEqual(self.playback.current_track, None) - - self.playback.play(self.tracklist.tl_tracks[-1]) - self.playback.next() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.assertEqual(self.playback.current_track, None) - - @populate_tracklist - def test_previous(self): - self.playback.play() - self.playback.next() - self.playback.previous() - self.assertEqual(self.playback.current_track, self.tracks[0]) - - @populate_tracklist - def test_previous_more(self): - self.playback.play() # At track 0 - self.playback.next() # At track 1 - self.playback.next() # At track 2 - self.playback.previous() # At track 1 - self.assertEqual(self.playback.current_track, self.tracks[1]) - - @populate_tracklist - def test_previous_return_value(self): - self.playback.play() - self.playback.next() - self.assertEqual(self.playback.previous(), None) - - @populate_tracklist - def test_previous_does_not_trigger_playback(self): - self.playback.play() - self.playback.next() - self.playback.stop() - self.playback.previous() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_previous_at_start_of_playlist(self): - self.playback.previous() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.assertEqual(self.playback.current_track, None) - - def test_previous_for_empty_playlist(self): - self.playback.previous() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.assertEqual(self.playback.current_track, None) - - @populate_tracklist - def test_previous_skips_to_previous_track_on_failure(self): - # If backend's play() returns False, it is a failure. - self.backend.playback.play = lambda track: track != self.tracks[1] - self.playback.play(self.tracklist.tl_tracks[2]) - self.assertEqual(self.playback.current_track, self.tracks[2]) - self.playback.previous() - self.assertNotEqual(self.playback.current_track, self.tracks[1]) - self.assertEqual(self.playback.current_track, self.tracks[0]) - - @populate_tracklist - def test_next(self): - self.playback.play() - - old_position = self.playback.tracklist_position - old_uri = self.playback.current_track.uri - - self.playback.next() - - self.assertEqual( - self.playback.tracklist_position, old_position + 1) - self.assertNotEqual(self.playback.current_track.uri, old_uri) - - @populate_tracklist - def test_next_return_value(self): - self.playback.play() - self.assertEqual(self.playback.next(), None) - - @populate_tracklist - def test_next_does_not_trigger_playback(self): - self.playback.next() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_next_at_end_of_playlist(self): - self.playback.play() - - for i, track in enumerate(self.tracks): - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(self.playback.current_track, track) - self.assertEqual(self.playback.tracklist_position, i) - - self.playback.next() - - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_next_until_end_of_playlist_and_play_from_start(self): - self.playback.play() - - for _ in self.tracks: - self.playback.next() - - self.assertEqual(self.playback.current_track, None) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(self.playback.current_track, self.tracks[0]) - - def test_next_for_empty_playlist(self): - self.playback.next() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_next_skips_to_next_track_on_failure(self): - # If backend's play() returns False, it is a failure. - self.backend.playback.play = lambda track: track != self.tracks[1] - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[0]) - self.playback.next() - self.assertNotEqual(self.playback.current_track, self.tracks[1]) - self.assertEqual(self.playback.current_track, self.tracks[2]) - - @populate_tracklist - def test_next_track_before_play(self): - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[0]) - - @populate_tracklist - def test_next_track_during_play(self): - self.playback.play() - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) - - @populate_tracklist - def test_next_track_after_previous(self): - self.playback.play() - self.playback.next() - self.playback.previous() - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) - - def test_next_track_empty_playlist(self): - self.assertEqual(self.playback.tl_track_at_next, None) - - @populate_tracklist - def test_next_track_at_end_of_playlist(self): - self.playback.play() - for _ in self.tracklist.tl_tracks[1:]: - self.playback.next() - self.assertEqual(self.playback.tl_track_at_next, None) - - @populate_tracklist - def test_next_track_at_end_of_playlist_with_repeat(self): - self.playback.repeat = True - self.playback.play() - for _ in self.tracks[1:]: - self.playback.next() - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[0]) - - @populate_tracklist - def test_next_track_with_random(self): - random.seed(1) - self.playback.random = True - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) - - @populate_tracklist - def test_next_with_consume(self): - self.playback.consume = True - self.playback.play() - self.playback.next() - self.assertIn(self.tracks[0], self.tracklist.tracks) - - @populate_tracklist - def test_next_with_single_and_repeat(self): - self.playback.single = True - self.playback.repeat = True - self.playback.play() - self.playback.next() - self.assertEqual(self.playback.current_track, self.tracks[1]) - - @populate_tracklist - def test_next_with_random(self): - # FIXME feels very fragile - random.seed(1) - self.playback.random = True - self.playback.play() - self.playback.next() - self.assertEqual(self.playback.current_track, self.tracks[1]) - - @populate_tracklist - def test_next_track_with_random_after_append_playlist(self): - random.seed(1) - self.playback.random = True - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) - self.tracklist.add(self.tracks[:1]) - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) - - @populate_tracklist - def test_end_of_track(self): - self.playback.play() - - old_position = self.playback.tracklist_position - old_uri = self.playback.current_track.uri - - self.playback.on_end_of_track() - - self.assertEqual( - self.playback.tracklist_position, old_position + 1) - self.assertNotEqual(self.playback.current_track.uri, old_uri) - - @populate_tracklist - def test_end_of_track_return_value(self): - self.playback.play() - self.assertEqual(self.playback.on_end_of_track(), None) - - @populate_tracklist - def test_end_of_track_does_not_trigger_playback(self): - self.playback.on_end_of_track() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_end_of_track_at_end_of_playlist(self): - self.playback.play() - - for i, track in enumerate(self.tracks): - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(self.playback.current_track, track) - self.assertEqual(self.playback.tracklist_position, i) - - self.playback.on_end_of_track() - - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_end_of_track_until_end_of_playlist_and_play_from_start(self): - self.playback.play() - - for _ in self.tracks: - self.playback.on_end_of_track() - - self.assertEqual(self.playback.current_track, None) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(self.playback.current_track, self.tracks[0]) - - def test_end_of_track_for_empty_playlist(self): - self.playback.on_end_of_track() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_end_of_track_skips_to_next_track_on_failure(self): - # If backend's play() returns False, it is a failure. - self.backend.playback.play = lambda track: track != self.tracks[1] - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[0]) - self.playback.on_end_of_track() - self.assertNotEqual(self.playback.current_track, self.tracks[1]) - self.assertEqual(self.playback.current_track, self.tracks[2]) - - @populate_tracklist - def test_end_of_track_track_before_play(self): - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[0]) - - @populate_tracklist - def test_end_of_track_track_during_play(self): - self.playback.play() - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) - - @populate_tracklist - def test_end_of_track_track_after_previous(self): - self.playback.play() - self.playback.on_end_of_track() - self.playback.previous() - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) - - def test_end_of_track_track_empty_playlist(self): - self.assertEqual(self.playback.tl_track_at_next, None) - - @populate_tracklist - def test_end_of_track_track_at_end_of_playlist(self): - self.playback.play() - for _ in self.tracklist.tl_tracks[1:]: - self.playback.on_end_of_track() - self.assertEqual(self.playback.tl_track_at_next, None) - - @populate_tracklist - def test_end_of_track_track_at_end_of_playlist_with_repeat(self): - self.playback.repeat = True - self.playback.play() - for _ in self.tracks[1:]: - self.playback.on_end_of_track() - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[0]) - - @populate_tracklist - def test_end_of_track_track_with_random(self): - random.seed(1) - self.playback.random = True - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) - - @populate_tracklist - def test_end_of_track_with_consume(self): - self.playback.consume = True - self.playback.play() - self.playback.on_end_of_track() - self.assertNotIn(self.tracks[0], self.tracklist.tracks) - - @populate_tracklist - def test_end_of_track_with_random(self): - # FIXME feels very fragile - random.seed(1) - self.playback.random = True - self.playback.play() - self.playback.on_end_of_track() - self.assertEqual(self.playback.current_track, self.tracks[1]) - - @populate_tracklist - def test_end_of_track_track_with_random_after_append_playlist(self): - random.seed(1) - self.playback.random = True - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) - self.tracklist.add(self.tracks[:1]) - self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) - - @populate_tracklist - def test_previous_track_before_play(self): - self.assertEqual(self.playback.tl_track_at_previous, None) - - @populate_tracklist - def test_previous_track_after_play(self): - self.playback.play() - self.assertEqual(self.playback.tl_track_at_previous, None) - - @populate_tracklist - def test_previous_track_after_next(self): - self.playback.play() - self.playback.next() - self.assertEqual(self.playback.tl_track_at_previous, self.tl_tracks[0]) - - @populate_tracklist - def test_previous_track_after_previous(self): - self.playback.play() # At track 0 - self.playback.next() # At track 1 - self.playback.next() # At track 2 - self.playback.previous() # At track 1 - self.assertEqual(self.playback.tl_track_at_previous, self.tl_tracks[0]) - - def test_previous_track_empty_playlist(self): - self.assertEqual(self.playback.tl_track_at_previous, None) - - @populate_tracklist - def test_previous_track_with_consume(self): - self.playback.consume = True - for _ in self.tracks: - self.playback.next() - self.assertEqual( - self.playback.tl_track_at_previous, - self.playback.current_tl_track) - - @populate_tracklist - def test_previous_track_with_random(self): - self.playback.random = True - for _ in self.tracks: - self.playback.next() - self.assertEqual( - self.playback.tl_track_at_previous, - self.playback.current_tl_track) - - @populate_tracklist - def test_initial_current_track(self): - self.assertEqual(self.playback.current_track, None) - - @populate_tracklist - def test_current_track_during_play(self): - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[0]) - - @populate_tracklist - def test_current_track_after_next(self): - self.playback.play() - self.playback.next() - self.assertEqual(self.playback.current_track, self.tracks[1]) - - @populate_tracklist - def test_initial_tracklist_position(self): - self.assertEqual(self.playback.tracklist_position, None) - - @populate_tracklist - def test_tracklist_position_during_play(self): - self.playback.play() - self.assertEqual(self.playback.tracklist_position, 0) - - @populate_tracklist - def test_tracklist_position_after_next(self): - self.playback.play() - self.playback.next() - self.assertEqual(self.playback.tracklist_position, 1) - - @populate_tracklist - def test_tracklist_position_at_end_of_playlist(self): - self.playback.play(self.tracklist.tl_tracks[-1]) - self.playback.on_end_of_track() - self.assertEqual(self.playback.tracklist_position, None) - - def test_on_tracklist_change_gets_called(self): - callback = self.playback.on_tracklist_change - - def wrapper(): - wrapper.called = True - return callback() - wrapper.called = False - - self.playback.on_tracklist_change = wrapper - self.tracklist.add([Track()]) - - self.assert_(wrapper.called) - - @unittest.SkipTest # Blocks for 10ms - @populate_tracklist - def test_end_of_track_callback_gets_called(self): - self.playback.play() - result = self.playback.seek(self.tracks[0].length - 10) - self.assertTrue(result, 'Seek failed') - message = self.core_queue.get(True, 1) - self.assertEqual('end_of_track', message['command']) - - @populate_tracklist - def test_on_tracklist_change_when_playing(self): - self.playback.play() - current_track = self.playback.current_track - self.tracklist.add([self.tracks[2]]) - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(self.playback.current_track, current_track) - - @populate_tracklist - def test_on_tracklist_change_when_stopped(self): - self.tracklist.add([self.tracks[2]]) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.assertEqual(self.playback.current_track, None) - - @populate_tracklist - def test_on_tracklist_change_when_paused(self): - self.playback.play() - self.playback.pause() - current_track = self.playback.current_track - self.tracklist.add([self.tracks[2]]) - self.assertEqual(self.playback.state, PlaybackState.PAUSED) - self.assertEqual(self.playback.current_track, current_track) - - @populate_tracklist - def test_pause_when_stopped(self): - self.playback.pause() - self.assertEqual(self.playback.state, PlaybackState.PAUSED) - - @populate_tracklist - def test_pause_when_playing(self): - self.playback.play() - self.playback.pause() - self.assertEqual(self.playback.state, PlaybackState.PAUSED) - - @populate_tracklist - def test_pause_when_paused(self): - self.playback.play() - self.playback.pause() - self.playback.pause() - self.assertEqual(self.playback.state, PlaybackState.PAUSED) - - @populate_tracklist - def test_pause_return_value(self): - self.playback.play() - self.assertEqual(self.playback.pause(), None) - - @populate_tracklist - def test_resume_when_stopped(self): - self.playback.resume() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_resume_when_playing(self): - self.playback.play() - self.playback.resume() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - - @populate_tracklist - def test_resume_when_paused(self): - self.playback.play() - self.playback.pause() - self.playback.resume() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - - @populate_tracklist - def test_resume_return_value(self): - self.playback.play() - self.playback.pause() - self.assertEqual(self.playback.resume(), None) - - @unittest.SkipTest # Uses sleep and might not work with LocalBackend - @populate_tracklist - def test_resume_continues_from_right_position(self): - self.playback.play() - time.sleep(0.2) - self.playback.pause() - self.playback.resume() - self.assertNotEqual(self.playback.time_position, 0) - - @populate_tracklist - def test_seek_when_stopped(self): - result = self.playback.seek(1000) - self.assert_(result, 'Seek return value was %s' % result) - - @populate_tracklist - def test_seek_when_stopped_updates_position(self): - self.playback.seek(1000) - position = self.playback.time_position - self.assertGreaterEqual(position, 990) - - def test_seek_on_empty_playlist(self): - self.assertFalse(self.playback.seek(0)) - - def test_seek_on_empty_playlist_updates_position(self): - self.playback.seek(0) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_seek_when_stopped_triggers_play(self): - self.playback.seek(0) - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - - @populate_tracklist - def test_seek_when_playing(self): - self.playback.play() - result = self.playback.seek(self.tracks[0].length - 1000) - self.assert_(result, 'Seek return value was %s' % result) - - @populate_tracklist - def test_seek_when_playing_updates_position(self): - length = self.tracklist.tracks[0].length - self.playback.play() - self.playback.seek(length - 1000) - position = self.playback.time_position - self.assertGreaterEqual(position, length - 1010) - - @populate_tracklist - def test_seek_when_paused(self): - self.playback.play() - self.playback.pause() - result = self.playback.seek(self.tracks[0].length - 1000) - self.assert_(result, 'Seek return value was %s' % result) - - @populate_tracklist - def test_seek_when_paused_updates_position(self): - length = self.tracklist.tracks[0].length - self.playback.play() - self.playback.pause() - self.playback.seek(length - 1000) - position = self.playback.time_position - self.assertGreaterEqual(position, length - 1010) - - @populate_tracklist - def test_seek_when_paused_triggers_play(self): - self.playback.play() - self.playback.pause() - self.playback.seek(0) - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - - @unittest.SkipTest - @populate_tracklist - def test_seek_beyond_end_of_song(self): - # FIXME need to decide return value - self.playback.play() - result = self.playback.seek(self.tracks[0].length * 100) - self.assert_(not result, 'Seek return value was %s' % result) - - @populate_tracklist - def test_seek_beyond_end_of_song_jumps_to_next_song(self): - self.playback.play() - self.playback.seek(self.tracks[0].length * 100) - self.assertEqual(self.playback.current_track, self.tracks[1]) - - @populate_tracklist - def test_seek_beyond_end_of_song_for_last_track(self): - self.playback.play(self.tracklist.tl_tracks[-1]) - self.playback.seek(self.tracklist.tracks[-1].length * 100) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @unittest.SkipTest - @populate_tracklist - def test_seek_beyond_start_of_song(self): - # FIXME need to decide return value - self.playback.play() - result = self.playback.seek(-1000) - self.assert_(not result, 'Seek return value was %s' % result) - - @populate_tracklist - def test_seek_beyond_start_of_song_update_postion(self): - self.playback.play() - self.playback.seek(-1000) - position = self.playback.time_position - self.assertGreaterEqual(position, 0) - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - - @populate_tracklist - def test_stop_when_stopped(self): - self.playback.stop() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_stop_when_playing(self): - self.playback.play() - self.playback.stop() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - @populate_tracklist - def test_stop_when_paused(self): - self.playback.play() - self.playback.pause() - self.playback.stop() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - def test_stop_return_value(self): - self.playback.play() - self.assertEqual(self.playback.stop(), None) - - def test_time_position_when_stopped(self): - future = mock.Mock() - future.get = mock.Mock(return_value=0) - self.audio.get_position = mock.Mock(return_value=future) - - self.assertEqual(self.playback.time_position, 0) - - @populate_tracklist - def test_time_position_when_stopped_with_playlist(self): - future = mock.Mock() - future.get = mock.Mock(return_value=0) - self.audio.get_position = mock.Mock(return_value=future) - - self.assertEqual(self.playback.time_position, 0) - - @unittest.SkipTest # Uses sleep and does might not work with LocalBackend - @populate_tracklist - def test_time_position_when_playing(self): - self.playback.play() - first = self.playback.time_position - time.sleep(1) - second = self.playback.time_position - self.assertGreater(second, first) - - @unittest.SkipTest # Uses sleep - @populate_tracklist - def test_time_position_when_paused(self): - self.playback.play() - time.sleep(0.2) - self.playback.pause() - time.sleep(0.2) - first = self.playback.time_position - second = self.playback.time_position - self.assertEqual(first, second) - - @populate_tracklist - def test_play_with_consume(self): - self.playback.consume = True - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[0]) - - @populate_tracklist - def test_playlist_is_empty_after_all_tracks_are_played_with_consume(self): - self.playback.consume = True - self.playback.play() - for _ in range(len(self.tracklist.tracks)): - self.playback.on_end_of_track() - self.assertEqual(len(self.tracklist.tracks), 0) - - @populate_tracklist - def test_play_with_random(self): - random.seed(1) - self.playback.random = True - self.playback.play() - self.assertEqual(self.playback.current_track, self.tracks[2]) - - @populate_tracklist - def test_previous_with_random(self): - random.seed(1) - self.playback.random = True - self.playback.play() - self.playback.next() - current_track = self.playback.current_track - self.playback.previous() - self.assertEqual(self.playback.current_track, current_track) - - @populate_tracklist - def test_end_of_song_starts_next_track(self): - self.playback.play() - self.playback.on_end_of_track() - self.assertEqual(self.playback.current_track, self.tracks[1]) - - @populate_tracklist - def test_end_of_song_with_single_and_repeat_starts_same(self): - self.playback.single = True - self.playback.repeat = True - self.playback.play() - self.playback.on_end_of_track() - self.assertEqual(self.playback.current_track, self.tracks[0]) - - @populate_tracklist - def test_end_of_playlist_stops(self): - self.playback.play(self.tracklist.tl_tracks[-1]) - self.playback.on_end_of_track() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - def test_repeat_off_by_default(self): - self.assertEqual(self.playback.repeat, False) - - def test_random_off_by_default(self): - self.assertEqual(self.playback.random, False) - - def test_consume_off_by_default(self): - self.assertEqual(self.playback.consume, False) - - @populate_tracklist - def test_random_until_end_of_playlist(self): - self.playback.random = True - self.playback.play() - for _ in self.tracks[1:]: - self.playback.next() - self.assertEqual(self.playback.tl_track_at_next, None) - - @populate_tracklist - def test_random_until_end_of_playlist_and_play_from_start(self): - self.playback.repeat = True - for _ in self.tracks: - self.playback.next() - self.assertNotEqual(self.playback.tl_track_at_next, None) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - - @populate_tracklist - def test_random_until_end_of_playlist_with_repeat(self): - self.playback.repeat = True - self.playback.random = True - self.playback.play() - for _ in self.tracks: - self.playback.next() - self.assertNotEqual(self.playback.tl_track_at_next, None) - - @populate_tracklist - def test_played_track_during_random_not_played_again(self): - self.playback.random = True - self.playback.play() - played = [] - for _ in self.tracks: - self.assertNotIn(self.playback.current_track, played) - played.append(self.playback.current_track) - self.playback.next() - - @populate_tracklist - def test_playing_track_that_isnt_in_playlist(self): - test = lambda: self.playback.play((17, Track())) - self.assertRaises(AssertionError, test) diff --git a/tests/backends/base/playlists.py b/tests/backends/base/playlists.py deleted file mode 100644 index 139c21c6d2..0000000000 --- a/tests/backends/base/playlists.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import unicode_literals - -import unittest - -import pykka - -from mopidy import audio, core -from mopidy.models import Playlist - - -class PlaylistsControllerTest(object): - config = {} - - def setUp(self): - self.audio = audio.DummyAudio.start().proxy() - self.backend = self.backend_class.start( - config=self.config, audio=self.audio).proxy() - self.core = core.Core(backends=[self.backend]) - - def tearDown(self): - pykka.ActorRegistry.stop_all() - - def test_create_returns_playlist_with_name_set(self): - playlist = self.core.playlists.create('test') - self.assertEqual(playlist.name, 'test') - - def test_create_returns_playlist_with_uri_set(self): - playlist = self.core.playlists.create('test') - self.assert_(playlist.uri) - - def test_create_adds_playlist_to_playlists_collection(self): - playlist = self.core.playlists.create('test') - self.assert_(self.core.playlists.playlists) - self.assertIn(playlist, self.core.playlists.playlists) - - def test_playlists_empty_to_start_with(self): - self.assert_(not self.core.playlists.playlists) - - def test_delete_non_existant_playlist(self): - self.core.playlists.delete('file:///unknown/playlist') - - def test_delete_playlist_removes_it_from_the_collection(self): - playlist = self.core.playlists.create('test') - self.assertIn(playlist, self.core.playlists.playlists) - - self.core.playlists.delete(playlist.uri) - - self.assertNotIn(playlist, self.core.playlists.playlists) - - def test_filter_without_criteria(self): - self.assertEqual( - self.core.playlists.playlists, self.core.playlists.filter()) - - def test_filter_with_wrong_criteria(self): - self.assertEqual([], self.core.playlists.filter(name='foo')) - - def test_filter_with_right_criteria(self): - playlist = self.core.playlists.create('test') - playlists = self.core.playlists.filter(name='test') - self.assertEqual([playlist], playlists) - - def test_filter_by_name_returns_single_match(self): - playlist = Playlist(name='b') - self.backend.playlists.playlists = [Playlist(name='a'), playlist] - self.assertEqual([playlist], self.core.playlists.filter(name='b')) - - def test_filter_by_name_returns_multiple_matches(self): - playlist = Playlist(name='b') - self.backend.playlists.playlists = [ - playlist, Playlist(name='a'), Playlist(name='b')] - playlists = self.core.playlists.filter(name='b') - self.assertIn(playlist, playlists) - self.assertEqual(2, len(playlists)) - - def test_filter_by_name_returns_no_matches(self): - self.backend.playlists.playlists = [ - Playlist(name='a'), Playlist(name='b')] - self.assertEqual([], self.core.playlists.filter(name='c')) - - def test_lookup_finds_playlist_by_uri(self): - original_playlist = self.core.playlists.create('test') - - looked_up_playlist = self.core.playlists.lookup(original_playlist.uri) - - self.assertEqual(original_playlist, looked_up_playlist) - - @unittest.SkipTest - def test_refresh(self): - pass - - def test_save_replaces_existing_playlist_with_updated_playlist(self): - playlist1 = self.core.playlists.create('test1') - self.assertIn(playlist1, self.core.playlists.playlists) - - playlist2 = playlist1.copy(name='test2') - playlist2 = self.core.playlists.save(playlist2) - self.assertNotIn(playlist1, self.core.playlists.playlists) - self.assertIn(playlist2, self.core.playlists.playlists) - - @unittest.SkipTest - def test_playlist_with_unknown_track(self): - pass diff --git a/tests/backends/base/tracklist.py b/tests/backends/base/tracklist.py deleted file mode 100644 index 5140d3aa26..0000000000 --- a/tests/backends/base/tracklist.py +++ /dev/null @@ -1,295 +0,0 @@ -from __future__ import unicode_literals - -import random - -import pykka - -from mopidy import audio, core -from mopidy.core import PlaybackState -from mopidy.models import TlTrack, Playlist, Track - -from tests.backends.base import populate_tracklist - - -class TracklistControllerTest(object): - tracks = [] - config = {} - - def setUp(self): - self.audio = audio.DummyAudio.start().proxy() - self.backend = self.backend_class.start( - config=self.config, audio=self.audio).proxy() - self.core = core.Core(audio=self.audio, backends=[self.backend]) - self.controller = self.core.tracklist - self.playback = self.core.playback - - assert len(self.tracks) == 3, 'Need three tracks to run tests.' - - def tearDown(self): - pykka.ActorRegistry.stop_all() - - def test_length(self): - self.assertEqual(0, len(self.controller.tl_tracks)) - self.assertEqual(0, self.controller.length) - self.controller.add(self.tracks) - self.assertEqual(3, len(self.controller.tl_tracks)) - self.assertEqual(3, self.controller.length) - - def test_add(self): - for track in self.tracks: - tl_tracks = self.controller.add([track]) - self.assertEqual(track, self.controller.tracks[-1]) - self.assertEqual(tl_tracks[0], self.controller.tl_tracks[-1]) - self.assertEqual(track, tl_tracks[0].track) - - def test_add_at_position(self): - for track in self.tracks[:-1]: - tl_tracks = self.controller.add([track], 0) - self.assertEqual(track, self.controller.tracks[0]) - self.assertEqual(tl_tracks[0], self.controller.tl_tracks[0]) - self.assertEqual(track, tl_tracks[0].track) - - @populate_tracklist - def test_add_at_position_outside_of_playlist(self): - for track in self.tracks: - tl_tracks = self.controller.add([track], len(self.tracks) + 2) - self.assertEqual(track, self.controller.tracks[-1]) - self.assertEqual(tl_tracks[0], self.controller.tl_tracks[-1]) - self.assertEqual(track, tl_tracks[0].track) - - @populate_tracklist - def test_filter_by_tlid(self): - tl_track = self.controller.tl_tracks[1] - self.assertEqual( - [tl_track], self.controller.filter(tlid=tl_track.tlid)) - - @populate_tracklist - def test_filter_by_uri(self): - tl_track = self.controller.tl_tracks[1] - self.assertEqual( - [tl_track], self.controller.filter(uri=tl_track.track.uri)) - - @populate_tracklist - def test_filter_by_uri_returns_nothing_for_invalid_uri(self): - self.assertEqual([], self.controller.filter(uri='foobar')) - - def test_filter_by_uri_returns_single_match(self): - track = Track(uri='a') - self.controller.add([Track(uri='z'), track, Track(uri='y')]) - self.assertEqual(track, self.controller.filter(uri='a')[0].track) - - def test_filter_by_uri_returns_multiple_matches(self): - track = Track(uri='a') - self.controller.add([Track(uri='z'), track, track]) - tl_tracks = self.controller.filter(uri='a') - self.assertEqual(track, tl_tracks[0].track) - self.assertEqual(track, tl_tracks[1].track) - - def test_filter_by_uri_returns_nothing_if_no_match(self): - self.controller.playlist = Playlist( - tracks=[Track(uri='z'), Track(uri='y')]) - self.assertEqual([], self.controller.filter(uri='a')) - - def test_filter_by_multiple_criteria_returns_elements_matching_all(self): - track1 = Track(uri='a', name='x') - track2 = Track(uri='b', name='x') - track3 = Track(uri='b', name='y') - self.controller.add([track1, track2, track3]) - self.assertEqual( - track1, self.controller.filter(uri='a', name='x')[0].track) - self.assertEqual( - track2, self.controller.filter(uri='b', name='x')[0].track) - self.assertEqual( - track3, self.controller.filter(uri='b', name='y')[0].track) - - def test_filter_by_criteria_that_is_not_present_in_all_elements(self): - track1 = Track() - track2 = Track(uri='b') - track3 = Track() - self.controller.add([track1, track2, track3]) - self.assertEqual(track2, self.controller.filter(uri='b')[0].track) - - @populate_tracklist - def test_clear(self): - self.controller.clear() - self.assertEqual(len(self.controller.tracks), 0) - - def test_clear_empty_playlist(self): - self.controller.clear() - self.assertEqual(len(self.controller.tracks), 0) - - @populate_tracklist - def test_clear_when_playing(self): - self.playback.play() - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.controller.clear() - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - - def test_add_appends_to_the_tracklist(self): - self.controller.add([Track(uri='a'), Track(uri='b')]) - self.assertEqual(len(self.controller.tracks), 2) - self.controller.add([Track(uri='c'), Track(uri='d')]) - self.assertEqual(len(self.controller.tracks), 4) - self.assertEqual(self.controller.tracks[0].uri, 'a') - self.assertEqual(self.controller.tracks[1].uri, 'b') - self.assertEqual(self.controller.tracks[2].uri, 'c') - self.assertEqual(self.controller.tracks[3].uri, 'd') - - def test_add_does_not_reset_version(self): - version = self.controller.version - self.controller.add([]) - self.assertEqual(self.controller.version, version) - - @populate_tracklist - def test_add_preserves_playing_state(self): - self.playback.play() - track = self.playback.current_track - self.controller.add(self.controller.tracks[1:2]) - self.assertEqual(self.playback.state, PlaybackState.PLAYING) - self.assertEqual(self.playback.current_track, track) - - @populate_tracklist - def test_add_preserves_stopped_state(self): - self.controller.add(self.controller.tracks[1:2]) - self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.assertEqual(self.playback.current_track, None) - - @populate_tracklist - def test_add_returns_the_tl_tracks_that_was_added(self): - tl_tracks = self.controller.add(self.controller.tracks[1:2]) - self.assertEqual(tl_tracks[0].track, self.controller.tracks[1]) - - def test_index_returns_index_of_track(self): - tl_tracks = self.controller.add(self.tracks) - self.assertEquals(0, self.controller.index(tl_tracks[0])) - self.assertEquals(1, self.controller.index(tl_tracks[1])) - self.assertEquals(2, self.controller.index(tl_tracks[2])) - - def test_index_raises_value_error_if_item_not_found(self): - test = lambda: self.controller.index(TlTrack(0, Track())) - self.assertRaises(ValueError, test) - - @populate_tracklist - def test_move_single(self): - self.controller.move(0, 0, 2) - - tracks = self.controller.tracks - self.assertEqual(tracks[2], self.tracks[0]) - - @populate_tracklist - def test_move_group(self): - self.controller.move(0, 2, 1) - - tracks = self.controller.tracks - self.assertEqual(tracks[1], self.tracks[0]) - self.assertEqual(tracks[2], self.tracks[1]) - - @populate_tracklist - def test_moving_track_outside_of_playlist(self): - tracks = len(self.controller.tracks) - test = lambda: self.controller.move(0, 0, tracks + 5) - self.assertRaises(AssertionError, test) - - @populate_tracklist - def test_move_group_outside_of_playlist(self): - tracks = len(self.controller.tracks) - test = lambda: self.controller.move(0, 2, tracks + 5) - self.assertRaises(AssertionError, test) - - @populate_tracklist - def test_move_group_out_of_range(self): - tracks = len(self.controller.tracks) - test = lambda: self.controller.move(tracks + 2, tracks + 3, 0) - self.assertRaises(AssertionError, test) - - @populate_tracklist - def test_move_group_invalid_group(self): - test = lambda: self.controller.move(2, 1, 0) - self.assertRaises(AssertionError, test) - - def test_tracks_attribute_is_immutable(self): - tracks1 = self.controller.tracks - tracks2 = self.controller.tracks - self.assertNotEqual(id(tracks1), id(tracks2)) - - @populate_tracklist - def test_remove(self): - track1 = self.controller.tracks[1] - track2 = self.controller.tracks[2] - version = self.controller.version - self.controller.remove(uri=track1.uri) - self.assertLess(version, self.controller.version) - self.assertNotIn(track1, self.controller.tracks) - self.assertEqual(track2, self.controller.tracks[1]) - - @populate_tracklist - def test_removing_track_that_does_not_exist_does_nothing(self): - self.controller.remove(uri='/nonexistant') - - def test_removing_from_empty_playlist_does_nothing(self): - self.controller.remove(uri='/nonexistant') - - @populate_tracklist - def test_shuffle(self): - random.seed(1) - self.controller.shuffle() - - shuffled_tracks = self.controller.tracks - - self.assertNotEqual(self.tracks, shuffled_tracks) - self.assertEqual(set(self.tracks), set(shuffled_tracks)) - - @populate_tracklist - def test_shuffle_subset(self): - random.seed(1) - self.controller.shuffle(1, 3) - - shuffled_tracks = self.controller.tracks - - self.assertNotEqual(self.tracks, shuffled_tracks) - self.assertEqual(self.tracks[0], shuffled_tracks[0]) - self.assertEqual(set(self.tracks), set(shuffled_tracks)) - - @populate_tracklist - def test_shuffle_invalid_subset(self): - test = lambda: self.controller.shuffle(3, 1) - self.assertRaises(AssertionError, test) - - @populate_tracklist - def test_shuffle_superset(self): - tracks = len(self.controller.tracks) - test = lambda: self.controller.shuffle(1, tracks + 5) - self.assertRaises(AssertionError, test) - - @populate_tracklist - def test_shuffle_open_subset(self): - random.seed(1) - self.controller.shuffle(1) - - shuffled_tracks = self.controller.tracks - - self.assertNotEqual(self.tracks, shuffled_tracks) - self.assertEqual(self.tracks[0], shuffled_tracks[0]) - self.assertEqual(set(self.tracks), set(shuffled_tracks)) - - @populate_tracklist - def test_slice_returns_a_subset_of_tracks(self): - track_slice = self.controller.slice(1, 3) - self.assertEqual(2, len(track_slice)) - self.assertEqual(self.tracks[1], track_slice[0].track) - self.assertEqual(self.tracks[2], track_slice[1].track) - - @populate_tracklist - def test_slice_returns_empty_list_if_indexes_outside_tracks_list(self): - self.assertEqual(0, len(self.controller.slice(7, 8))) - self.assertEqual(0, len(self.controller.slice(-1, 1))) - - def test_version_does_not_change_when_adding_nothing(self): - version = self.controller.version - self.controller.add([]) - self.assertEquals(version, self.controller.version) - - def test_version_increases_when_adding_something(self): - version = self.controller.version - self.controller.add([Track()]) - self.assertLess(version, self.controller.version) diff --git a/tests/backends/local/__init__.py b/tests/backends/local/__init__.py index 1738722f63..f408139f67 100644 --- a/tests/backends/local/__init__.py +++ b/tests/backends/local/__init__.py @@ -1,4 +1,15 @@ from __future__ import unicode_literals -generate_song = lambda i: 'local:track:song%s.wav' % i +def generate_song(i): + return 'local:track:song%s.wav' % i + + +def populate_tracklist(func): + def wrapper(self): + self.tl_tracks = self.core.tracklist.add(self.tracks) + return func(self) + + wrapper.__name__ = func.__name__ + wrapper.__doc__ = func.__doc__ + return wrapper diff --git a/tests/backends/local/events_test.py b/tests/backends/local/events_test.py index 7b7ceadd2f..725c580f1c 100644 --- a/tests/backends/local/events_test.py +++ b/tests/backends/local/events_test.py @@ -2,14 +2,18 @@ import unittest +import mock +import pykka + +from mopidy import core, audio +from mopidy.backends import listener from mopidy.backends.local import actor from tests import path_to_data_dir -from tests.backends.base import events -class LocalBackendEventsTest(events.BackendEventsTest, unittest.TestCase): - backend_class = actor.LocalBackend +@mock.patch.object(listener.BackendListener, 'send') +class LocalBackendEventsTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), @@ -17,3 +21,17 @@ class LocalBackendEventsTest(events.BackendEventsTest, unittest.TestCase): 'tag_cache_file': path_to_data_dir('empty_tag_cache'), } } + + def setUp(self): + self.audio = audio.DummyAudio.start().proxy() + self.backend = actor.LocalBackend.start( + config=self.config, audio=self.audio).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() + + def tearDown(self): + pykka.ActorRegistry.stop_all() + + def test_playlists_refresh_sends_playlists_loaded_event(self, send): + send.reset_mock() + self.core.playlists.refresh().get() + self.assertEqual(send.call_args[0][0], 'playlists_loaded') diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index c249a10e6e..1cb0745103 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -1,15 +1,48 @@ from __future__ import unicode_literals +import tempfile import unittest +import pykka + +from mopidy import core from mopidy.backends.local import actor +from mopidy.models import Track, Album, Artist from tests import path_to_data_dir -from tests.backends.base.library import LibraryControllerTest -class LocalLibraryControllerTest(LibraryControllerTest, unittest.TestCase): - backend_class = actor.LocalBackend +# TODO: update tests to only use backend, not core. we need a seperate +# core test that does this integration test. +class LocalLibraryProviderTest(unittest.TestCase): + artists = [ + Artist(name='artist1'), + Artist(name='artist2'), + Artist(name='artist3'), + Artist(name='artist4'), + ] + + albums = [ + Album(name='album1', artists=[artists[0]]), + Album(name='album2', artists=[artists[1]]), + Album(name='album3', artists=[artists[2]]), + ] + + tracks = [ + Track( + uri='local:track:path1', name='track1', + artists=[artists[0]], album=albums[0], + date='2001-02-03', length=4000, track_no=1), + Track( + uri='local:track:path2', name='track2', + artists=[artists[1]], album=albums[1], + date='2002', length=4000, track_no=2), + Track( + uri='local:track:path3', name='track3', + artists=[artists[3]], album=albums[2], + date='2003', length=4000, track_no=3), + ] + config = { 'local': { 'media_dir': path_to_data_dir(''), @@ -17,3 +50,319 @@ class LocalLibraryControllerTest(LibraryControllerTest, unittest.TestCase): 'tag_cache_file': path_to_data_dir('library_tag_cache'), } } + + def setUp(self): + self.backend = actor.LocalBackend.start( + config=self.config, audio=None).proxy() + self.core = core.Core(backends=[self.backend]) + self.library = self.core.library + + def tearDown(self): + pykka.ActorRegistry.stop_all() + + def test_refresh(self): + self.library.refresh() + + @unittest.SkipTest + def test_refresh_uri(self): + pass + + def test_refresh_missing_uri(self): + # Verifies that https://github.com/mopidy/mopidy/issues/500 + # has been fixed. + + tag_cache = tempfile.NamedTemporaryFile() + with open(self.config['local']['tag_cache_file']) as fh: + tag_cache.write(fh.read()) + tag_cache.flush() + + config = {'local': self.config['local'].copy()} + config['local']['tag_cache_file'] = tag_cache.name + backend = actor.LocalBackend(config=config, audio=None) + + # Sanity check that value is in tag cache + result = backend.library.lookup(self.tracks[0].uri) + self.assertEqual(result, self.tracks[0:1]) + + # Clear tag cache and refresh + tag_cache.seek(0) + tag_cache.truncate() + backend.library.refresh() + + # Now it should be gone. + result = backend.library.lookup(self.tracks[0].uri) + self.assertEqual(result, []) + + def test_lookup(self): + tracks = self.library.lookup(self.tracks[0].uri) + self.assertEqual(tracks, self.tracks[0:1]) + + def test_lookup_unknown_track(self): + tracks = self.library.lookup('fake uri') + self.assertEqual(tracks, []) + + def test_find_exact_no_hits(self): + result = self.library.find_exact(track=['unknown track']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.find_exact(artist=['unknown artist']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.find_exact(album=['unknown artist']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.find_exact(date=['1990']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.find_exact(track_no=[9]) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.find_exact(uri=['fake uri']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.find_exact(any=['unknown any']) + self.assertEqual(list(result[0].tracks), []) + + def test_find_exact_uri(self): + track_1_uri = 'local:track:path1' + result = self.library.find_exact(uri=track_1_uri) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + track_2_uri = 'local:track:path2' + result = self.library.find_exact(uri=track_2_uri) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_find_exact_track(self): + result = self.library.find_exact(track=['track1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.find_exact(track=['track2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_find_exact_artist(self): + result = self.library.find_exact(artist=['artist1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.find_exact(artist=['artist2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_find_exact_album(self): + result = self.library.find_exact(album=['album1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.find_exact(album=['album2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_find_exact_albumartist(self): + # Artist is both track artist and album artist + result = self.library.find_exact(albumartist=['artist1']) + self.assertEqual(list(result[0].tracks), [self.tracks[0]]) + + # Artist is both track and album artist + result = self.library.find_exact(albumartist=['artist2']) + self.assertEqual(list(result[0].tracks), [self.tracks[1]]) + + # Artist is just album artist + result = self.library.find_exact(albumartist=['artist3']) + self.assertEqual(list(result[0].tracks), [self.tracks[2]]) + + def test_find_exact_track_no(self): + result = self.library.find_exact(track_no=[1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.find_exact(track_no=[2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_find_exact_date(self): + result = self.library.find_exact(date=['2001']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.find_exact(date=['2001-02-03']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.find_exact(date=['2002']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_find_exact_any(self): + # Matches on track artist + result = self.library.find_exact(any=['artist1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.find_exact(any=['artist2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + # Matches on track + result = self.library.find_exact(any=['track1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.find_exact(any=['track2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + # Matches on track album + result = self.library.find_exact(any=['album1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + # Matches on track album artists + result = self.library.find_exact(any=['artist3']) + self.assertEqual(list(result[0].tracks), self.tracks[2:3]) + + # Matches on track year + result = self.library.find_exact(any=['2002']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + # Matches on URI + result = self.library.find_exact(any=['local:track:path1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + def test_find_exact_wrong_type(self): + test = lambda: self.library.find_exact(wrong=['test']) + self.assertRaises(LookupError, test) + + def test_find_exact_with_empty_query(self): + test = lambda: self.library.find_exact(artist=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.find_exact(track=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.find_exact(album=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.find_exact(track_no=[]) + self.assertRaises(LookupError, test) + + test = lambda: self.library.find_exact(date=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.find_exact(any=['']) + self.assertRaises(LookupError, test) + + def test_search_no_hits(self): + result = self.library.search(track=['unknown track']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.search(artist=['unknown artist']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.search(album=['unknown artist']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.search(track_no=[9]) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.search(date=['unknown date']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.search(uri=['unknown uri']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.search(any=['unknown anything']) + self.assertEqual(list(result[0].tracks), []) + + def test_search_uri(self): + result = self.library.search(uri=['TH1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.search(uri=['TH2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_search_track(self): + result = self.library.search(track=['Rack1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.search(track=['Rack2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_search_artist(self): + result = self.library.search(artist=['Tist1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.search(artist=['Tist2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_search_albumartist(self): + # Artist is both track artist and album artist + result = self.library.search(albumartist=['Tist1']) + self.assertEqual(list(result[0].tracks), [self.tracks[0]]) + + # Artist is both track artist and album artist + result = self.library.search(albumartist=['Tist2']) + self.assertEqual(list(result[0].tracks), [self.tracks[1]]) + + # Artist is just album artist + result = self.library.search(albumartist=['Tist3']) + self.assertEqual(list(result[0].tracks), [self.tracks[2]]) + + def test_search_album(self): + result = self.library.search(album=['Bum1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.search(album=['Bum2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_search_date(self): + result = self.library.search(date=['2001']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.search(date=['2001-02-03']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.search(date=['2001-02-04']) + self.assertEqual(list(result[0].tracks), []) + + result = self.library.search(date=['2002']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_search_track_no(self): + result = self.library.search(track_no=[1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.search(track_no=[2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + def test_search_any(self): + # Matches on track artist + result = self.library.search(any=['Tist1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + # Matches on track + result = self.library.search(any=['Rack1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + result = self.library.search(any=['Rack2']) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) + + # Matches on track album + result = self.library.search(any=['Bum1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + # Matches on track album artists + result = self.library.search(any=['Tist3']) + self.assertEqual(list(result[0].tracks), self.tracks[2:3]) + + # Matches on URI + result = self.library.search(any=['TH1']) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) + + def test_search_wrong_type(self): + test = lambda: self.library.search(wrong=['test']) + self.assertRaises(LookupError, test) + + def test_search_with_empty_query(self): + test = lambda: self.library.search(artist=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.search(track=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.search(album=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.search(date=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.search(uri=['']) + self.assertRaises(LookupError, test) + + test = lambda: self.library.search(any=['']) + self.assertRaises(LookupError, test) diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 530f09c8d8..8fbc441568 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -1,18 +1,24 @@ from __future__ import unicode_literals +import mock +import time import unittest +import pykka + +from mopidy import audio, core from mopidy.backends.local import actor from mopidy.core import PlaybackState from mopidy.models import Track from tests import path_to_data_dir -from tests.backends.base.playback import PlaybackControllerTest -from tests.backends.local import generate_song +from tests.backends.local import generate_song, populate_tracklist + + +# TODO Test 'playlist repeat', e.g. repeat=1,single=0 -class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): - backend_class = actor.LocalBackend +class LocalPlaybackProviderTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), @@ -20,13 +26,33 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): 'tag_cache_file': path_to_data_dir('empty_tag_cache'), } } + + # We need four tracks so that our shuffled track tests behave nicely with + # reversed as a fake shuffle. Ensuring that shuffled order is [4,3,2,1] and + # normal order [1,2,3,4] which means next_track != next_track_with_random tracks = [ - Track(uri=generate_song(i), length=4464) for i in range(1, 4)] + Track(uri=generate_song(i), length=4464) for i in (1, 2, 3, 4)] def add_track(self, uri): track = Track(uri=uri, length=4464) self.tracklist.add([track]) + def setUp(self): + self.audio = audio.DummyAudio.start().proxy() + self.backend = actor.LocalBackend.start( + config=self.config, audio=self.audio).proxy() + self.core = core.Core(backends=[self.backend]) + self.playback = self.core.playback + self.tracklist = self.core.tracklist + + assert len(self.tracks) >= 3, \ + 'Need at least three tracks to run tests.' + assert self.tracks[0].length >= 2000, \ + 'First song needs to be at least 2000 miliseconds' + + def tearDown(self): + pykka.ActorRegistry.stop_all() + def test_uri_scheme(self): self.assertNotIn('file', self.core.uri_schemes) self.assertIn('local', self.core.uri_schemes) @@ -45,3 +71,1013 @@ def test_play_flac(self): self.add_track('local:track:blank.flac') self.playback.play() self.assertEqual(self.playback.state, PlaybackState.PLAYING) + + def test_play_uri_with_non_ascii_bytes(self): + # Regression test: If trying to do .split(u':') on a bytestring, the + # string will be decoded from ASCII to Unicode, which will crash on + # non-ASCII strings, like the bytestring the following URI decodes to. + self.add_track('local:track:12%20Doin%E2%80%99%20It%20Right.flac') + self.playback.play() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + + def test_initial_state_is_stopped(self): + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + def test_play_with_empty_playlist(self): + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.playback.play() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + def test_play_with_empty_playlist_return_value(self): + self.assertEqual(self.playback.play(), None) + + @populate_tracklist + def test_play_state(self): + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.playback.play() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + + @populate_tracklist + def test_play_return_value(self): + self.assertEqual(self.playback.play(), None) + + @populate_tracklist + def test_play_track_state(self): + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.playback.play(self.tracklist.tl_tracks[-1]) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + + @populate_tracklist + def test_play_track_return_value(self): + self.assertEqual(self.playback.play( + self.tracklist.tl_tracks[-1]), None) + + @populate_tracklist + def test_play_when_playing(self): + self.playback.play() + track = self.playback.current_track + self.playback.play() + self.assertEqual(track, self.playback.current_track) + + @populate_tracklist + def test_play_when_paused(self): + self.playback.play() + track = self.playback.current_track + self.playback.pause() + self.playback.play() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.assertEqual(track, self.playback.current_track) + + @populate_tracklist + def test_play_when_pause_after_next(self): + self.playback.play() + self.playback.next() + self.playback.next() + track = self.playback.current_track + self.playback.pause() + self.playback.play() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.assertEqual(track, self.playback.current_track) + + @populate_tracklist + def test_play_sets_current_track(self): + self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[0]) + + @populate_tracklist + def test_play_track_sets_current_track(self): + self.playback.play(self.tracklist.tl_tracks[-1]) + self.assertEqual(self.playback.current_track, self.tracks[-1]) + + @populate_tracklist + def test_play_skips_to_next_track_on_failure(self): + # If backend's play() returns False, it is a failure. + self.backend.playback.play = lambda track: track != self.tracks[0] + self.playback.play() + self.assertNotEqual(self.playback.current_track, self.tracks[0]) + self.assertEqual(self.playback.current_track, self.tracks[1]) + + @populate_tracklist + def test_current_track_after_completed_playlist(self): + self.playback.play(self.tracklist.tl_tracks[-1]) + self.playback.on_end_of_track() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assertEqual(self.playback.current_track, None) + + self.playback.play(self.tracklist.tl_tracks[-1]) + self.playback.next() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assertEqual(self.playback.current_track, None) + + @populate_tracklist + def test_previous(self): + self.playback.play() + self.playback.next() + self.playback.previous() + self.assertEqual(self.playback.current_track, self.tracks[0]) + + @populate_tracklist + def test_previous_more(self): + self.playback.play() # At track 0 + self.playback.next() # At track 1 + self.playback.next() # At track 2 + self.playback.previous() # At track 1 + self.assertEqual(self.playback.current_track, self.tracks[1]) + + @populate_tracklist + def test_previous_return_value(self): + self.playback.play() + self.playback.next() + self.assertEqual(self.playback.previous(), None) + + @populate_tracklist + def test_previous_does_not_trigger_playback(self): + self.playback.play() + self.playback.next() + self.playback.stop() + self.playback.previous() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_previous_at_start_of_playlist(self): + self.playback.previous() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assertEqual(self.playback.current_track, None) + + def test_previous_for_empty_playlist(self): + self.playback.previous() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assertEqual(self.playback.current_track, None) + + @populate_tracklist + def test_previous_skips_to_previous_track_on_failure(self): + # If backend's play() returns False, it is a failure. + self.backend.playback.play = lambda track: track != self.tracks[1] + self.playback.play(self.tracklist.tl_tracks[2]) + self.assertEqual(self.playback.current_track, self.tracks[2]) + self.playback.previous() + self.assertNotEqual(self.playback.current_track, self.tracks[1]) + self.assertEqual(self.playback.current_track, self.tracks[0]) + + @populate_tracklist + def test_next(self): + self.playback.play() + + tl_track = self.playback.current_tl_track + old_position = self.tracklist.index(tl_track) + old_uri = tl_track.track.uri + + self.playback.next() + + tl_track = self.playback.current_tl_track + self.assertEqual( + self.tracklist.index(tl_track), old_position + 1) + self.assertNotEqual(self.playback.current_track.uri, old_uri) + + @populate_tracklist + def test_next_return_value(self): + self.playback.play() + self.assertEqual(self.playback.next(), None) + + @populate_tracklist + def test_next_does_not_trigger_playback(self): + self.playback.next() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_next_at_end_of_playlist(self): + self.playback.play() + + for i, track in enumerate(self.tracks): + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.assertEqual(self.playback.current_track, track) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.index(tl_track), i) + + self.playback.next() + + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_next_until_end_of_playlist_and_play_from_start(self): + self.playback.play() + + for _ in self.tracks: + self.playback.next() + + self.assertEqual(self.playback.current_track, None) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + self.playback.play() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.assertEqual(self.playback.current_track, self.tracks[0]) + + def test_next_for_empty_playlist(self): + self.playback.next() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_next_skips_to_next_track_on_failure(self): + # If backend's play() returns False, it is a failure. + self.backend.playback.play = lambda track: track != self.tracks[1] + self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[0]) + self.playback.next() + self.assertNotEqual(self.playback.current_track, self.tracks[1]) + self.assertEqual(self.playback.current_track, self.tracks[2]) + + @populate_tracklist + def test_next_track_before_play(self): + tl_track = self.playback.current_tl_track + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[0]) + + @populate_tracklist + def test_next_track_during_play(self): + self.playback.play() + tl_track = self.playback.current_tl_track + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[1]) + + @populate_tracklist + def test_next_track_after_previous(self): + self.playback.play() + self.playback.next() + self.playback.previous() + tl_track = self.playback.current_tl_track + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[1]) + + def test_next_track_empty_playlist(self): + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), None) + + @populate_tracklist + def test_next_track_at_end_of_playlist(self): + self.playback.play() + for _ in self.tracklist.tl_tracks[1:]: + self.playback.next() + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), None) + + @populate_tracklist + def test_next_track_at_end_of_playlist_with_repeat(self): + self.tracklist.repeat = True + self.playback.play() + for _ in self.tracks[1:]: + self.playback.next() + tl_track = self.playback.current_tl_track + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[0]) + + @populate_tracklist + @mock.patch('random.shuffle') + def test_next_track_with_random(self, shuffle_mock): + shuffle_mock.side_effect = lambda tracks: tracks.reverse() + + self.tracklist.random = True + current_tl_track = self.playback.current_tl_track + next_tl_track = self.tracklist.next_track(current_tl_track) + self.assertEqual(next_tl_track, self.tl_tracks[-1]) + + @populate_tracklist + def test_next_with_consume(self): + self.tracklist.consume = True + self.playback.play() + self.playback.next() + self.assertIn(self.tracks[0], self.tracklist.tracks) + + @populate_tracklist + def test_next_with_single_and_repeat(self): + self.tracklist.single = True + self.tracklist.repeat = True + self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[0]) + self.playback.next() + self.assertEqual(self.playback.current_track, self.tracks[1]) + + @populate_tracklist + @mock.patch('random.shuffle') + def test_next_with_random(self, shuffle_mock): + shuffle_mock.side_effect = lambda tracks: tracks.reverse() + + self.tracklist.random = True + self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[-1]) + self.playback.next() + self.assertEqual(self.playback.current_track, self.tracks[-2]) + + @populate_tracklist + @mock.patch('random.shuffle') + def test_next_track_with_random_after_append_playlist(self, shuffle_mock): + shuffle_mock.side_effect = lambda tracks: tracks.reverse() + + self.tracklist.random = True + current_tl_track = self.playback.current_tl_track + + expected_tl_track = self.tracklist.tl_tracks[-1] + next_tl_track = self.tracklist.next_track(current_tl_track) + + # Baseline checking that first next_track is last tl track per our fake + # shuffle. + self.assertEqual(next_tl_track, expected_tl_track) + + self.tracklist.add(self.tracks[:1]) + + old_next_tl_track = next_tl_track + expected_tl_track = self.tracklist.tl_tracks[-1] + next_tl_track = self.tracklist.next_track(current_tl_track) + + # Verify that first next track has changed since we added to the + # playlist. + self.assertEqual(next_tl_track, expected_tl_track) + self.assertNotEqual(next_tl_track, old_next_tl_track) + + @populate_tracklist + def test_end_of_track(self): + self.playback.play() + + tl_track = self.playback.current_tl_track + old_position = self.tracklist.index(tl_track) + old_uri = tl_track.track.uri + + self.playback.on_end_of_track() + + tl_track = self.playback.current_tl_track + self.assertEqual( + self.tracklist.index(tl_track), old_position + 1) + self.assertNotEqual(self.playback.current_track.uri, old_uri) + + @populate_tracklist + def test_end_of_track_return_value(self): + self.playback.play() + self.assertEqual(self.playback.on_end_of_track(), None) + + @populate_tracklist + def test_end_of_track_does_not_trigger_playback(self): + self.playback.on_end_of_track() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_end_of_track_at_end_of_playlist(self): + self.playback.play() + + for i, track in enumerate(self.tracks): + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.assertEqual(self.playback.current_track, track) + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.index(tl_track), i) + + self.playback.on_end_of_track() + + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_end_of_track_until_end_of_playlist_and_play_from_start(self): + self.playback.play() + + for _ in self.tracks: + self.playback.on_end_of_track() + + self.assertEqual(self.playback.current_track, None) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + self.playback.play() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.assertEqual(self.playback.current_track, self.tracks[0]) + + def test_end_of_track_for_empty_playlist(self): + self.playback.on_end_of_track() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_end_of_track_skips_to_next_track_on_failure(self): + # If backend's play() returns False, it is a failure. + self.backend.playback.play = lambda track: track != self.tracks[1] + self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[0]) + self.playback.on_end_of_track() + self.assertNotEqual(self.playback.current_track, self.tracks[1]) + self.assertEqual(self.playback.current_track, self.tracks[2]) + + @populate_tracklist + def test_end_of_track_track_before_play(self): + tl_track = self.playback.current_tl_track + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[0]) + + @populate_tracklist + def test_end_of_track_track_during_play(self): + self.playback.play() + tl_track = self.playback.current_tl_track + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[1]) + + @populate_tracklist + def test_end_of_track_track_after_previous(self): + self.playback.play() + self.playback.on_end_of_track() + self.playback.previous() + tl_track = self.playback.current_tl_track + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[1]) + + def test_end_of_track_track_empty_playlist(self): + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), None) + + @populate_tracklist + def test_end_of_track_track_at_end_of_playlist(self): + self.playback.play() + for _ in self.tracklist.tl_tracks[1:]: + self.playback.on_end_of_track() + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), None) + + @populate_tracklist + def test_end_of_track_track_at_end_of_playlist_with_repeat(self): + self.tracklist.repeat = True + self.playback.play() + for _ in self.tracks[1:]: + self.playback.on_end_of_track() + tl_track = self.playback.current_tl_track + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[0]) + + @populate_tracklist + @mock.patch('random.shuffle') + def test_end_of_track_track_with_random(self, shuffle_mock): + shuffle_mock.side_effect = lambda tracks: tracks.reverse() + + self.tracklist.random = True + tl_track = self.playback.current_tl_track + self.assertEqual( + self.tracklist.next_track(tl_track), self.tl_tracks[-1]) + + @populate_tracklist + def test_end_of_track_with_consume(self): + self.tracklist.consume = True + self.playback.play() + self.playback.on_end_of_track() + self.assertNotIn(self.tracks[0], self.tracklist.tracks) + + @populate_tracklist + @mock.patch('random.shuffle') + def test_end_of_track_with_random(self, shuffle_mock): + shuffle_mock.side_effect = lambda tracks: tracks.reverse() + + self.tracklist.random = True + self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[-1]) + self.playback.on_end_of_track() + self.assertEqual(self.playback.current_track, self.tracks[-2]) + + @populate_tracklist + @mock.patch('random.shuffle') + def test_end_of_track_track_with_random_after_append_playlist( + self, shuffle_mock): + shuffle_mock.side_effect = lambda tracks: tracks.reverse() + + self.tracklist.random = True + current_tl_track = self.playback.current_tl_track + + expected_tl_track = self.tracklist.tl_tracks[-1] + eot_tl_track = self.tracklist.eot_track(current_tl_track) + + # Baseline checking that first eot_track is last tl track per our fake + # shuffle. + self.assertEqual(eot_tl_track, expected_tl_track) + + self.tracklist.add(self.tracks[:1]) + + old_eot_tl_track = eot_tl_track + expected_tl_track = self.tracklist.tl_tracks[-1] + eot_tl_track = self.tracklist.eot_track(current_tl_track) + + # Verify that first next track has changed since we added to the + # playlist. + self.assertEqual(eot_tl_track, expected_tl_track) + self.assertNotEqual(eot_tl_track, old_eot_tl_track) + + @populate_tracklist + def test_previous_track_before_play(self): + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.previous_track(tl_track), None) + + @populate_tracklist + def test_previous_track_after_play(self): + self.playback.play() + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.previous_track(tl_track), None) + + @populate_tracklist + def test_previous_track_after_next(self): + self.playback.play() + self.playback.next() + tl_track = self.playback.current_tl_track + self.assertEqual( + self.tracklist.previous_track(tl_track), self.tl_tracks[0]) + + @populate_tracklist + def test_previous_track_after_previous(self): + self.playback.play() # At track 0 + self.playback.next() # At track 1 + self.playback.next() # At track 2 + self.playback.previous() # At track 1 + tl_track = self.playback.current_tl_track + self.assertEqual( + self.tracklist.previous_track(tl_track), self.tl_tracks[0]) + + def test_previous_track_empty_playlist(self): + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.previous_track(tl_track), None) + + @populate_tracklist + def test_previous_track_with_consume(self): + self.tracklist.consume = True + for _ in self.tracks: + self.playback.next() + tl_track = self.playback.current_tl_track + self.assertEqual( + self.tracklist.previous_track(tl_track), + self.playback.current_tl_track) + + @populate_tracklist + def test_previous_track_with_random(self): + self.tracklist.random = True + for _ in self.tracks: + self.playback.next() + tl_track = self.playback.current_tl_track + self.assertEqual( + self.tracklist.previous_track(tl_track), + self.playback.current_tl_track) + + @populate_tracklist + def test_initial_current_track(self): + self.assertEqual(self.playback.current_track, None) + + @populate_tracklist + def test_current_track_during_play(self): + self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[0]) + + @populate_tracklist + def test_current_track_after_next(self): + self.playback.play() + self.playback.next() + self.assertEqual(self.playback.current_track, self.tracks[1]) + + @populate_tracklist + def test_initial_tracklist_position(self): + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.index(tl_track), None) + + @populate_tracklist + def test_tracklist_position_during_play(self): + self.playback.play() + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.index(tl_track), 0) + + @populate_tracklist + def test_tracklist_position_after_next(self): + self.playback.play() + self.playback.next() + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.index(tl_track), 1) + + @populate_tracklist + def test_tracklist_position_at_end_of_playlist(self): + self.playback.play(self.tracklist.tl_tracks[-1]) + self.playback.on_end_of_track() + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.index(tl_track), None) + + def test_on_tracklist_change_gets_called(self): + callback = self.playback.on_tracklist_change + + def wrapper(): + wrapper.called = True + return callback() + wrapper.called = False + + self.playback.on_tracklist_change = wrapper + self.tracklist.add([Track()]) + + self.assert_(wrapper.called) + + @unittest.SkipTest # Blocks for 10ms + @populate_tracklist + def test_end_of_track_callback_gets_called(self): + self.playback.play() + result = self.playback.seek(self.tracks[0].length - 10) + self.assertTrue(result, 'Seek failed') + message = self.core_queue.get(True, 1) + self.assertEqual('end_of_track', message['command']) + + @populate_tracklist + def test_on_tracklist_change_when_playing(self): + self.playback.play() + current_track = self.playback.current_track + self.tracklist.add([self.tracks[2]]) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.assertEqual(self.playback.current_track, current_track) + + @populate_tracklist + def test_on_tracklist_change_when_stopped(self): + self.tracklist.add([self.tracks[2]]) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assertEqual(self.playback.current_track, None) + + @populate_tracklist + def test_on_tracklist_change_when_paused(self): + self.playback.play() + self.playback.pause() + current_track = self.playback.current_track + self.tracklist.add([self.tracks[2]]) + self.assertEqual(self.playback.state, PlaybackState.PAUSED) + self.assertEqual(self.playback.current_track, current_track) + + @populate_tracklist + def test_pause_when_stopped(self): + self.playback.pause() + self.assertEqual(self.playback.state, PlaybackState.PAUSED) + + @populate_tracklist + def test_pause_when_playing(self): + self.playback.play() + self.playback.pause() + self.assertEqual(self.playback.state, PlaybackState.PAUSED) + + @populate_tracklist + def test_pause_when_paused(self): + self.playback.play() + self.playback.pause() + self.playback.pause() + self.assertEqual(self.playback.state, PlaybackState.PAUSED) + + @populate_tracklist + def test_pause_return_value(self): + self.playback.play() + self.assertEqual(self.playback.pause(), None) + + @populate_tracklist + def test_resume_when_stopped(self): + self.playback.resume() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_resume_when_playing(self): + self.playback.play() + self.playback.resume() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + + @populate_tracklist + def test_resume_when_paused(self): + self.playback.play() + self.playback.pause() + self.playback.resume() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + + @populate_tracklist + def test_resume_return_value(self): + self.playback.play() + self.playback.pause() + self.assertEqual(self.playback.resume(), None) + + @unittest.SkipTest # Uses sleep and might not work with LocalBackend + @populate_tracklist + def test_resume_continues_from_right_position(self): + self.playback.play() + time.sleep(0.2) + self.playback.pause() + self.playback.resume() + self.assertNotEqual(self.playback.time_position, 0) + + @populate_tracklist + def test_seek_when_stopped(self): + result = self.playback.seek(1000) + self.assert_(result, 'Seek return value was %s' % result) + + @populate_tracklist + def test_seek_when_stopped_updates_position(self): + self.playback.seek(1000) + position = self.playback.time_position + self.assertGreaterEqual(position, 990) + + def test_seek_on_empty_playlist(self): + self.assertFalse(self.playback.seek(0)) + + def test_seek_on_empty_playlist_updates_position(self): + self.playback.seek(0) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_seek_when_stopped_triggers_play(self): + self.playback.seek(0) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + + @populate_tracklist + def test_seek_when_playing(self): + self.playback.play() + result = self.playback.seek(self.tracks[0].length - 1000) + self.assert_(result, 'Seek return value was %s' % result) + + @populate_tracklist + def test_seek_when_playing_updates_position(self): + length = self.tracklist.tracks[0].length + self.playback.play() + self.playback.seek(length - 1000) + position = self.playback.time_position + self.assertGreaterEqual(position, length - 1010) + + @populate_tracklist + def test_seek_when_paused(self): + self.playback.play() + self.playback.pause() + result = self.playback.seek(self.tracks[0].length - 1000) + self.assert_(result, 'Seek return value was %s' % result) + + @populate_tracklist + def test_seek_when_paused_updates_position(self): + length = self.tracklist.tracks[0].length + self.playback.play() + self.playback.pause() + self.playback.seek(length - 1000) + position = self.playback.time_position + self.assertGreaterEqual(position, length - 1010) + + @populate_tracklist + def test_seek_when_paused_triggers_play(self): + self.playback.play() + self.playback.pause() + self.playback.seek(0) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + + @unittest.SkipTest + @populate_tracklist + def test_seek_beyond_end_of_song(self): + # FIXME need to decide return value + self.playback.play() + result = self.playback.seek(self.tracks[0].length * 100) + self.assert_(not result, 'Seek return value was %s' % result) + + @populate_tracklist + def test_seek_beyond_end_of_song_jumps_to_next_song(self): + self.playback.play() + self.playback.seek(self.tracks[0].length * 100) + self.assertEqual(self.playback.current_track, self.tracks[1]) + + @populate_tracklist + def test_seek_beyond_end_of_song_for_last_track(self): + self.playback.play(self.tracklist.tl_tracks[-1]) + self.playback.seek(self.tracklist.tracks[-1].length * 100) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @unittest.SkipTest + @populate_tracklist + def test_seek_beyond_start_of_song(self): + # FIXME need to decide return value + self.playback.play() + result = self.playback.seek(-1000) + self.assert_(not result, 'Seek return value was %s' % result) + + @populate_tracklist + def test_seek_beyond_start_of_song_update_postion(self): + self.playback.play() + self.playback.seek(-1000) + position = self.playback.time_position + self.assertGreaterEqual(position, 0) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + + @populate_tracklist + def test_stop_when_stopped(self): + self.playback.stop() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_stop_when_playing(self): + self.playback.play() + self.playback.stop() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_stop_when_paused(self): + self.playback.play() + self.playback.pause() + self.playback.stop() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + def test_stop_return_value(self): + self.playback.play() + self.assertEqual(self.playback.stop(), None) + + def test_time_position_when_stopped(self): + future = mock.Mock() + future.get = mock.Mock(return_value=0) + self.audio.get_position = mock.Mock(return_value=future) + + self.assertEqual(self.playback.time_position, 0) + + @populate_tracklist + def test_time_position_when_stopped_with_playlist(self): + future = mock.Mock() + future.get = mock.Mock(return_value=0) + self.audio.get_position = mock.Mock(return_value=future) + + self.assertEqual(self.playback.time_position, 0) + + @unittest.SkipTest # Uses sleep and does might not work with LocalBackend + @populate_tracklist + def test_time_position_when_playing(self): + self.playback.play() + first = self.playback.time_position + time.sleep(1) + second = self.playback.time_position + self.assertGreater(second, first) + + @unittest.SkipTest # Uses sleep + @populate_tracklist + def test_time_position_when_paused(self): + self.playback.play() + time.sleep(0.2) + self.playback.pause() + time.sleep(0.2) + first = self.playback.time_position + second = self.playback.time_position + self.assertEqual(first, second) + + @populate_tracklist + def test_play_with_consume(self): + self.tracklist.consume = True + self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[0]) + + @populate_tracklist + def test_playlist_is_empty_after_all_tracks_are_played_with_consume(self): + self.tracklist.consume = True + self.playback.play() + for _ in range(len(self.tracklist.tracks)): + self.playback.on_end_of_track() + self.assertEqual(len(self.tracklist.tracks), 0) + + @populate_tracklist + @mock.patch('random.shuffle') + def test_play_with_random(self, shuffle_mock): + shuffle_mock.side_effect = lambda tracks: tracks.reverse() + + self.tracklist.random = True + self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[-1]) + + @populate_tracklist + @mock.patch('random.shuffle') + def test_previous_with_random(self, shuffle_mock): + shuffle_mock.side_effect = lambda tracks: tracks.reverse() + + self.tracklist.random = True + self.playback.play() + self.playback.next() + current_track = self.playback.current_track + self.playback.previous() + self.assertEqual(self.playback.current_track, current_track) + + @populate_tracklist + def test_end_of_song_starts_next_track(self): + self.playback.play() + self.playback.on_end_of_track() + self.assertEqual(self.playback.current_track, self.tracks[1]) + + @populate_tracklist + def test_end_of_song_with_single_and_repeat_starts_same(self): + self.tracklist.single = True + self.tracklist.repeat = True + self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[0]) + self.playback.on_end_of_track() + self.assertEqual(self.playback.current_track, self.tracks[0]) + + @populate_tracklist + def test_end_of_song_with_single_random_and_repeat_starts_same(self): + self.tracklist.single = True + self.tracklist.repeat = True + self.tracklist.random = True + self.playback.play() + current_track = self.playback.current_track + self.playback.on_end_of_track() + self.assertEqual(self.playback.current_track, current_track) + + @populate_tracklist + def test_end_of_song_with_single_stops(self): + self.tracklist.single = True + self.playback.play() + self.assertEqual(self.playback.current_track, self.tracks[0]) + self.playback.on_end_of_track() + self.assertEqual(self.playback.current_track, None) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_end_of_song_with_single_and_random_stops(self): + self.tracklist.single = True + self.tracklist.random = True + self.playback.play() + self.playback.on_end_of_track() + self.assertEqual(self.playback.current_track, None) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + @populate_tracklist + def test_end_of_playlist_stops(self): + self.playback.play(self.tracklist.tl_tracks[-1]) + self.playback.on_end_of_track() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + def test_repeat_off_by_default(self): + self.assertEqual(self.tracklist.repeat, False) + + def test_random_off_by_default(self): + self.assertEqual(self.tracklist.random, False) + + def test_consume_off_by_default(self): + self.assertEqual(self.tracklist.consume, False) + + @populate_tracklist + def test_random_until_end_of_playlist(self): + self.tracklist.random = True + self.playback.play() + for _ in self.tracks[1:]: + self.playback.next() + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.next_track(tl_track), None) + + @populate_tracklist + def test_random_with_eot_until_end_of_playlist(self): + self.tracklist.random = True + self.playback.play() + for _ in self.tracks[1:]: + self.playback.on_end_of_track() + tl_track = self.playback.current_tl_track + self.assertEqual(self.tracklist.eot_track(tl_track), None) + + @populate_tracklist + def test_random_until_end_of_playlist_and_play_from_start(self): + self.tracklist.random = True + self.playback.play() + for _ in self.tracks: + self.playback.next() + tl_track = self.playback.current_tl_track + self.assertNotEqual(self.tracklist.next_track(tl_track), None) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.playback.play() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + + @populate_tracklist + def test_random_with_eot_until_end_of_playlist_and_play_from_start(self): + self.tracklist.random = True + self.playback.play() + for _ in self.tracks: + self.playback.on_end_of_track() + tl_track = self.playback.current_tl_track + self.assertNotEqual(self.tracklist.eot_track(tl_track), None) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.playback.play() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + + @populate_tracklist + def test_random_until_end_of_playlist_with_repeat(self): + self.tracklist.repeat = True + self.tracklist.random = True + self.playback.play() + for _ in self.tracks[1:]: + self.playback.next() + tl_track = self.playback.current_tl_track + self.assertNotEqual(self.tracklist.next_track(tl_track), None) + + @populate_tracklist + def test_played_track_during_random_not_played_again(self): + self.tracklist.random = True + self.playback.play() + played = [] + for _ in self.tracks: + self.assertNotIn(self.playback.current_track, played) + played.append(self.playback.current_track) + self.playback.next() + + @populate_tracklist + @mock.patch('random.shuffle') + def test_play_track_then_enable_random(self, shuffle_mock): + # Covers underlying issue IssueGH17RegressionTest tests for. + shuffle_mock.side_effect = lambda tracks: tracks.reverse() + + expected = self.tl_tracks[::-1] + [None] + actual = [] + + self.playback.play() + self.tracklist.random = True + while self.playback.state != PlaybackState.STOPPED: + self.playback.next() + actual.append(self.playback.current_tl_track) + self.assertEqual(actual, expected) + + @populate_tracklist + def test_playing_track_that_isnt_in_playlist(self): + test = lambda: self.playback.play((17, Track())) + self.assertRaises(AssertionError, test) diff --git a/tests/backends/local/playlists_test.py b/tests/backends/local/playlists_test.py index d405e8877b..c8fedd6213 100644 --- a/tests/backends/local/playlists_test.py +++ b/tests/backends/local/playlists_test.py @@ -5,18 +5,17 @@ import tempfile import unittest +import pykka + +from mopidy import audio, core from mopidy.backends.local import actor -from mopidy.models import Track +from mopidy.models import Playlist, Track from tests import path_to_data_dir -from tests.backends.base.playlists import ( - PlaylistsControllerTest) from tests.backends.local import generate_song -class LocalPlaylistsControllerTest( - PlaylistsControllerTest, unittest.TestCase): - +class LocalPlaylistsProviderTest(unittest.TestCase): backend_class = actor.LocalBackend config = { 'local': { @@ -29,10 +28,13 @@ def setUp(self): self.config['local']['playlists_dir'] = tempfile.mkdtemp() self.playlists_dir = self.config['local']['playlists_dir'] - super(LocalPlaylistsControllerTest, self).setUp() + self.audio = audio.DummyAudio.start().proxy() + self.backend = actor.LocalBackend.start( + config=self.config, audio=self.audio).proxy() + self.core = core.Core(backends=[self.backend]) def tearDown(self): - super(LocalPlaylistsControllerTest, self).tearDown() + pykka.ActorRegistry.stop_all() if os.path.exists(self.playlists_dir): shutil.rmtree(self.playlists_dir) @@ -121,3 +123,96 @@ def test_santitising_of_playlist_filenames(self): @unittest.SkipTest def test_playlist_dir_is_created(self): pass + + def test_create_returns_playlist_with_name_set(self): + playlist = self.core.playlists.create('test') + self.assertEqual(playlist.name, 'test') + + def test_create_returns_playlist_with_uri_set(self): + playlist = self.core.playlists.create('test') + self.assert_(playlist.uri) + + def test_create_adds_playlist_to_playlists_collection(self): + playlist = self.core.playlists.create('test') + self.assert_(self.core.playlists.playlists) + self.assertIn(playlist, self.core.playlists.playlists) + + def test_playlists_empty_to_start_with(self): + self.assert_(not self.core.playlists.playlists) + + def test_delete_non_existant_playlist(self): + self.core.playlists.delete('file:///unknown/playlist') + + def test_delete_playlist_removes_it_from_the_collection(self): + playlist = self.core.playlists.create('test') + self.assertIn(playlist, self.core.playlists.playlists) + + self.core.playlists.delete(playlist.uri) + + self.assertNotIn(playlist, self.core.playlists.playlists) + + def test_filter_without_criteria(self): + self.assertEqual( + self.core.playlists.playlists, self.core.playlists.filter()) + + def test_filter_with_wrong_criteria(self): + self.assertEqual([], self.core.playlists.filter(name='foo')) + + def test_filter_with_right_criteria(self): + playlist = self.core.playlists.create('test') + playlists = self.core.playlists.filter(name='test') + self.assertEqual([playlist], playlists) + + def test_filter_by_name_returns_single_match(self): + playlist = Playlist(name='b') + self.backend.playlists.playlists = [Playlist(name='a'), playlist] + self.assertEqual([playlist], self.core.playlists.filter(name='b')) + + def test_filter_by_name_returns_multiple_matches(self): + playlist = Playlist(name='b') + self.backend.playlists.playlists = [ + playlist, Playlist(name='a'), Playlist(name='b')] + playlists = self.core.playlists.filter(name='b') + self.assertIn(playlist, playlists) + self.assertEqual(2, len(playlists)) + + def test_filter_by_name_returns_no_matches(self): + self.backend.playlists.playlists = [ + Playlist(name='a'), Playlist(name='b')] + self.assertEqual([], self.core.playlists.filter(name='c')) + + def test_lookup_finds_playlist_by_uri(self): + original_playlist = self.core.playlists.create('test') + + looked_up_playlist = self.core.playlists.lookup(original_playlist.uri) + + self.assertEqual(original_playlist, looked_up_playlist) + + @unittest.SkipTest + def test_refresh(self): + pass + + def test_save_replaces_existing_playlist_with_updated_playlist(self): + playlist1 = self.core.playlists.create('test1') + self.assertIn(playlist1, self.core.playlists.playlists) + + playlist2 = playlist1.copy(name='test2') + playlist2 = self.core.playlists.save(playlist2) + self.assertNotIn(playlist1, self.core.playlists.playlists) + self.assertIn(playlist2, self.core.playlists.playlists) + + def test_playlist_with_unknown_track(self): + track = Track(uri='file:///dev/null') + playlist = self.core.playlists.create('test') + playlist = playlist.copy(tracks=[track]) + playlist = self.core.playlists.save(playlist) + + backend = self.backend_class(config=self.config, audio=self.audio) + + self.assert_(backend.playlists.playlists) + self.assertEqual( + 'local:playlist:test', backend.playlists.playlists[0].uri) + self.assertEqual( + playlist.name, backend.playlists.playlists[0].name) + self.assertEqual( + track.uri, backend.playlists.playlists[0].tracks[0].uri) diff --git a/tests/backends/local/tracklist_test.py b/tests/backends/local/tracklist_test.py index c7650ac065..8310ce1a80 100644 --- a/tests/backends/local/tracklist_test.py +++ b/tests/backends/local/tracklist_test.py @@ -1,17 +1,20 @@ from __future__ import unicode_literals +import random import unittest +import pykka + +from mopidy import audio, core from mopidy.backends.local import actor -from mopidy.models import Track +from mopidy.core import PlaybackState +from mopidy.models import Playlist, TlTrack, Track from tests import path_to_data_dir -from tests.backends.base.tracklist import TracklistControllerTest -from tests.backends.local import generate_song +from tests.backends.local import generate_song, populate_tracklist -class LocalTracklistControllerTest(TracklistControllerTest, unittest.TestCase): - backend_class = actor.LocalBackend +class LocalTracklistProviderTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), @@ -21,3 +24,282 @@ class LocalTracklistControllerTest(TracklistControllerTest, unittest.TestCase): } tracks = [ Track(uri=generate_song(i), length=4464) for i in range(1, 4)] + + def setUp(self): + self.audio = audio.DummyAudio.start().proxy() + self.backend = actor.LocalBackend.start( + config=self.config, audio=self.audio).proxy() + self.core = core.Core(audio=self.audio, backends=[self.backend]) + self.controller = self.core.tracklist + self.playback = self.core.playback + + assert len(self.tracks) == 3, 'Need three tracks to run tests.' + + def tearDown(self): + pykka.ActorRegistry.stop_all() + + def test_length(self): + self.assertEqual(0, len(self.controller.tl_tracks)) + self.assertEqual(0, self.controller.length) + self.controller.add(self.tracks) + self.assertEqual(3, len(self.controller.tl_tracks)) + self.assertEqual(3, self.controller.length) + + def test_add(self): + for track in self.tracks: + tl_tracks = self.controller.add([track]) + self.assertEqual(track, self.controller.tracks[-1]) + self.assertEqual(tl_tracks[0], self.controller.tl_tracks[-1]) + self.assertEqual(track, tl_tracks[0].track) + + def test_add_at_position(self): + for track in self.tracks[:-1]: + tl_tracks = self.controller.add([track], 0) + self.assertEqual(track, self.controller.tracks[0]) + self.assertEqual(tl_tracks[0], self.controller.tl_tracks[0]) + self.assertEqual(track, tl_tracks[0].track) + + @populate_tracklist + def test_add_at_position_outside_of_playlist(self): + for track in self.tracks: + tl_tracks = self.controller.add([track], len(self.tracks) + 2) + self.assertEqual(track, self.controller.tracks[-1]) + self.assertEqual(tl_tracks[0], self.controller.tl_tracks[-1]) + self.assertEqual(track, tl_tracks[0].track) + + @populate_tracklist + def test_filter_by_tlid(self): + tl_track = self.controller.tl_tracks[1] + self.assertEqual( + [tl_track], self.controller.filter(tlid=tl_track.tlid)) + + @populate_tracklist + def test_filter_by_uri(self): + tl_track = self.controller.tl_tracks[1] + self.assertEqual( + [tl_track], self.controller.filter(uri=tl_track.track.uri)) + + @populate_tracklist + def test_filter_by_uri_returns_nothing_for_invalid_uri(self): + self.assertEqual([], self.controller.filter(uri='foobar')) + + def test_filter_by_uri_returns_single_match(self): + track = Track(uri='a') + self.controller.add([Track(uri='z'), track, Track(uri='y')]) + self.assertEqual(track, self.controller.filter(uri='a')[0].track) + + def test_filter_by_uri_returns_multiple_matches(self): + track = Track(uri='a') + self.controller.add([Track(uri='z'), track, track]) + tl_tracks = self.controller.filter(uri='a') + self.assertEqual(track, tl_tracks[0].track) + self.assertEqual(track, tl_tracks[1].track) + + def test_filter_by_uri_returns_nothing_if_no_match(self): + self.controller.playlist = Playlist( + tracks=[Track(uri='z'), Track(uri='y')]) + self.assertEqual([], self.controller.filter(uri='a')) + + def test_filter_by_multiple_criteria_returns_elements_matching_all(self): + track1 = Track(uri='a', name='x') + track2 = Track(uri='b', name='x') + track3 = Track(uri='b', name='y') + self.controller.add([track1, track2, track3]) + self.assertEqual( + track1, self.controller.filter(uri='a', name='x')[0].track) + self.assertEqual( + track2, self.controller.filter(uri='b', name='x')[0].track) + self.assertEqual( + track3, self.controller.filter(uri='b', name='y')[0].track) + + def test_filter_by_criteria_that_is_not_present_in_all_elements(self): + track1 = Track() + track2 = Track(uri='b') + track3 = Track() + self.controller.add([track1, track2, track3]) + self.assertEqual(track2, self.controller.filter(uri='b')[0].track) + + @populate_tracklist + def test_clear(self): + self.controller.clear() + self.assertEqual(len(self.controller.tracks), 0) + + def test_clear_empty_playlist(self): + self.controller.clear() + self.assertEqual(len(self.controller.tracks), 0) + + @populate_tracklist + def test_clear_when_playing(self): + self.playback.play() + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.controller.clear() + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + + def test_add_appends_to_the_tracklist(self): + self.controller.add([Track(uri='a'), Track(uri='b')]) + self.assertEqual(len(self.controller.tracks), 2) + self.controller.add([Track(uri='c'), Track(uri='d')]) + self.assertEqual(len(self.controller.tracks), 4) + self.assertEqual(self.controller.tracks[0].uri, 'a') + self.assertEqual(self.controller.tracks[1].uri, 'b') + self.assertEqual(self.controller.tracks[2].uri, 'c') + self.assertEqual(self.controller.tracks[3].uri, 'd') + + def test_add_does_not_reset_version(self): + version = self.controller.version + self.controller.add([]) + self.assertEqual(self.controller.version, version) + + @populate_tracklist + def test_add_preserves_playing_state(self): + self.playback.play() + track = self.playback.current_track + self.controller.add(self.controller.tracks[1:2]) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) + self.assertEqual(self.playback.current_track, track) + + @populate_tracklist + def test_add_preserves_stopped_state(self): + self.controller.add(self.controller.tracks[1:2]) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) + self.assertEqual(self.playback.current_track, None) + + @populate_tracklist + def test_add_returns_the_tl_tracks_that_was_added(self): + tl_tracks = self.controller.add(self.controller.tracks[1:2]) + self.assertEqual(tl_tracks[0].track, self.controller.tracks[1]) + + def test_index_returns_index_of_track(self): + tl_tracks = self.controller.add(self.tracks) + self.assertEqual(0, self.controller.index(tl_tracks[0])) + self.assertEqual(1, self.controller.index(tl_tracks[1])) + self.assertEqual(2, self.controller.index(tl_tracks[2])) + + def test_index_returns_none_if_item_not_found(self): + tl_track = TlTrack(0, Track()) + self.assertEqual(self.controller.index(tl_track), None) + + @populate_tracklist + def test_move_single(self): + self.controller.move(0, 0, 2) + + tracks = self.controller.tracks + self.assertEqual(tracks[2], self.tracks[0]) + + @populate_tracklist + def test_move_group(self): + self.controller.move(0, 2, 1) + + tracks = self.controller.tracks + self.assertEqual(tracks[1], self.tracks[0]) + self.assertEqual(tracks[2], self.tracks[1]) + + @populate_tracklist + def test_moving_track_outside_of_playlist(self): + tracks = len(self.controller.tracks) + test = lambda: self.controller.move(0, 0, tracks + 5) + self.assertRaises(AssertionError, test) + + @populate_tracklist + def test_move_group_outside_of_playlist(self): + tracks = len(self.controller.tracks) + test = lambda: self.controller.move(0, 2, tracks + 5) + self.assertRaises(AssertionError, test) + + @populate_tracklist + def test_move_group_out_of_range(self): + tracks = len(self.controller.tracks) + test = lambda: self.controller.move(tracks + 2, tracks + 3, 0) + self.assertRaises(AssertionError, test) + + @populate_tracklist + def test_move_group_invalid_group(self): + test = lambda: self.controller.move(2, 1, 0) + self.assertRaises(AssertionError, test) + + def test_tracks_attribute_is_immutable(self): + tracks1 = self.controller.tracks + tracks2 = self.controller.tracks + self.assertNotEqual(id(tracks1), id(tracks2)) + + @populate_tracklist + def test_remove(self): + track1 = self.controller.tracks[1] + track2 = self.controller.tracks[2] + version = self.controller.version + self.controller.remove(uri=track1.uri) + self.assertLess(version, self.controller.version) + self.assertNotIn(track1, self.controller.tracks) + self.assertEqual(track2, self.controller.tracks[1]) + + @populate_tracklist + def test_removing_track_that_does_not_exist_does_nothing(self): + self.controller.remove(uri='/nonexistant') + + def test_removing_from_empty_playlist_does_nothing(self): + self.controller.remove(uri='/nonexistant') + + @populate_tracklist + def test_shuffle(self): + random.seed(1) + self.controller.shuffle() + + shuffled_tracks = self.controller.tracks + + self.assertNotEqual(self.tracks, shuffled_tracks) + self.assertEqual(set(self.tracks), set(shuffled_tracks)) + + @populate_tracklist + def test_shuffle_subset(self): + random.seed(1) + self.controller.shuffle(1, 3) + + shuffled_tracks = self.controller.tracks + + self.assertNotEqual(self.tracks, shuffled_tracks) + self.assertEqual(self.tracks[0], shuffled_tracks[0]) + self.assertEqual(set(self.tracks), set(shuffled_tracks)) + + @populate_tracklist + def test_shuffle_invalid_subset(self): + test = lambda: self.controller.shuffle(3, 1) + self.assertRaises(AssertionError, test) + + @populate_tracklist + def test_shuffle_superset(self): + tracks = len(self.controller.tracks) + test = lambda: self.controller.shuffle(1, tracks + 5) + self.assertRaises(AssertionError, test) + + @populate_tracklist + def test_shuffle_open_subset(self): + random.seed(1) + self.controller.shuffle(1) + + shuffled_tracks = self.controller.tracks + + self.assertNotEqual(self.tracks, shuffled_tracks) + self.assertEqual(self.tracks[0], shuffled_tracks[0]) + self.assertEqual(set(self.tracks), set(shuffled_tracks)) + + @populate_tracklist + def test_slice_returns_a_subset_of_tracks(self): + track_slice = self.controller.slice(1, 3) + self.assertEqual(2, len(track_slice)) + self.assertEqual(self.tracks[1], track_slice[0].track) + self.assertEqual(self.tracks[2], track_slice[1].track) + + @populate_tracklist + def test_slice_returns_empty_list_if_indexes_outside_tracks_list(self): + self.assertEqual(0, len(self.controller.slice(7, 8))) + self.assertEqual(0, len(self.controller.slice(-1, 1))) + + def test_version_does_not_change_when_adding_nothing(self): + version = self.controller.version + self.controller.add([]) + self.assertEquals(version, self.controller.version) + + def test_version_increases_when_adding_something(self): + version = self.controller.version + self.controller.add([Track()]) + self.assertLess(version, self.controller.version) diff --git a/tests/config/config_test.py b/tests/config/config_test.py index c40baa87b1..fceb293d62 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -106,3 +106,162 @@ def test_config_single_schema_config_error(self): self.assertEqual({'foo': {'bar': 'bad'}}, errors) # TODO: add more tests + + +INPUT_CONFIG = """# comments before first section should work + +[section] anything goes ; after the [] block it seems. +; this is a valid comment +this-should-equal-baz = baz ; as this is a comment +this-should-equal-everything = baz # as this is not a comment + +# this is also a comment ; and the next line should be a blank comment. +; +# foo # = should all be treated as a comment.""" + +PROCESSED_CONFIG = """[__COMMENTS__] +__HASH0__ = comments before first section should work +__BLANK1__ = +[section] +__SECTION2__ = anything goes +__INLINE3__ = after the [] block it seems. +__SEMICOLON4__ = this is a valid comment +this-should-equal-baz = baz +__INLINE5__ = as this is a comment +this-should-equal-everything = baz # as this is not a comment +__BLANK6__ = +__HASH7__ = this is also a comment +__INLINE8__ = and the next line should be a blank comment. +__SEMICOLON9__ = +__HASH10__ = foo # = should all be treated as a comment.""" + + +class PreProcessorTest(unittest.TestCase): + maxDiff = None # Show entire diff. + + def test_empty_config(self): + result = config._preprocess('') + self.assertEqual(result, '[__COMMENTS__]') + + def test_plain_section(self): + result = config._preprocess('[section]\nfoo = bar') + self.assertEqual(result, '[__COMMENTS__]\n' + '[section]\n' + 'foo = bar') + + def test_initial_comments(self): + result = config._preprocess('; foobar') + self.assertEqual(result, '[__COMMENTS__]\n' + '__SEMICOLON0__ = foobar') + + result = config._preprocess('# foobar') + self.assertEqual(result, '[__COMMENTS__]\n' + '__HASH0__ = foobar') + + result = config._preprocess('; foo\n# bar') + self.assertEqual(result, '[__COMMENTS__]\n' + '__SEMICOLON0__ = foo\n' + '__HASH1__ = bar') + + def test_initial_comment_inline_handling(self): + result = config._preprocess('; foo ; bar ; baz') + self.assertEqual(result, '[__COMMENTS__]\n' + '__SEMICOLON0__ = foo\n' + '__INLINE1__ = bar\n' + '__INLINE2__ = baz') + + def test_inline_semicolon_comment(self): + result = config._preprocess('[section]\nfoo = bar ; baz') + self.assertEqual(result, '[__COMMENTS__]\n' + '[section]\n' + 'foo = bar\n' + '__INLINE0__ = baz') + + def test_no_inline_hash_comment(self): + result = config._preprocess('[section]\nfoo = bar # baz') + self.assertEqual(result, '[__COMMENTS__]\n' + '[section]\n' + 'foo = bar # baz') + + def test_section_extra_text(self): + result = config._preprocess('[section] foobar') + self.assertEqual(result, '[__COMMENTS__]\n' + '[section]\n' + '__SECTION0__ = foobar') + + def test_section_extra_text_inline_semicolon(self): + result = config._preprocess('[section] foobar ; baz') + self.assertEqual(result, '[__COMMENTS__]\n' + '[section]\n' + '__SECTION0__ = foobar\n' + '__INLINE1__ = baz') + + def test_conversion(self): + """Tests all of the above cases at once.""" + result = config._preprocess(INPUT_CONFIG) + self.assertEqual(result, PROCESSED_CONFIG) + + +class PostProcessorTest(unittest.TestCase): + maxDiff = None # Show entire diff. + + def test_empty_config(self): + result = config._postprocess('[__COMMENTS__]') + self.assertEqual(result, '') + + def test_plain_section(self): + result = config._postprocess('[__COMMENTS__]\n' + '[section]\n' + 'foo = bar') + self.assertEqual(result, '[section]\nfoo = bar') + + def test_initial_comments(self): + result = config._postprocess('[__COMMENTS__]\n' + '__SEMICOLON0__ = foobar') + self.assertEqual(result, '; foobar') + + result = config._postprocess('[__COMMENTS__]\n' + '__HASH0__ = foobar') + self.assertEqual(result, '# foobar') + + result = config._postprocess('[__COMMENTS__]\n' + '__SEMICOLON0__ = foo\n' + '__HASH1__ = bar') + self.assertEqual(result, '; foo\n# bar') + + def test_initial_comment_inline_handling(self): + result = config._postprocess('[__COMMENTS__]\n' + '__SEMICOLON0__ = foo\n' + '__INLINE1__ = bar\n' + '__INLINE2__ = baz') + self.assertEqual(result, '; foo ; bar ; baz') + + def test_inline_semicolon_comment(self): + result = config._postprocess('[__COMMENTS__]\n' + '[section]\n' + 'foo = bar\n' + '__INLINE0__ = baz') + self.assertEqual(result, '[section]\nfoo = bar ; baz') + + def test_no_inline_hash_comment(self): + result = config._preprocess('[section]\nfoo = bar # baz') + self.assertEqual(result, '[__COMMENTS__]\n' + '[section]\n' + 'foo = bar # baz') + + def test_section_extra_text(self): + result = config._postprocess('[__COMMENTS__]\n' + '[section]\n' + '__SECTION0__ = foobar') + self.assertEqual(result, '[section] foobar') + + def test_section_extra_text_inline_semicolon(self): + result = config._postprocess('[__COMMENTS__]\n' + '[section]\n' + '__SECTION0__ = foobar\n' + '__INLINE1__ = baz') + self.assertEqual(result, '[section] foobar ; baz') + + def test_conversion(self): + result = config._postprocess(PROCESSED_CONFIG) + self.assertEqual(result, INPUT_CONFIG) diff --git a/tests/core/listener_test.py b/tests/core/listener_test.py index bf3a235d99..3678451d4f 100644 --- a/tests/core/listener_test.py +++ b/tests/core/listener_test.py @@ -49,7 +49,10 @@ def test_listener_has_default_impl_for_options_changed(self): 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 74f8a1054a..f3374547fb 100644 --- a/tests/core/playback_test.py +++ b/tests/core/playback_test.py @@ -177,3 +177,10 @@ def test_time_position_returns_0_if_track_is_unplayable(self): 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/data/library_tag_cache b/tests/data/library_tag_cache index 9dc11777d8..e9e87c1b2c 100644 --- a/tests/data/library_tag_cache +++ b/tests/data/library_tag_cache @@ -9,18 +9,23 @@ Artist: artist1 Title: track1 Album: album1 Date: 2001-02-03 +Track: 1 Time: 4 -key: key1 +key: key2 file: /path2 Artist: artist2 Title: track2 Album: album2 Date: 2002 +Track: 2 Time: 4 key: key3 file: /path3 -Artist: artist3 +Artist: artist4 +AlbumArtist: artist3 Title: track3 Album: album3 +Date: 2003 +Track: 3 Time: 4 songList end diff --git a/tests/data/song4.wav b/tests/data/song4.wav new file mode 100644 index 0000000000..0041c7ba42 Binary files /dev/null and b/tests/data/song4.wav differ diff --git a/tests/frontends/mpd/protocol/audio_output_test.py b/tests/frontends/mpd/protocol/audio_output_test.py index 560e935fc7..4871f16981 100644 --- a/tests/frontends/mpd/protocol/audio_output_test.py +++ b/tests/frontends/mpd/protocol/audio_output_test.py @@ -5,16 +5,48 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): def test_enableoutput(self): + self.core.playback.mute = False + self.sendRequest('enableoutput "0"') - self.assertInResponse('ACK [0@0] {} Not implemented') + + self.assertInResponse('OK') + self.assertEqual(self.core.playback.mute.get(), True) + + 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.core.playback.mute = True + self.sendRequest('disableoutput "0"') - self.assertInResponse('ACK [0@0] {} Not implemented') - def test_outputs(self): + self.assertInResponse('OK') + self.assertEqual(self.core.playback.mute.get(), False) + + 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 + self.sendRequest('outputs') + + self.assertInResponse('outputid: 0') + 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: Default') + self.assertInResponse('outputname: Mute') self.assertInResponse('outputenabled: 1') self.assertInResponse('OK') diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 21c6721f96..0114340bf4 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -24,6 +24,28 @@ def test_count_with_multiple_pairs(self): self.assertInResponse('playtime: 0') self.assertInResponse('OK') + def test_count_correct_length(self): + # Count the lone track + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[ + Track(uri='dummy:a', name="foo", date="2001", length=4000), + ]) + self.sendRequest('count "title" "foo"') + self.assertInResponse('songs: 1') + self.assertInResponse('playtime: 4') + self.assertInResponse('OK') + + # Count multiple tracks + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[ + Track(uri='dummy:b', date="2001", length=50000), + Track(uri='dummy:c', date="2001", length=600000), + ]) + self.sendRequest('count "date" "2001"') + self.assertInResponse('songs: 2') + self.assertInResponse('playtime: 650') + self.assertInResponse('OK') + def test_findadd(self): self.backend.library.dummy_find_exact_result = SearchResult( tracks=[Track(uri='dummy:a', name='A')]) @@ -175,6 +197,26 @@ def test_find_artist_does_not_include_fake_artist_tracks(self): self.assertInResponse('OK') + def test_find_albumartist_does_not_include_fake_artist_tracks(self): + self.backend.library.dummy_find_exact_result = SearchResult( + albums=[Album(uri='dummy:album:a', name='A', date='2001')], + artists=[Artist(uri='dummy:artist:b', name='B')], + tracks=[Track(uri='dummy:track:c', name='C')]) + + self.sendRequest('find "albumartist" "foo"') + + self.assertNotInResponse('file: dummy:artist:b') + self.assertNotInResponse('Title: Artist: B') + + self.assertInResponse('file: dummy:album:a') + self.assertInResponse('Title: Album: A') + self.assertInResponse('Date: 2001') + + self.assertInResponse('file: dummy:track:c') + self.assertInResponse('Title: C') + + self.assertInResponse('OK') + def test_find_artist_and_album_does_not_include_fake_tracks(self): self.backend.library.dummy_find_exact_result = SearchResult( albums=[Album(uri='dummy:album:a', name='A', date='2001')], @@ -211,6 +253,14 @@ def test_find_artist_without_quotes(self): self.sendRequest('find artist "what"') self.assertInResponse('OK') + def test_find_albumartist(self): + self.sendRequest('find "albumartist" "what"') + self.assertInResponse('OK') + + def test_find_albumartist_without_quotes(self): + self.sendRequest('find albumartist "what"') + self.assertInResponse('OK') + def test_find_filename(self): self.sendRequest('find "filename" "afilename"') self.assertInResponse('OK') @@ -235,6 +285,18 @@ def test_find_title_without_quotes(self): self.sendRequest('find title "what"') self.assertInResponse('OK') + def test_find_track_no(self): + self.sendRequest('find "track" "10"') + self.assertInResponse('OK') + + def test_find_track_no_without_quotes(self): + self.sendRequest('find track "10"') + self.assertInResponse('OK') + + def test_find_track_no_without_filter_value(self): + self.sendRequest('find "track" ""') + self.assertInResponse('OK') + def test_find_date(self): self.sendRequest('find "date" "2002-01-01"') self.assertInResponse('OK') @@ -366,6 +428,10 @@ def test_list_album_by_album(self): self.sendRequest('list "album" "album" "analbum"') self.assertInResponse('OK') + def test_list_album_by_albumartist(self): + self.sendRequest('list "album" "albumartist" "anartist"') + self.assertInResponse('OK') + def test_list_album_by_full_date(self): self.sendRequest('list "album" "date" "2001-01-01"') self.assertInResponse('OK') @@ -541,6 +607,18 @@ def test_search_artist_without_filter_value(self): self.sendRequest('search "artist" ""') self.assertInResponse('OK') + def test_search_albumartist(self): + self.sendRequest('search "albumartist" "analbumartist"') + self.assertInResponse('OK') + + def test_search_albumartist_without_quotes(self): + self.sendRequest('search albumartist "analbumartist"') + self.assertInResponse('OK') + + def test_search_albumartist_without_filter_value(self): + self.sendRequest('search "albumartist" ""') + self.assertInResponse('OK') + def test_search_filename(self): self.sendRequest('search "filename" "afilename"') self.assertInResponse('OK') @@ -589,6 +667,18 @@ def test_search_any_without_filter_value(self): self.sendRequest('search "any" ""') self.assertInResponse('OK') + def test_search_track_no(self): + self.sendRequest('search "track" "10"') + self.assertInResponse('OK') + + def test_search_track_no_without_quotes(self): + self.sendRequest('search track "10"') + self.assertInResponse('OK') + + def test_search_track_no_without_filter_value(self): + self.sendRequest('search "track" ""') + self.assertInResponse('OK') + def test_search_date(self): self.sendRequest('search "date" "2002-01-01"') self.assertInResponse('OK') diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 2cfc1b9822..fc91c09c52 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -16,22 +16,22 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_consume_off(self): self.sendRequest('consume "0"') - self.assertFalse(self.core.playback.consume.get()) + self.assertFalse(self.core.tracklist.consume.get()) self.assertInResponse('OK') def test_consume_off_without_quotes(self): self.sendRequest('consume 0') - self.assertFalse(self.core.playback.consume.get()) + self.assertFalse(self.core.tracklist.consume.get()) self.assertInResponse('OK') def test_consume_on(self): self.sendRequest('consume "1"') - self.assertTrue(self.core.playback.consume.get()) + self.assertTrue(self.core.tracklist.consume.get()) self.assertInResponse('OK') def test_consume_on_without_quotes(self): self.sendRequest('consume 1') - self.assertTrue(self.core.playback.consume.get()) + self.assertTrue(self.core.tracklist.consume.get()) self.assertInResponse('OK') def test_crossfade(self): @@ -40,42 +40,42 @@ def test_crossfade(self): def test_random_off(self): self.sendRequest('random "0"') - self.assertFalse(self.core.playback.random.get()) + self.assertFalse(self.core.tracklist.random.get()) self.assertInResponse('OK') def test_random_off_without_quotes(self): self.sendRequest('random 0') - self.assertFalse(self.core.playback.random.get()) + self.assertFalse(self.core.tracklist.random.get()) self.assertInResponse('OK') def test_random_on(self): self.sendRequest('random "1"') - self.assertTrue(self.core.playback.random.get()) + self.assertTrue(self.core.tracklist.random.get()) self.assertInResponse('OK') def test_random_on_without_quotes(self): self.sendRequest('random 1') - self.assertTrue(self.core.playback.random.get()) + self.assertTrue(self.core.tracklist.random.get()) self.assertInResponse('OK') def test_repeat_off(self): self.sendRequest('repeat "0"') - self.assertFalse(self.core.playback.repeat.get()) + self.assertFalse(self.core.tracklist.repeat.get()) self.assertInResponse('OK') def test_repeat_off_without_quotes(self): self.sendRequest('repeat 0') - self.assertFalse(self.core.playback.repeat.get()) + self.assertFalse(self.core.tracklist.repeat.get()) self.assertInResponse('OK') def test_repeat_on(self): self.sendRequest('repeat "1"') - self.assertTrue(self.core.playback.repeat.get()) + self.assertTrue(self.core.tracklist.repeat.get()) self.assertInResponse('OK') def test_repeat_on_without_quotes(self): self.sendRequest('repeat 1') - self.assertTrue(self.core.playback.repeat.get()) + self.assertTrue(self.core.tracklist.repeat.get()) self.assertInResponse('OK') def test_setvol_below_min(self): @@ -115,22 +115,22 @@ def test_setvol_without_quotes(self): def test_single_off(self): self.sendRequest('single "0"') - self.assertFalse(self.core.playback.single.get()) + self.assertFalse(self.core.tracklist.single.get()) self.assertInResponse('OK') def test_single_off_without_quotes(self): self.sendRequest('single 0') - self.assertFalse(self.core.playback.single.get()) + self.assertFalse(self.core.tracklist.single.get()) self.assertInResponse('OK') def test_single_on(self): self.sendRequest('single "1"') - self.assertTrue(self.core.playback.single.get()) + self.assertTrue(self.core.tracklist.single.get()) self.assertInResponse('OK') def test_single_on_without_quotes(self): self.sendRequest('single 1') - self.assertTrue(self.core.playback.single.get()) + self.assertTrue(self.core.tracklist.single.get()) self.assertInResponse('OK') def test_replay_gain_mode_off(self): diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index ded0c3b251..d86f7dcd96 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -64,7 +64,7 @@ def test_status_method_contains_repeat_is_0(self): self.assertEqual(int(result['repeat']), 0) def test_status_method_contains_repeat_is_1(self): - self.core.playback.repeat = 1 + self.core.tracklist.repeat = 1 result = dict(status.status(self.context)) self.assertIn('repeat', result) self.assertEqual(int(result['repeat']), 1) @@ -75,7 +75,7 @@ def test_status_method_contains_random_is_0(self): self.assertEqual(int(result['random']), 0) def test_status_method_contains_random_is_1(self): - self.core.playback.random = 1 + self.core.tracklist.random = 1 result = dict(status.status(self.context)) self.assertIn('random', result) self.assertEqual(int(result['random']), 1) @@ -91,7 +91,7 @@ def test_status_method_contains_consume_is_0(self): self.assertEqual(int(result['consume']), 0) def test_status_method_contains_consume_is_1(self): - self.core.playback.consume = 1 + self.core.tracklist.consume = 1 result = dict(status.status(self.context)) self.assertIn('consume', result) self.assertEqual(int(result['consume']), 1) diff --git a/tests/frontends/mpris/__init__.py b/tests/frontends/mpris/__init__.py deleted file mode 100644 index baffc48825..0000000000 --- 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 0a4bc79fd6..0000000000 --- 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 52cd964b9a..0000000000 --- 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.playback.repeat = False - self.core.playback.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.playback.repeat = True - self.core.playback.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.playback.repeat = True - self.core.playback.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.playback.repeat = True - self.core.playback.single = True - self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'None') - self.assertEqual(self.core.playback.repeat.get(), True) - self.assertEqual(self.core.playback.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.playback.repeat.get(), False) - self.assertEqual(self.core.playback.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.playback.repeat.get(), True) - self.assertEqual(self.core.playback.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.playback.repeat.get(), True) - self.assertEqual(self.core.playback.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.playback.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.playback.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.playback.random = False - self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) - self.assertFalse(self.core.playback.random.get()) - - def test_set_shuffle_to_true_activates_random_mode(self): - self.core.playback.random = False - self.assertFalse(self.core.playback.random.get()) - self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) - self.assertTrue(self.core.playback.random.get()) - - def test_set_shuffle_to_false_deactivates_random_mode(self): - self.core.playback.random = True - self.assertTrue(self.core.playback.random.get()) - self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', False) - self.assertFalse(self.core.playback.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.playback.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.playback.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 f8e2cf3e41..0000000000 --- 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 f95f0969cb..0000000000 --- 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/scanner_test.py b/tests/scanner_test.py index ca00753319..1102c525aa 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -2,8 +2,9 @@ import unittest -from mopidy.scanner import Scanner, translator +from mopidy import exceptions from mopidy.models import Track, Artist, Album +from mopidy.scanner import Scanner, translator from mopidy.utils import path as path_lib from tests import path_to_data_dir @@ -150,21 +151,18 @@ def setUp(self): def scan(self, path): paths = path_lib.find_files(path_to_data_dir(path)) uris = (path_lib.path_to_uri(p) for p in paths) - scanner = Scanner(uris, self.data_callback, self.error_callback) - scanner.start() + scanner = Scanner() + for uri in uris: + key = uri[len('file://'):] + try: + self.data[key] = scanner.scan(uri) + except exceptions.ScannerError as error: + self.errors[key] = error def check(self, name, key, value): name = path_to_data_dir(name) self.assertEqual(self.data[name][key], value) - def data_callback(self, data): - uri = data['uri'][len('file://'):] - self.data[uri] = data - - def error_callback(self, uri, error, debug): - uri = uri[len('file://'):] - self.errors[uri] = (error, debug) - def test_data_is_set(self): self.scan('scanner/simple') self.assert_(self.data) @@ -210,7 +208,7 @@ def test_other_media_is_ignored(self): self.scan('scanner/image') self.assert_(self.errors) - def test_log_file_is_ignored(self): + def test_log_file_that_gst_thinks_is_mpeg_1_is_ignored(self): self.scan('scanner/example.log') self.assert_(self.errors) diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index 5dccbe050a..c6f516bb1c 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -34,6 +34,9 @@ def take_it_all(self, a, b, c=True, *args, **kwargs): def _secret(self): return 'Grand Unified Theory' + def fail(self): + raise ValueError('What did you expect?') + class JsonRpcTestBase(unittest.TestCase): def setUp(self): @@ -266,12 +269,12 @@ def test_notification_unknown_method_returns_nothing(self): class JsonRpcBatchTest(JsonRpcTestBase): def test_batch_of_only_commands_returns_all(self): - self.core.playback.set_random(True).get() + self.core.tracklist.set_random(True).get() request = [ - {'jsonrpc': '2.0', 'method': 'core.playback.get_repeat', 'id': 1}, - {'jsonrpc': '2.0', 'method': 'core.playback.get_random', 'id': 2}, - {'jsonrpc': '2.0', 'method': 'core.playback.get_single', 'id': 3}, + {'jsonrpc': '2.0', 'method': 'core.tracklist.get_repeat', 'id': 1}, + {'jsonrpc': '2.0', 'method': 'core.tracklist.get_random', 'id': 2}, + {'jsonrpc': '2.0', 'method': 'core.tracklist.get_single', 'id': 3}, ] response = self.jrw.handle_data(request) @@ -283,12 +286,12 @@ def test_batch_of_only_commands_returns_all(self): self.assertEqual(response[3]['result'], False) def test_batch_of_commands_and_notifications_returns_some(self): - self.core.playback.set_random(True).get() + self.core.tracklist.set_random(True).get() request = [ - {'jsonrpc': '2.0', 'method': 'core.playback.get_repeat'}, - {'jsonrpc': '2.0', 'method': 'core.playback.get_random', 'id': 2}, - {'jsonrpc': '2.0', 'method': 'core.playback.get_single', 'id': 3}, + {'jsonrpc': '2.0', 'method': 'core.tracklist.get_repeat'}, + {'jsonrpc': '2.0', 'method': 'core.tracklist.get_random', 'id': 2}, + {'jsonrpc': '2.0', 'method': 'core.tracklist.get_single', 'id': 3}, ] response = self.jrw.handle_data(request) @@ -300,12 +303,12 @@ def test_batch_of_commands_and_notifications_returns_some(self): self.assertEqual(response[3]['result'], False) def test_batch_of_only_notifications_returns_nothing(self): - self.core.playback.set_random(True).get() + self.core.tracklist.set_random(True).get() request = [ - {'jsonrpc': '2.0', 'method': 'core.playback.get_repeat'}, - {'jsonrpc': '2.0', 'method': 'core.playback.get_random'}, - {'jsonrpc': '2.0', 'method': 'core.playback.get_single'}, + {'jsonrpc': '2.0', 'method': 'core.tracklist.get_repeat'}, + {'jsonrpc': '2.0', 'method': 'core.tracklist.get_random'}, + {'jsonrpc': '2.0', 'method': 'core.tracklist.get_single'}, ] response = self.jrw.handle_data(request) @@ -316,8 +319,8 @@ class JsonRpcSingleCommandErrorTest(JsonRpcTestBase): def test_application_error_response(self): request = { 'jsonrpc': '2.0', - 'method': 'core.tracklist.index', - 'params': ['bogus'], + 'method': 'calc.fail', + 'params': [], 'id': 1, } response = self.jrw.handle_data(request) @@ -330,7 +333,7 @@ def test_application_error_response(self): data = error['data'] self.assertEqual(data['type'], 'ValueError') - self.assertIn('not in list', data['message']) + self.assertIn('What did you expect?', data['message']) self.assertIn('traceback', data) self.assertIn('Traceback (most recent call last):', data['traceback']) @@ -522,10 +525,10 @@ def test_batch_of_both_successfull_and_failing_requests(self): {'jsonrpc': '2.0', 'method': 'core.playback.set_volume', 'params': [47], 'id': '1'}, # Notification - {'jsonrpc': '2.0', 'method': 'core.playback.set_consume', + {'jsonrpc': '2.0', 'method': 'core.tracklist.set_consume', 'params': [True]}, # Call with positional params - {'jsonrpc': '2.0', 'method': 'core.playback.set_repeat', + {'jsonrpc': '2.0', 'method': 'core.tracklist.set_repeat', 'params': [False], 'id': '2'}, # Invalid request {'foo': 'boo'}, @@ -533,7 +536,7 @@ def test_batch_of_both_successfull_and_failing_requests(self): {'jsonrpc': '2.0', 'method': 'foo.get', 'params': {'name': 'myself'}, 'id': '5'}, # Call without params - {'jsonrpc': '2.0', 'method': 'core.playback.get_random', + {'jsonrpc': '2.0', 'method': 'core.tracklist.get_random', 'id': '9'}, ] response = self.jrw.handle_data(request) diff --git a/tests/version_test.py b/tests/version_test.py index 6503ef39e9..94fe4544b9 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -39,5 +39,6 @@ def test_versions_can_be_strictly_ordered(self): 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'))