Release v0.16.0
This commit is contained in:
commit
1ff9ce4b08
5
.coveragerc
Normal file
5
.coveragerc
Normal file
@ -0,0 +1,5 @@
|
||||
[report]
|
||||
omit =
|
||||
*/pyshared/*
|
||||
*/python?.?/*
|
||||
*/site-packages/nose/*
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -14,3 +14,5 @@ docs/_build/
|
||||
mopidy.log*
|
||||
node_modules/
|
||||
nosetests.xml
|
||||
*~
|
||||
*.orig
|
||||
|
||||
2
.mailmap
2
.mailmap
@ -8,3 +8,5 @@ John Bäckstrand <sopues@gmail.com> <sandos@XBMCLive.(none)>
|
||||
Alli Witheford <alzeih@gmail.com>
|
||||
Alexandre Petitjean <alpetitjean@gmail.com>
|
||||
Alexandre Petitjean <alpetitjean@gmail.com> <alexandre.petitjean@lne.fr>
|
||||
Javier Domingo Cansino <javier.domingo@fon.com> <javierdo1@gmail.com>
|
||||
Lasse Bigum <lasse@bigum.org> <l.bigum@samsung.com>
|
||||
|
||||
@ -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:
|
||||
|
||||
3
AUTHORS
3
AUTHORS
@ -23,4 +23,7 @@
|
||||
- Tobias Sauerwein <cgtobi@gmail.com>
|
||||
- Alli Witheford <alzeih@gmail.com>
|
||||
- Alexandre Petitjean <alpetitjean@gmail.com>
|
||||
- Terje Larsen <terlar@gmail.com>
|
||||
- Javier Domingo Cansino <javier.domingo@fon.com>
|
||||
- Pavol Babincak <scroolik@gmail.com>
|
||||
- Lasse Bigum <lasse@bigum.org>
|
||||
|
||||
@ -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
|
||||
|
||||
13
README.rst
13
README.rst
@ -25,5 +25,18 @@ To get started with Mopidy, check out `the docs <http://docs.mopidy.com/>`_.
|
||||
- Mailing list: `mopidy@googlegroups.com <https://groups.google.com/forum/?fromgroups=#!forum/mopidy>`_
|
||||
- Twitter: `@mopidy <https://twitter.com/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
|
||||
|
||||
@ -54,5 +54,4 @@ Backend implementations
|
||||
|
||||
* :mod:`mopidy.backends.dummy`
|
||||
* :mod:`mopidy.backends.local`
|
||||
* :mod:`mopidy.backends.spotify`
|
||||
* :mod:`mopidy.backends.stream`
|
||||
|
||||
@ -49,5 +49,3 @@ Frontend implementations
|
||||
|
||||
* :mod:`mopidy.frontends.http`
|
||||
* :mod:`mopidy.frontends.mpd`
|
||||
* :mod:`mopidy.frontends.mpris`
|
||||
* :mod:`mopidy.frontends.scrobbler`
|
||||
|
||||
@ -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 <extensiondev>`, 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 <authors>`, 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 <https://github.com/mopidy/mopidy-spotify>`_.
|
||||
|
||||
- The Last.fm scrobbler has been moved to
|
||||
`Mopidy-Scrobbler <https://github.com/mopidy/mopidy-scrobbler>`_.
|
||||
|
||||
- The MPRIS frontend has been moved to
|
||||
`Mopidy-MPRIS <https://github.com/mopidy/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
|
||||
<https://github.com/mopidy/cookiecutter-mopidy-ext>`_. (Fixes: :issue:`522`)
|
||||
|
||||
|
||||
v0.15.0 (2013-09-19)
|
||||
====================
|
||||
|
||||
|
||||
@ -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 <ext-mpris>` currently implements all required
|
||||
The MPRIS frontend provided by the `Mopidy-MPRIS extension
|
||||
<https://github.com/mopidy/mopidy-mpris>`_ currently implements all required
|
||||
parts of the MPRIS spec, plus the optional playlist interface. It does not
|
||||
implement the optional tracklist interface.
|
||||
|
||||
|
||||
@ -36,19 +36,21 @@ How to make Mopidy available as an UPnP MediaRenderer
|
||||
=====================================================
|
||||
|
||||
With the help of `the Rygel project <https://live.gnome.org/Rygel>`_ Mopidy can
|
||||
be made available as an UPnP MediaRenderer. Rygel will interface with Mopidy's
|
||||
:ref:`MPRIS frontend <ext-mpris>`, and make Mopidy available as a MediaRenderer
|
||||
on the local network. Since this depends on the MPRIS frontend, which again
|
||||
depends on D-Bus being available, this will only work on Linux, and not OS X.
|
||||
MPRIS/D-Bus is only available to other applications on the same host, so Rygel
|
||||
must be running on the same machine as Mopidy.
|
||||
be made available as an UPnP MediaRenderer. Rygel will interface with the MPRIS
|
||||
interface provided by the `Mopidy-MPRIS extension
|
||||
<https://github.com/mopidy/mopidy-mpris>`_, 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 <ext-mpris>` 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 <ext-mpris>` page.
|
||||
1. Start Mopidy and make sure the MPRIS frontend is working. It is activated
|
||||
by default when the Mopidy-MPRIS extension is installed, but you may miss
|
||||
dependencies or be using OS X, in which case it will not work. Check the
|
||||
console output when Mopidy is started for any errors related to the MPRIS
|
||||
frontend. If you're unsure it is working, there are instructions for how to
|
||||
test it on in the `Mopidy-MPRIS readme
|
||||
<https://github.com/mopidy/mopidy-mpris>`_.
|
||||
|
||||
2. Install Rygel. On Debian/Ubuntu::
|
||||
|
||||
|
||||
@ -35,6 +35,8 @@ class Mock(object):
|
||||
elif (name[0] == name[0].upper()
|
||||
# gst.interfaces.MIXER_TRACK_*
|
||||
and not name.startswith('MIXER_TRACK_')
|
||||
# gst.PadTemplate
|
||||
and not name.startswith('PadTemplate')
|
||||
# dbus.String()
|
||||
and not name == 'String'):
|
||||
return type(name, (), {})
|
||||
@ -76,6 +78,9 @@ sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + '/../'))
|
||||
# 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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
<http://beets.radbox.org/>`_ music library through Beets' web extension.
|
||||
|
||||
Author:
|
||||
Janez Troha
|
||||
PyPI:
|
||||
`Mopidy-Beets <https://pypi.python.org/pypi/Mopidy-Beets>`_
|
||||
GitHub:
|
||||
`dz0ny/mopidy-beets <https://github.com/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
|
||||
<https://play.google.com/music/>`_.
|
||||
|
||||
Author:
|
||||
Ronald Hecht
|
||||
PyPI:
|
||||
`Mopidy-GMusic <https://pypi.python.org/pypi/Mopidy-GMusic>`_
|
||||
GitHub:
|
||||
`hechtus/mopidy-gmusic <https://github.com/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 <http://www.mpris.org/>`_
|
||||
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 <https://pypi.python.org/pypi/Mopidy-NAD>`_
|
||||
GitHub:
|
||||
`mopidy/mopidy-nad <https://github.com/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 <http://www.radio.de/>`_, `rad.io <http://www.rad.io/>`_,
|
||||
`radio.fr <http://www.radio.fr/>`_, and `radio.at <http://www.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 <http://somafm.com/>`_
|
||||
service.
|
||||
|
||||
Author:
|
||||
Alexandre Petitjean
|
||||
PyPI:
|
||||
`Mopidy-SomaFM <https://pypi.python.org/pypi/Mopidy-SomaFM>`_
|
||||
GitHub:
|
||||
`AlexandrePTJ/mopidy-somafm <https://github.com/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
|
||||
<http://www.soundcloud.com/>`_ service.
|
||||
|
||||
Author:
|
||||
Janez Troha
|
||||
PyPI:
|
||||
`Mopidy-SoundCloud <https://pypi.python.org/pypi/Mopidy-SoundCloud>`_
|
||||
GitHub:
|
||||
`dz0ny/mopidy-soundcloud <https://github.com/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 <http://www.spotify.com/>`_ music
|
||||
streaming service.
|
||||
|
||||
|
||||
Mopidy-Subsonic
|
||||
---------------
|
||||
|
||||
https://github.com/rattboi/mopidy-subsonic
|
||||
|
||||
Provides a backend for playing music from a `Subsonic Music Streamer
|
||||
<http://www.subsonic.org/>`_ library.
|
||||
|
||||
Author:
|
||||
Bradon Kanyid
|
||||
PyPI:
|
||||
`Mopidy-Subsonic <https://pypi.python.org/pypi/Mopidy-Subsonic>`_
|
||||
GitHub:
|
||||
`rattboi/mopidy-subsonic <https://github.com/rattboi/mopidy-subsonic>`_
|
||||
Issues:
|
||||
https://github.com/rattboi/mopidy-subsonic/issues
|
||||
|
||||
@ -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
|
||||
=====
|
||||
|
||||
@ -1,105 +0,0 @@
|
||||
.. _ext-mpris:
|
||||
|
||||
************
|
||||
Mopidy-MPRIS
|
||||
************
|
||||
|
||||
This extension lets you control Mopidy through the Media Player Remote
|
||||
Interfacing Specification (`MPRIS <http://www.mpris.org/>`_) 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
|
||||
<http://www.mpris.org/>`_. 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
|
||||
<http://www.mpris.org/>`_.
|
||||
@ -1,55 +0,0 @@
|
||||
.. _ext-scrobbler:
|
||||
|
||||
****************
|
||||
Mopidy-Scrobbler
|
||||
****************
|
||||
|
||||
This extension scrobbles the music you play to your `Last.fm
|
||||
<http://www.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
|
||||
@ -1,83 +0,0 @@
|
||||
.. _ext-spotify:
|
||||
|
||||
**************
|
||||
Mopidy-Spotify
|
||||
**************
|
||||
|
||||
An extension for playing music from Spotify.
|
||||
|
||||
`Spotify <http://www.spotify.com/>`_ is a music streaming service. The backend
|
||||
uses the official `libspotify
|
||||
<http://developer.spotify.com/en/libspotify/overview/>`_ library and the
|
||||
`pyspotify <http://github.com/mopidy/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.
|
||||
@ -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 <http://cookiecutter.readthedocs.org/>`_
|
||||
project template for `creating new Mopidy extensions
|
||||
<https://github.com/mopidy/cookiecutter-mopidy-ext>`_. 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
|
||||
<https://github.com/mopidy/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 <http://www.mopidy.com/>`_ extension for playing music from
|
||||
`Soundspot <http://soundspot.example.com/>`_.
|
||||
|
||||
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
|
||||
<http://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 <https://github.com/mopidy/mopidy-soundspot>`_
|
||||
- `Issue tracker <https://github.com/mopidy/mopidy-soundspot/issues>`_
|
||||
- `Download development snapshot <https://github.com/mopidy/mopidy-soundspot/tarball/develop#egg=Mopidy-Soundspot-dev>`_
|
||||
- `Download development snapshot <https://github.com/mopidy/mopidy-soundspot/tarball/master#egg=Mopidy-Soundspot-dev>`_
|
||||
|
||||
|
||||
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',
|
||||
|
||||
@ -24,10 +24,9 @@ Glossary
|
||||
|
||||
frontend
|
||||
A part of Mopidy *using* the :term:`core` API. Existing frontends
|
||||
include the :ref:`MPD server <ext-mpd>`, the :ref:`MPRIS/D-Bus
|
||||
integration <ext-mpris>`, the :ref:`Last.fm scrobbler <ext-scrobbler>`,
|
||||
and the :ref:`HTTP server <ext-http>` with JavaScript API. See
|
||||
:ref:`frontend-api` for details.
|
||||
include the :ref:`MPD server <ext-mpd>`, the MPRIS/D-Bus integration,
|
||||
the Last.fm scrobbler, and the :ref:`HTTP server <ext-http>` with
|
||||
JavaScript API. See :ref:`frontend-api` for details.
|
||||
|
||||
mixer
|
||||
A GStreamer element that controls audio volume.
|
||||
|
||||
@ -4,9 +4,9 @@ Mopidy
|
||||
|
||||
Mopidy is a music server which can play music both from multiple sources, like
|
||||
your :ref:`local hard drive <ext-local>`, :ref:`radio streams <ext-stream>`,
|
||||
and from :ref:`Spotify <ext-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 <http-clients>`, the :ref:`Ubuntu Sound Menu <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`
|
||||
|
||||
@ -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
|
||||
<https://aur.archlinux.org/packages/python2-pylast/>`_ from AUR.
|
||||
install `python2-pylast`::
|
||||
|
||||
sudo pacman -S python2-pylast
|
||||
|
||||
#. Finally, you need to set a couple of :doc:`config values </config>`, and
|
||||
then you're ready to :doc:`run Mopidy </running>`.
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -21,4 +21,4 @@ if (isinstance(pykka.__version__, basestring)
|
||||
warnings.filterwarnings('ignore', 'could not open display')
|
||||
|
||||
|
||||
__version__ = '0.15.0'
|
||||
__version__ = '0.16.0'
|
||||
|
||||
@ -11,7 +11,7 @@ import pykka
|
||||
|
||||
from mopidy.utils import process
|
||||
|
||||
from . import mixers, utils
|
||||
from . import mixers, playlists, utils
|
||||
from .constants import PlaybackState
|
||||
from .listener import AudioListener
|
||||
|
||||
@ -19,6 +19,9 @@ logger = logging.getLogger('mopidy.audio')
|
||||
|
||||
mixers.register_mixers()
|
||||
|
||||
playlists.register_typefinders()
|
||||
playlists.register_elements()
|
||||
|
||||
|
||||
MB = 1 << 20
|
||||
|
||||
@ -541,9 +544,42 @@ class Audio(pykka.ThreadingActor):
|
||||
"""Convert value between scales."""
|
||||
new_min, new_max = new
|
||||
old_min, old_max = old
|
||||
if old_min == old_max:
|
||||
return old_max
|
||||
scaling = float(new_max - new_min) / (old_max - old_min)
|
||||
return int(round(scaling * (value - old_min) + new_min))
|
||||
|
||||
def get_mute(self):
|
||||
"""
|
||||
Get mute status of the installed mixer.
|
||||
|
||||
:rtype: :class:`True` if muted, :class:`False` if unmuted,
|
||||
:class:`None` if no mixer is installed.
|
||||
"""
|
||||
if self._software_mixing:
|
||||
return self._playbin.get_property('mute')
|
||||
|
||||
if self._mixer_track is None:
|
||||
return None
|
||||
|
||||
return bool(self._mixer_track.flags & gst.interfaces.MIXER_TRACK_MUTE)
|
||||
|
||||
def set_mute(self, mute):
|
||||
"""
|
||||
Mute or unmute of the installed mixer.
|
||||
|
||||
:param mute: Wether to mute the mixer or not.
|
||||
:type mute: bool
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
if self._software_mixing:
|
||||
return self._playbin.set_property('mute', bool(mute))
|
||||
|
||||
if self._mixer_track is None:
|
||||
return False
|
||||
|
||||
return self._mixer.set_mute(self._mixer_track, bool(mute))
|
||||
|
||||
def set_metadata(self, track):
|
||||
"""
|
||||
Set track metadata for currently playing song.
|
||||
|
||||
412
mopidy/audio/playlists.py
Normal file
412
mopidy/audio/playlists.py
Normal file
@ -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)
|
||||
@ -21,7 +21,9 @@ class Extension(ext.Extension):
|
||||
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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -27,9 +27,14 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
|
||||
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 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
|
||||
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 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
|
||||
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 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
|
||||
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 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
|
||||
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 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
|
||||
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':
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -6,7 +6,7 @@ import os
|
||||
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 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider):
|
||||
|
||||
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)
|
||||
|
||||
@ -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]
|
||||
@ -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()
|
||||
@ -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.
|
||||
@ -1,7 +0,0 @@
|
||||
[spotify]
|
||||
enabled = true
|
||||
username =
|
||||
password =
|
||||
bitrate = 160
|
||||
timeout = 10
|
||||
cache_dir = $XDG_CACHE_DIR/mopidy/spotify
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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())
|
||||
@ -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
|
||||
@ -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()
|
||||
Binary file not shown.
@ -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)
|
||||
@ -2,8 +2,10 @@ from __future__ import unicode_literals
|
||||
|
||||
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
|
||||
|
||||
@ -132,11 +132,25 @@ class CoreListener(object):
|
||||
"""
|
||||
pass
|
||||
|
||||
def volume_changed(self):
|
||||
def volume_changed(self, volume):
|
||||
"""
|
||||
Called whenever the volume is changed.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
|
||||
:param volume: the new volume in the range [0..100]
|
||||
:type volume: int
|
||||
"""
|
||||
pass
|
||||
|
||||
def mute_changed(self, mute):
|
||||
"""
|
||||
Called whenever the mute state is changed.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
|
||||
:param mute: the new mute state
|
||||
:type mute: boolean
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
import random
|
||||
import urlparse
|
||||
|
||||
from mopidy.audio import PlaybackState
|
||||
@ -21,9 +20,8 @@ class PlaybackController(object):
|
||||
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 @@ class PlaybackController(object):
|
||||
|
||||
### 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 @@ class PlaybackController(object):
|
||||
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 @@ class PlaybackController(object):
|
||||
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 @@ class PlaybackController(object):
|
||||
volume = property(get_volume, set_volume)
|
||||
"""Volume as int in range [0..100] or :class:`None`"""
|
||||
|
||||
def get_mute(self):
|
||||
if self.audio:
|
||||
return self.audio.get_mute().get()
|
||||
else:
|
||||
# For testing
|
||||
return self._mute
|
||||
|
||||
def set_mute(self, value):
|
||||
value = bool(value)
|
||||
if self.audio:
|
||||
self.audio.set_mute(value)
|
||||
else:
|
||||
# For testing
|
||||
self._mute = value
|
||||
|
||||
self._trigger_mute_changed(value)
|
||||
|
||||
mute = property(get_mute, set_mute)
|
||||
"""Mute state as a :class:`True` if muted, :class:`False` otherwise"""
|
||||
|
||||
### Methods
|
||||
|
||||
def change_track(self, tl_track, on_error_step=1):
|
||||
@ -318,15 +157,15 @@ class PlaybackController(object):
|
||||
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 @@ class PlaybackController(object):
|
||||
|
||||
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 @@ class PlaybackController(object):
|
||||
The current playback state will be kept. If it was playing, playing
|
||||
will continue. If it was paused, it will still be paused, etc.
|
||||
"""
|
||||
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 @@ class PlaybackController(object):
|
||||
: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 @@ class PlaybackController(object):
|
||||
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 @@ class PlaybackController(object):
|
||||
'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)
|
||||
|
||||
@ -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 @@ class TracklistController(object):
|
||||
|
||||
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 @@ class TracklistController(object):
|
||||
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 @@ class TracklistController(object):
|
||||
'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 @@ class TracklistController(object):
|
||||
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 @@ class TracklistController(object):
|
||||
"""
|
||||
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')
|
||||
|
||||
@ -18,3 +18,7 @@ class MopidyException(Exception):
|
||||
|
||||
class ExtensionError(MopidyException):
|
||||
pass
|
||||
|
||||
|
||||
class ScannerError(MopidyException):
|
||||
pass
|
||||
|
||||
@ -83,8 +83,7 @@ class Extension(object):
|
||||
"""List of library updater classes
|
||||
|
||||
:returns: list of
|
||||
:class:`~mopidy.backends.base.BaseLibraryUpdateProvider`
|
||||
subclasses
|
||||
:class:`~mopidy.backends.base.BaseLibraryUpdateProvider` subclasses
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
@ -55,3 +55,6 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
|
||||
|
||||
def volume_changed(self, volume):
|
||||
self.send_idle('mixer')
|
||||
|
||||
def mute_changed(self, mute):
|
||||
self.send_idle('output')
|
||||
|
||||
@ -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<outputid>\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<outputid>\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),
|
||||
]
|
||||
|
||||
@ -5,13 +5,13 @@ import itertools
|
||||
|
||||
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<mpd_query>("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile|[Ff]ilename|'
|
||||
r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$')
|
||||
r'(?P<mpd_query>("?([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)]
|
||||
|
||||
@ -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<seconds>\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<state>[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<mode>(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$')
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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<field>( # 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<what>[^"]+)" # 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<field>([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile|[Ff]ilename|'
|
||||
r'[Tt]itle|[Aa]ny))"? "(?P<what>[^"]+)"')
|
||||
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
|
||||
|
||||
@ -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]
|
||||
@ -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 <https://wiki.ubuntu.com/SoundMenu>`_.
|
||||
|
||||
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)
|
||||
@ -1,3 +0,0 @@
|
||||
[mpris]
|
||||
enabled = true
|
||||
desktop_file = /usr/share/applications/mopidy.desktop
|
||||
@ -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)
|
||||
@ -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]
|
||||
@ -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)
|
||||
@ -1,4 +0,0 @@
|
||||
[scrobbler]
|
||||
enabled = true
|
||||
username =
|
||||
password =
|
||||
@ -16,17 +16,12 @@ mopidy_args = sys.argv[1:]
|
||||
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 translator(data):
|
||||
|
||||
|
||||
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()
|
||||
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)
|
||||
|
||||
def process_tags(self, bus, message):
|
||||
taglist = message.parse_tag()
|
||||
data = {}
|
||||
audio_streams = info.get_audio_streams()
|
||||
|
||||
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]
|
||||
if not audio_streams:
|
||||
raise exceptions.ScannerError('Did not find any audio streams.')
|
||||
|
||||
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()
|
||||
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]
|
||||
|
||||
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
|
||||
# Never trust metadata for these fields:
|
||||
data[b'uri'] = uri
|
||||
data[b'duration'] = info.get_duration() // gst.MSECOND
|
||||
|
||||
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
|
||||
if data[b'duration'] < 100:
|
||||
raise exceptions.ScannerError(
|
||||
'Rejecting file with less than 100ms audio data.')
|
||||
|
||||
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()
|
||||
return data
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
5
setup.py
5
setup.py
@ -28,8 +28,6 @@ setup(
|
||||
'Pykka >= 1.1',
|
||||
],
|
||||
extras_require={
|
||||
'spotify': ['pyspotify >= 1.9, < 2'],
|
||||
'scrobbler': ['pylast >= 0.5.7'],
|
||||
'http': ['cherrypy >= 3.2.2', 'ws4py >= 0.2.3'],
|
||||
},
|
||||
test_suite='nose.collector',
|
||||
@ -45,11 +43,8 @@ setup(
|
||||
],
|
||||
'mopidy.ext': [
|
||||
'http = mopidy.frontends.http:Extension [http]',
|
||||
'scrobbler = mopidy.frontends.scrobbler:Extension [scrobbler]',
|
||||
'local = mopidy.backends.local:Extension',
|
||||
'mpd = mopidy.frontends.mpd:Extension',
|
||||
'mpris = mopidy.frontends.mpris:Extension',
|
||||
'spotify = mopidy.backends.spotify:Extension [spotify]',
|
||||
'stream = mopidy.backends.stream:Extension',
|
||||
],
|
||||
},
|
||||
|
||||
@ -6,6 +6,9 @@ import pygst
|
||||
pygst.require('0.10')
|
||||
import gst
|
||||
|
||||
import gobject
|
||||
gobject.threads_init()
|
||||
|
||||
import pykka
|
||||
|
||||
from mopidy import audio
|
||||
@ -80,6 +83,22 @@ class AudioTest(unittest.TestCase):
|
||||
self.assertTrue(self.audio.set_volume(value).get())
|
||||
self.assertEqual(value, self.audio.get_volume().get())
|
||||
|
||||
def test_set_volume_with_mixer_min_equal_max(self):
|
||||
config = {
|
||||
'audio': {
|
||||
'mixer': 'fakemixer track_max_volume=0',
|
||||
'mixer_track': None,
|
||||
'output': 'fakesink',
|
||||
'visualizer': None,
|
||||
}
|
||||
}
|
||||
self.audio = audio.Audio.start(config=config).proxy()
|
||||
self.assertEqual(0, self.audio.get_volume().get())
|
||||
|
||||
@unittest.SkipTest
|
||||
def test_set_mute(self):
|
||||
pass # TODO Probably needs a fakemixer with a mixer track
|
||||
|
||||
@unittest.SkipTest
|
||||
def test_set_state_encapsulation(self):
|
||||
pass # TODO
|
||||
|
||||
128
tests/audio/playlists_test.py
Normal file
128
tests/audio/playlists_test.py
Normal file
@ -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"""<asx version="3.0">
|
||||
<title>Example</title>
|
||||
<entry>
|
||||
<title>Sample Title</title>
|
||||
<ref href="file:///tmp/foo" />
|
||||
</entry>
|
||||
<entry>
|
||||
<title>Example title</title>
|
||||
<ref href="file:///tmp/bar" />
|
||||
</entry>
|
||||
<entry>
|
||||
<title>Other title</title>
|
||||
<ref href="file:///tmp/baz" />
|
||||
</entry>
|
||||
</asx>
|
||||
"""
|
||||
|
||||
XSPF = b"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<playlist version="1" xmlns="http://xspf.org/ns/0/">
|
||||
<trackList>
|
||||
<track>
|
||||
<title>Sample Title</title>
|
||||
<location>file:///tmp/foo</location>
|
||||
</track>
|
||||
<track>
|
||||
<title>Example title</title>
|
||||
<location>file:///tmp/bar</location>
|
||||
</track>
|
||||
<track>
|
||||
<title>Other title</title>
|
||||
<location>file:///tmp/baz</location>
|
||||
</track>
|
||||
</trackList>
|
||||
</playlist>
|
||||
"""
|
||||
|
||||
|
||||
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)
|
||||
@ -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
|
||||
@ -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')
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
|
||||
|
||||
@ -2,14 +2,18 @@ from __future__ import unicode_literals
|
||||
|
||||
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')
|
||||
|
||||
@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -5,18 +5,17 @@ import shutil
|
||||
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 @@ class LocalPlaylistsControllerTest(
|
||||
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 @@ class LocalPlaylistsControllerTest(
|
||||
@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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -106,3 +106,162 @@ class ValidateTest(unittest.TestCase):
|
||||
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)
|
||||
|
||||
@ -49,7 +49,10 @@ class CoreListenerTest(unittest.TestCase):
|
||||
self.listener.options_changed()
|
||||
|
||||
def test_listener_has_default_impl_for_volume_changed(self):
|
||||
self.listener.volume_changed()
|
||||
self.listener.volume_changed(70)
|
||||
|
||||
def test_listener_has_default_impl_for_mute_changed(self):
|
||||
self.listener.mute_changed(True)
|
||||
|
||||
def test_listener_has_default_impl_for_seeked(self):
|
||||
self.listener.seeked(0)
|
||||
|
||||
@ -177,3 +177,10 @@ class CorePlaybackTest(unittest.TestCase):
|
||||
self.assertEqual(result, 0)
|
||||
self.assertFalse(self.playback1.get_time_position.called)
|
||||
self.assertFalse(self.playback2.get_time_position.called)
|
||||
|
||||
def test_mute(self):
|
||||
self.assertEqual(self.core.playback.mute, False)
|
||||
|
||||
self.core.playback.mute = True
|
||||
|
||||
self.assertEqual(self.core.playback.mute, True)
|
||||
|
||||
@ -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
|
||||
|
||||
BIN
tests/data/song4.wav
Normal file
BIN
tests/data/song4.wav
Normal file
Binary file not shown.
@ -5,16 +5,48 @@ from tests.frontends.mpd import protocol
|
||||
|
||||
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.sendRequest('disableoutput "0"')
|
||||
self.assertInResponse('ACK [0@0] {} Not implemented')
|
||||
self.core.playback.mute = True
|
||||
|
||||
self.sendRequest('disableoutput "0"')
|
||||
|
||||
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
|
||||
|
||||
def test_outputs(self):
|
||||
self.sendRequest('outputs')
|
||||
|
||||
self.assertInResponse('outputid: 0')
|
||||
self.assertInResponse('outputname: Default')
|
||||
self.assertInResponse('outputname: Mute')
|
||||
self.assertInResponse('outputenabled: 0')
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_outputs_when_muted(self):
|
||||
self.core.playback.mute = True
|
||||
|
||||
self.sendRequest('outputs')
|
||||
|
||||
self.assertInResponse('outputid: 0')
|
||||
self.assertInResponse('outputname: Mute')
|
||||
self.assertInResponse('outputenabled: 1')
|
||||
self.assertInResponse('OK')
|
||||
|
||||
@ -24,6 +24,28 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase):
|
||||
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 @@ class MusicDatabaseFindTest(protocol.BaseTestCase):
|
||||
|
||||
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 @@ class MusicDatabaseFindTest(protocol.BaseTestCase):
|
||||
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 @@ class MusicDatabaseFindTest(protocol.BaseTestCase):
|
||||
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 @@ class MusicDatabaseListTest(protocol.BaseTestCase):
|
||||
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 @@ class MusicDatabaseSearchTest(protocol.BaseTestCase):
|
||||
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 @@ class MusicDatabaseSearchTest(protocol.BaseTestCase):
|
||||
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')
|
||||
|
||||
@ -16,22 +16,22 @@ STOPPED = PlaybackState.STOPPED
|
||||
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 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase):
|
||||
|
||||
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 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase):
|
||||
|
||||
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):
|
||||
|
||||
@ -64,7 +64,7 @@ class StatusHandlerTest(unittest.TestCase):
|
||||
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 @@ class StatusHandlerTest(unittest.TestCase):
|
||||
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 @@ class StatusHandlerTest(unittest.TestCase):
|
||||
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)
|
||||
|
||||
@ -1 +0,0 @@
|
||||
from __future__ import unicode_literals
|
||||
@ -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', ''))
|
||||
@ -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')
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -2,8 +2,9 @@ from __future__ import unicode_literals
|
||||
|
||||
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 @@ class ScannerTest(unittest.TestCase):
|
||||
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 @@ class ScannerTest(unittest.TestCase):
|
||||
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)
|
||||
|
||||
|
||||
@ -34,6 +34,9 @@ class Calculator(object):
|
||||
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 @@ class JsonRpcSingleNotificationTest(JsonRpcTestBase):
|
||||
|
||||
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 @@ class JsonRpcBatchTest(JsonRpcTestBase):
|
||||
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 @@ class JsonRpcBatchTest(JsonRpcTestBase):
|
||||
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 @@ class JsonRpcSingleCommandErrorTest(JsonRpcTestBase):
|
||||
|
||||
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 @@ class JsonRpcBatchErrorTest(JsonRpcTestBase):
|
||||
{'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 @@ class JsonRpcBatchErrorTest(JsonRpcTestBase):
|
||||
{'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)
|
||||
|
||||
@ -39,5 +39,6 @@ class VersionTest(unittest.TestCase):
|
||||
self.assertLess(SV('0.13.0'), SV('0.14.0'))
|
||||
self.assertLess(SV('0.14.0'), SV('0.14.1'))
|
||||
self.assertLess(SV('0.14.1'), SV('0.14.2'))
|
||||
self.assertLess(SV('0.14.2'), SV(__version__))
|
||||
self.assertLess(SV(__version__), SV('0.15.1'))
|
||||
self.assertLess(SV('0.14.2'), SV('0.15.0'))
|
||||
self.assertLess(SV('0.15.0'), SV(__version__))
|
||||
self.assertLess(SV(__version__), SV('0.16.1'))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user