Release v1.0.0

This commit is contained in:
Stein Magnus Jodal 2015-03-25 22:25:00 +01:00
commit 85f11baa41
240 changed files with 9474 additions and 6265 deletions

3
.gitignore vendored
View File

@ -13,8 +13,7 @@ cover/
coverage.xml
dist/
docs/_build/
js/test/lib/
mopidy.log*
node_modules/
nosetests.xml
xunit-*.xml
tmp/

View File

@ -5,6 +5,9 @@ Kristian Klette <klette@samfundet.no>
Johannes Knutsen <johannes@knutseninfo.no> <johannes@iterate.no>
Johannes Knutsen <johannes@knutseninfo.no> <johannes@barbarmaclin.(none)>
John Bäckstrand <sopues@gmail.com> <sandos@XBMCLive.(none)>
David Caruso <deibido.caruso@gmail.com> <dav@dav.com>
Adam Rigg <adam@adamrigg.id.au> <radx@live.com.au>
Ernst Bammer <herr.ernst@gmail.com>
Alli Witheford <alzeih@gmail.com>
Alexandre Petitjean <alpetitjean@gmail.com>
Alexandre Petitjean <alpetitjean@gmail.com> <alexandre.petitjean@lne.fr>
@ -15,5 +18,9 @@ Janez Troha <janez.troha@gmail.com> <dz0ny@users.noreply.github.com>
Janez Troha <janez.troha@gmail.com> <dz0ny@ubuntu.si>
Luke Giuliani <luke@giuliani.com.au>
Colin Montgomerie <kiteflyingmonkey@gmail.com>
Nathan Harper <nathan.sam.harper@gmail.com>
Ignasi Fosch <natx@y10k.ws> <ifosch@serenity-2.local>
Christopher Schirner <christopher@hackerspace-bamberg.de> <schinken@hackerspace-bamberg.de>
Laura Barber <laura.c.barber@gmail.com> <artzii.laura@gmail.com>
John Cass <john.cass77@gmail.com>
Ronald Zielaznicki <zielaznickiz@g.cofc.edu>

View File

@ -23,6 +23,10 @@ script:
after_success:
- "if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi"
branches:
except:
- debian
notifications:
irc:
channels:

17
AUTHORS
View File

@ -8,14 +8,14 @@
- John Bäckstrand <sopues@gmail.com>
- Fred Hatfull <fred.hatfull@gmail.com>
- Erling Børresen <erling@fenicore.net>
- David C <dav@dav.com>
- David Caruso <deibido.caruso@gmail.com>
- Christian Johansen <christian@cjohansen.no>
- Matt Bray <mattjbray@gmail.com>
- Trygve Aaberge <trygveaa@gmail.com>
- Wouter van Wijk <woutervanwijk@gmail.com>
- Jeremy B. Merrill <jeremybmerrill@gmail.com>
- 0xadam <radx@live.com.au>
- herrernst <herr.ernst@gmail.com>
- Adam Rigg <adam@adamrigg.id.au>
- Ernst Bammer <herr.ernst@gmail.com>
- Nick Steel <kingosticks@gmail.com>
- Zan Dobersek <zandobersek@gmail.com>
- Thomas Refis <refis.thomas@gmail.com>
@ -36,7 +36,7 @@
- Colin Montgomerie <kiteflyingmonkey@gmail.com>
- Simon de Bakker <simon@simbits.nl>
- Arnaud Barisain-Monrose <abarisain@gmail.com>
- nathanharper <nathan.sam.harper@gmail.com>
- Nathan Harper <nathan.sam.harper@gmail.com>
- Pierpaolo Frasa <pfrasa@smail.uni-koeln.de>
- Thomas Scholtes <thomas-scholtes@gmx.de>
- Sam Willcocks <sam@wlcx.cc>
@ -44,5 +44,12 @@
- Arjun Naik <arjun@arjunnaik.in>
- Christopher Schirner <christopher@hackerspace-bamberg.de>
- Dmitry Sandalov <dmitry@sandalov.org>
- Deni Bertovic <deni@kset.org>
- Lukas Vogel <lukas@vogelnest.org>
- Thomas Amland <thomas.amland@gmail.com>
- Deni Bertovic <deni@kset.org>
- Ali Ukani <ali.ukani@gmail.com>
- Dirk Groenen <dirk_groenen@live.nl>
- John Cass <john.cass77@gmail.com>
- Laura Barber <laura.c.barber@gmail.com>
- Jakab Kristóf <jaksi07c8@gmail.com>
- Ronald Zielaznicki <zielaznickiz@g.cofc.edu>

View File

@ -9,14 +9,10 @@ include LICENSE
include MANIFEST.in
include tox.ini
recursive-include data *
recursive-include docs *
prune docs/_build
recursive-include js *
prune js/node_modules
prune js/test/lib
recursive-include extra *
recursive-include mopidy *.conf
recursive-include mopidy/http/data *

View File

@ -1,5 +1,5 @@
# Automate tasks
fabric
invoke
# Build documentation
sphinx
@ -12,12 +12,11 @@ flake8-import-order
mock
# Test runners
nose
pytest
pytest-cov
pytest-xdist
tox
# Measure test's code coverage
coverage
# Check that MANIFEST.in matches Git repo contents before making a release
check-manifest

View File

@ -35,3 +35,9 @@ Audio scanner
.. autoclass:: mopidy.audio.scan.Scanner
:members:
Audio utils
===========
.. automodule:: mopidy.audio.utils
:members:

View File

@ -6,14 +6,15 @@ Architecture and concepts
The overall architecture of Mopidy is organized around multiple frontends and
backends. The frontends use the core API. The core actor makes multiple backends
work as one. The backends connect to various music sources. Both the core actor
and the backends use the audio actor to play audio and control audio volume.
work as one. The backends connect to various music sources. The core actor use
the mixer actor to control volume, while the backends use the audio actor to
play audio.
.. digraph:: overall_architecture
"Multiple frontends" -> Core
Core -> "Multiple backends"
Core -> Audio
Core -> Mixer
"Multiple backends" -> Audio
@ -21,15 +22,16 @@ Frontends
=========
Frontends expose Mopidy to the external world. They can implement servers for
protocols like MPD and MPRIS, and they can be used to update other services
when something happens in Mopidy, like the Last.fm scrobbler frontend does. See
:ref:`frontend-api` for more details.
protocols like HTTP, MPD and MPRIS, and they can be used to update other
services when something happens in Mopidy, like the Last.fm scrobbler frontend
does. See :ref:`frontend-api` for more details.
.. digraph:: frontend_architecture
"HTTP\nfrontend" -> Core
"MPD\nfrontend" -> Core
"MPRIS\nfrontend" -> Core
"Last.fm\nfrontend" -> Core
"Scrobbler\nfrontend" -> Core
Core
@ -54,6 +56,7 @@ See :ref:`core-api` for more details.
Core -> "Library\ncontroller"
Core -> "Playback\ncontroller"
Core -> "Playlists\ncontroller"
Core -> "History\ncontroller"
"Library\ncontroller" -> "Local backend"
"Library\ncontroller" -> "Spotify backend"
@ -93,7 +96,16 @@ Audio
=====
The audio actor is a thin wrapper around the parts of the GStreamer library we
use. In addition to playback, it's responsible for volume control through both
GStreamer's own volume mixers, and mixers we've created ourselves. If you
implement an advanced backend, you may need to implement your own playback
provider using the :ref:`audio-api`.
use. If you implement an advanced backend, you may need to implement your own
playback provider using the :ref:`audio-api`, but most backends can use the
default playback provider without any changes.
Mixer
=====
The mixer actor is responsible for volume control and muting. The default
mixer use the audio actor to control volume in software. The alternative
implementations are typically independent of the audio actor, but instead use
some third party Python library or a serial interface to control other forms
of volume controls.

View File

@ -37,6 +37,15 @@ Manages everything related to the tracks we are currently playing.
:members:
History controller
==================
Keeps record of what tracks have been played.
.. autoclass:: mopidy.core.HistoryController
:members:
Playlists controller
====================
@ -55,6 +64,14 @@ Manages the music library, e.g. searching for tracks to be added to a playlist.
:members:
Mixer controller
================
Manages volume and muting.
.. autoclass:: mopidy.core.MixerController
:members:
Core listener
=============

View File

@ -43,7 +43,7 @@ available at http://localhost:6680/mywebclient/foo.html.
::
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import os
@ -95,7 +95,7 @@ Mopidy $version``.
::
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import os
@ -149,7 +149,7 @@ http://localhost:6680/mywebclient/.
::
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import os

View File

@ -14,18 +14,6 @@ WebSocket API for use both from browsers and Node.js. The
:ref:`http-explore-extension` extension, can also be used to get you
familiarized with HTTP based APIs.
.. warning:: API stability
Since the HTTP JSON-RPC API exposes our internal core API directly it is to
be regarded as **experimental**. We cannot promise to keep any form of
backwards compatibility between releases as we will need to change the core
API while working out how to support new use cases. Thus, if you use this
API, you must expect to do small adjustments to your client for every
release of Mopidy.
From Mopidy 1.0 and onwards, we intend to keep the core API far more
stable.
.. _http-post-api:

View File

@ -4,13 +4,10 @@
API reference
*************
.. warning:: API stability
.. note:: What is public?
Only APIs documented here are public and open for use by Mopidy
extensions. We will change these APIs, but will keep the changelog up to
date with all breaking changes.
From Mopidy 1.0 and onwards, we intend to keep these APIs far more stable.
extensions.
.. toctree::

View File

@ -8,18 +8,6 @@ We've made a JavaScript library, Mopidy.js, which wraps the
:ref:`websocket-api` and gets you quickly started with working on your client
instead of figuring out how to communicate with Mopidy.
.. warning:: API stability
Since the Mopidy.js API exposes our internal core API directly it is to be
regarded as **experimental**. We cannot promise to keep any form of
backwards compatibility between releases as we will need to change the core
API while working out how to support new use cases. Thus, if you use this
API, you must expect to do small adjustments to your client for every
release of Mopidy.
From Mopidy 1.0 and onwards, we intend to keep the core API far more
stable.
Getting the library for browser use
===================================
@ -66,9 +54,10 @@ After npm completes, you can import Mopidy.js using ``require()``:
Getting the library for development on the library
==================================================
If you want to work on the Mopidy.js library itself, you'll find a complete
development setup in the ``js/`` dir in our repo. The instructions in
``js/README.md`` will guide you on your way.
If you want to work on the Mopidy.js library itself, you'll find the source
code and a complete development setup in the `Mopidy.js Git repo
<https://github.com/mopidy/mopidy.js>`_. The instructions in ``README.md`` will
guide you on your way.
Creating an instance
@ -288,9 +277,10 @@ unhandled errors. In general, unhandled errors will not go silently missing.
The promise objects returned by Mopidy.js adheres to the `CommonJS Promises/A
<http://wiki.commonjs.org/wiki/Promises/A>`_ standard. We use the
implementation known as `when.js <https://github.com/cujojs/when>`_. Please
refer to when.js' documentation or the standard for further details on how to
work with promise objects.
implementation known as `when.js <https://github.com/cujojs/when>`_, and
reexport it as ``Mopidy.when`` so you don't have to duplicate the dependency.
Please refer to when.js' documentation or the standard for further details on
how to work with promise objects.
Cleaning up

View File

@ -28,19 +28,54 @@ Data model relations
.. digraph:: model_relations
Playlist -> Track [ label="has 0..n" ]
Track -> Album [ label="has 0..1" ]
Track -> Artist [ label="has 0..n" ]
Album -> Artist [ label="has 0..n" ]
Ref -> Album [ style="dotted", weight=1 ]
Ref -> Artist [ style="dotted", weight=1 ]
Ref -> Directory [ style="dotted", weight=1 ]
Ref -> Playlist [ style="dotted", weight=1 ]
Ref -> Track [ style="dotted", weight=1 ]
SearchResult -> Artist [ label="has 0..n" ]
SearchResult -> Album [ label="has 0..n" ]
SearchResult -> Track [ label="has 0..n" ]
Playlist -> Track [ label="has 0..n", weight=2 ]
Track -> Album [ label="has 0..1", weight=10 ]
Track -> Artist [ label="has 0..n", weight=10 ]
Album -> Artist [ label="has 0..n", weight=10 ]
Image
SearchResult -> Artist [ label="has 0..n", weight=1 ]
SearchResult -> Album [ label="has 0..n", weight=1 ]
SearchResult -> Track [ label="has 0..n", weight=1 ]
TlTrack -> Track [ label="has 1", weight=20 ]
Data model API
==============
.. automodule:: mopidy.models
.. module:: mopidy.models
:synopsis: Data model API
:members:
.. autoclass:: mopidy.models.Ref
.. autoclass:: mopidy.models.Track
.. autoclass:: mopidy.models.Album
.. autoclass:: mopidy.models.Artist
.. autoclass:: mopidy.models.Playlist
.. autoclass:: mopidy.models.Image
.. autoclass:: mopidy.models.TlTrack
.. autoclass:: mopidy.models.SearchResult
Data model helpers
==================
.. autoclass:: mopidy.models.ImmutableObject
.. autoclass:: mopidy.models.ModelJSONEncoder
.. autofunction:: mopidy.models.model_json_decoder

View File

@ -14,7 +14,7 @@ our Git repository.
.. include:: ../AUTHORS
If you already enjoy Mopidy, or don't enjoy it and want to help us making
Mopidy better, the best way to do so is to contribute back to the community.
You can contribute code, documentation, tests, bug reports, or help other
users, spreading the word, etc. See :ref:`contributing` for a head start.
If want to help us making Mopidy better, the best way to do so is to contribute
back to the community, either through code, documentation, tests, bug reports,
or by helping other users, spreading the word, etc. See :ref:`contributing` for
a head start.

View File

@ -5,6 +5,463 @@ Changelog
This changelog is used to track all major changes to Mopidy.
v1.0.0 (2015-03-25)
===================
Three months after our fifth anniversary, Mopidy 1.0 is finally here!
Since the release of 0.19, we've closed or merged approximately 140 issues and
pull requests through more than 600 commits by a record high 19 extraordinary
people, including seven newcomers. Thanks to :ref:`everyone <authors>` who has
:ref:`contributed <contributing>`!
For the longest time, the focus of Mopidy 1.0 was to be another incremental
improvement, to be numbered 0.20. The result is still very much an incremental
improvement, with lots of small and larger improvements across Mopidy's
functionality.
The major features of Mopidy 1.0 are:
- :ref:`Semantic Versioning <versioning>`. We promise to not break APIs before
Mopidy 2.0. A Mopidy extension working with Mopidy 1.0 should continue to
work with all Mopidy 1.x releases.
- Preparation work to ease migration to a cleaned up and leaner core API in
Mopidy 2.0, and to give us some of the benefits of the cleaned up core API
right away.
- Preparation work to enable gapless playback in an upcoming 1.x release.
Dependencies
------------
Since the previous release there are no changes to Mopidy's dependencies.
However, porting from GStreamer 0.10 to 1.x and support for running Mopidy with
Python 3.4+ is not far off on our roadmap.
Core API
--------
In the API used by all frontends and web extensions there is lots of methods
and arguments that are now deprecated in preparation for the next major
release. With the exception of some internals that leaked out in the playback
controller, no core APIs have been removed in this release. In other words,
most clients should continue to work unchanged when upgrading to Mopidy 1.0.
Though, it is strongly encouraged to review any use of the deprecated parts of
the API as those parts will be removed in Mopidy 2.0.
- **Deprecated:** Deprecate all Python properties in the core API. The
previously undocumented getter and setter methods are now the official API.
This aligns the Python API with the WebSocket/JavaScript API. Python
frontends needs to be updated. WebSocket/JavaScript API users are not
affected. (Fixes: :issue:`952`)
- Add :class:`mopidy.core.HistoryController` which keeps track of what tracks
have been played. (Fixes: :issue:`423`, :issue:`1056`, PR: :issue:`803`,
:issue:`1063`)
- Add :class:`mopidy.core.MixerController` which keeps track of volume and
mute. (Fixes: :issue:`962`)
Core library controller
~~~~~~~~~~~~~~~~~~~~~~~
- **Deprecated:** :meth:`mopidy.core.LibraryController.find_exact`. Use
:meth:`mopidy.core.LibraryController.search` with the ``exact`` keyword
argument set to :class:`True`.
- **Deprecated:** The ``uri`` argument to
:meth:`mopidy.core.LibraryController.lookup`. Use new ``uris`` keyword
argument instead.
- Add ``exact`` keyword argument to
:meth:`mopidy.core.LibraryController.search`.
- Add ``uris`` keyword argument to :meth:`mopidy.core.LibraryController.lookup`
which allows for simpler lookup of multiple URIs. (Fixes: :issue:`1008`, PR:
:issue:`1047`)
- Updated :meth:`mopidy.core.LibraryController.search` and
:meth:`mopidy.core.LibraryController.find_exact` to normalize and warn about
malformed queries from clients. (Fixes: :issue:`1067`, PR: :issue:`1073`)
- Add :meth:`mopidy.core.LibraryController.get_distinct` for getting unique
values for a given field. (Fixes: :issue:`913`, PR: :issue:`1022`)
- Add :meth:`mopidy.core.LibraryController.get_images` for looking up images
for any URI that is known to the backends. (Fixes :issue:`973`, PR:
:issue:`981`, :issue:`992` and :issue:`1013`)
Core playlist controller
~~~~~~~~~~~~~~~~~~~~~~~~
- **Deprecated:** :meth:`mopidy.core.PlaylistsController.get_playlists`. Use
:meth:`~mopidy.core.PlaylistsController.as_list` and
:meth:`~mopidy.core.PlaylistsController.get_items` instead. (Fixes:
:issue:`1057`, PR: :issue:`1075`)
- **Deprecated:** :meth:`mopidy.core.PlaylistsController.filter`. Use
:meth:`~mopidy.core.PlaylistsController.as_list` and filter yourself.
- Add :meth:`mopidy.core.PlaylistsController.as_list`. (Fixes: :issue:`1057`,
PR: :issue:`1075`)
- Add :meth:`mopidy.core.PlaylistsController.get_items`. (Fixes: :issue:`1057`,
PR: :issue:`1075`)
Core tracklist controller
~~~~~~~~~~~~~~~~~~~~~~~~~
- **Removed:** The following methods were documented as internal. They are now
fully private and unavailable outside the core actor. (Fixes: :issue:`1058`,
PR: :issue:`1062`)
- :meth:`mopidy.core.TracklistController.mark_played`
- :meth:`mopidy.core.TracklistController.mark_playing`
- :meth:`mopidy.core.TracklistController.mark_unplayable`
- Add ``uris`` argument to :meth:`mopidy.core.TracklistController.add` which
allows for simpler addition of multiple URIs to the tracklist. (Fixes:
:issue:`1060`, PR: :issue:`1065`)
Core playback controller
~~~~~~~~~~~~~~~~~~~~~~~~
- **Removed:** Remove several internal parts that were leaking into the public
API and was never intended to be used externally. (Fixes: :issue:`1070`, PR:
:issue:`1076`)
- :meth:`mopidy.core.PlaybackController.change_track` is now internal.
- Removed ``on_error_step`` keyword argument from
:meth:`mopidy.core.PlaybackController.play`
- Removed ``clear_current_track`` keyword argument to
:meth:`mopidy.core.PlaybackController.stop`.
- Made the following event triggers internal:
- :meth:`mopidy.core.PlaybackController.on_end_of_track`
- :meth:`mopidy.core.PlaybackController.on_stream_changed`
- :meth:`mopidy.core.PlaybackController.on_tracklist_changed`
- :meth:`mopidy.core.PlaybackController.set_current_tl_track` is now
internal.
- **Deprecated:** The old methods on :class:`mopidy.core.PlaybackController`
for volume and mute management have been deprecated. Use
:class:`mopidy.core.MixerController` instead. (Fixes: :issue:`962`)
- When seeking while paused, we no longer change to playing. (Fixes:
:issue:`939`, PR: :issue:`1018`)
- Changed :meth:`mopidy.core.PlaybackController.play` to take the return value
from :meth:`mopidy.backend.PlaybackProvider.change_track` into account when
determining the success of the :meth:`~mopidy.core.PlaybackController.play`
call. (PR: :issue:`1071`)
- Add :meth:`mopidy.core.Listener.stream_title_changed` and
:meth:`mopidy.core.PlaybackController.get_stream_title` for letting clients
know about the current title in streams. (PR: :issue:`938`, :issue:`1030`)
Backend API
-----------
In the API implemented by all backends there have been way fewer but somewhat
more drastic changes with some methods removed and new ones being required for
certain functionality to continue working. Most backends were already updated to
be compatible with Mopidy 1.0 before the release. New versions of the backends
will be released shortly after Mopidy itself.
Backend library providers
~~~~~~~~~~~~~~~~~~~~~~~~~
- **Removed:** Remove :meth:`mopidy.backend.LibraryProvider.find_exact`.
- Add an ``exact`` keyword argument to
:meth:`mopidy.backend.LibraryProvider.search` to replace the old
:meth:`~mopidy.backend.LibraryProvider.find_exact` method.
Backend playlist providers
~~~~~~~~~~~~~~~~~~~~~~~~~~
- **Removed:** Remove default implementation of
:attr:`mopidy.backend.PlaylistsProvider.playlists`. This is potentially
backwards incompatible. (PR: :issue:`1046`)
- Changed the API for :class:`mopidy.backend.PlaylistsProvider`. Note that this
change is **not** backwards compatible. These changes are important to reduce
the Mopidy startup time. (Fixes: :issue:`1057`, PR: :issue:`1075`)
- Add :meth:`mopidy.backend.PlaylistsProvider.as_list`.
- Add :meth:`mopidy.backend.PlaylistsProvider.get_items`.
- Remove :attr:`mopidy.backend.PlaylistsProvider.playlists` property.
Backend playback providers
~~~~~~~~~~~~~~~~~~~~~~~~~~
- Changed the API for :class:`mopidy.backend.PlaybackProvider`. Note that this
change is **not** backwards compatible for certain backends. These changes
are crucial to adding gapless in one of the upcoming releases.
(Fixes: :issue:`1052`, PR: :issue:`1064`)
- :meth:`mopidy.backend.PlaybackProvider.translate_uri` has been added. It is
strongly recommended that all backends migrate to using this API for
translating "Mopidy URIs" to real ones for playback.
- The semantics and signature of :meth:`mopidy.backend.PlaybackProvider.play`
has changed. The method is now only used to set the playback state to
playing, and no longer takes a track.
Backends must migrate to
:meth:`mopidy.backend.PlaybackProvider.translate_uri` or
:meth:`mopidy.backend.PlaybackProvider.change_track` to continue working.
- :meth:`mopidy.backend.PlaybackProvider.prepare_change` has been added.
Models
------
- Add :class:`mopidy.models.Image` model to be returned by
:meth:`mopidy.core.LibraryController.get_images`. (Part of :issue:`973`)
- Change the semantics of :attr:`mopidy.models.Track.last_modified` to be
milliseconds instead of seconds since Unix epoch, or a simple counter,
depending on the source of the track. This makes it match the semantics of
:attr:`mopidy.models.Playlist.last_modified`. (Fixes: :issue:`678`, PR:
:issue:`1036`)
Commands
--------
- Make the ``mopidy`` command print a friendly error message if the
:mod:`gobject` Python module cannot be imported. (Fixes: :issue:`836`)
- Add support for repeating the :option:`-v <mopidy -v>` argument four times
to set the log level for all loggers to the lowest possible value, including
log records at levels lower than ``DEBUG`` too.
- Add path to the current ``mopidy`` executable to the output of ``mopidy
deps``. This make it easier to see that a user is using pip-installed Mopidy
instead of APT-installed Mopidy without asking for ``which mopidy`` output.
Configuration
-------------
- Add support for the log level value ``all`` to the loglevels configurations.
This can be used to show absolutely all log records, including those at
custom levels below ``DEBUG``.
- Add debug logging of unknown sections. (Fixes: :issue:`694`, PR: :issue:`1002`)
Logging
-------
- Add custom log level ``TRACE`` (numerical level 5), which can be used by
Mopidy and extensions to log at an even more detailed level than ``DEBUG``.
- Add support for per logger color overrides. (Fixes: :issue:`808`, PR:
:issue:`1005`)
Local backend
-------------
- Improve error logging for scanner. (Fixes: :issue:`856`, PR: :issue:`874`)
- Add symlink support with loop protection to file finder. (Fixes:
:issue:`858`, PR: :issue:`874`)
- Add ``--force`` option for ``mopidy local scan`` for forcing a full rescan of
the library. (Fixes: :issue:`910`, PR: :issue:`1010`)
- Stop ignoring ``offset`` and ``limit`` in searches when using the default
JSON backed local library. (Fixes: :issue:`917`, PR: :issue:`949`)
- Removed double triggering of ``playlists_loaded`` event.
(Fixes: :issue:`998`, PR: :issue:`999`)
- Cleanup and refactoring of local playlist code. Preserves playlist names
better and fixes bug in deletion of playlists. (Fixes: :issue:`937`,
PR: :issue:`995` and rebased into :issue:`1000`)
- Sort local playlists by name. (Fixes: :issue:`1026`, PR: :issue:`1028`)
- Moved playlist support out to a new extension, :ref:`ext-m3u`.
- *Deprecated:* The config value :confval:`local/playlists_dir` is no longer in
use and can be removed from your config.
Local library API
~~~~~~~~~~~~~~~~~
- Implementors of :meth:`mopidy.local.Library.lookup` should now return a list
of :class:`~mopidy.models.Track` instead of a single track, just like the
other ``lookup()`` methods in Mopidy. For now, returning a single track will
continue to work. (PR: :issue:`840`)
- Add support for giving local libraries direct access to tags and duration.
(Fixes: :issue:`967`)
- Add :meth:`mopidy.local.Library.get_images` for looking up images
for local URIs. (Fixes: :issue:`1031`, PR: :issue:`1032` and :issue:`1037`)
Stream backend
--------------
- Add support for HTTP proxies when doing initial metadata lookup for a stream.
(Fixes :issue:`390`, PR: :issue:`982`)
- Add basic tests for the stream library provider.
M3U backend
-----------
- Mopidy-M3U is a new bundled backend. It provides the same M3U support as was
previously part of the local backend. See :ref:`m3u-migration` for how to
migrate your local playlists to work with the M3U backend. (Fixes:
:issue:`1054`, PR: :issue:`1066`)
- In playlist names, replace "/", which are illegal in M3U file names,
with "|". (PR: :issue:`1084`)
MPD frontend
------------
- Add support for blacklisting MPD commands. This is used to prevent clients
from using ``listall`` and ``listallinfo`` which recursively lookup the entire
"database". If you insist on using a client that needs these commands change
:confval:`mpd/command_blacklist`.
- Start setting the ``Name`` field with the stream title when listening to
radio streams. (Fixes: :issue:`944`, PR: :issue:`1030`)
- Enable browsing of artist references, in addition to albums and playlists.
(PR: :issue:`884`)
- Switch the ``list`` command over to using the new method
:meth:`mopidy.core.LibraryController.get_distinct` for increased performance.
(Fixes: :issue:`913`)
- In stored playlist names, replace "/", which are illegal, with "|" instead of
a whitespace. Pipes are more similar to forward slash.
- Share a single mapping between names and URIs across all MPD sessions. (Fixes:
:issue:`934`, PR: :issue:`968`)
- Add support for ``toggleoutput`` command. (PR: :issue:`1015`)
- The ``mixrampdb`` and ``mixrampdelay`` commands are now known to Mopidy, but
are not implemented. (PR: :issue:`1015`)
- Fix crash on socket error when using a locale causing the exception's error
message to contain characters not in ASCII. (Fixes: issue:`971`, PR:
:issue:`1044`)
HTTP frontend
-------------
- **Deprecated:** Deprecated the :confval:`http/static_dir` config. Please make
your web clients pip-installable Mopidy extensions to make it easier to
install for end users.
- Prevent a race condition in WebSocket event broadcasting from crashing the
web server. (PR: :issue:`1020`)
Mixers
------
- Add support for disabling volume control in Mopidy entirely by setting the
configuration :confval:`audio/mixer` to ``none``. (Fixes: :issue:`936`, PR:
:issue:`1015`, :issue:`1035`)
Audio
-----
- **Removed:** Support for visualizers and the :confval:`audio/visualizer`
config value. The feature was originally added as a workaround for all the
people asking for ncmpcpp visualizer support, and since we could get it
almost for free thanks to GStreamer. But, this feature did never make sense
for a server such as Mopidy.
- **Deprecated:** Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`.
Pass a :class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` to end
the stream. This should only affect Mopidy-Spotify.
- Add :meth:`mopidy.audio.AudioListener.tags_changed`. Notifies core when new
tags are found.
- Add :meth:`mopidy.audio.Audio.get_current_tags` for looking up the current
tags of the playing media.
- Internal code cleanup within audio subsystem:
- Started splitting audio code into smaller better defined pieces.
- Improved GStreamer related debug logging.
- Provide better error messages for missing plugins.
- Add foundation for trying to re-add multiple output support.
- Add internal helper for converting GStreamer data types to Python.
- Reduce scope of audio scanner to just find tags and duration. Modification
time, URI and minimum length handling are now outside of this class.
- Update scanner to operate with milliseconds for duration.
- Update scanner to use a custom source, typefind and decodebin. This allows
us to detect playlists before we try to decode them.
- Refactored scanner to create a new pipeline per track, this is needed as
reseting decodebin is much slower than tearing it down and making a fresh
one.
- Move and rename helper for converting tags to tracks.
- Ignore albums without a name when converting tags to tracks.
- Support UTF-8 in M3U playlists. (Fixes: :issue:`853`)
- Add workaround for volume not persisting across tracks on OS X.
(Issue: :issue:`886`, PR: :issue:`958`)
- Improved missing plugin error reporting in scanner. (PR: :issue:`1033`)
- Introduced a new return type for the scanner, a named tuple with ``uri``,
``tags``, ``duration``, ``seekable`` and ``mime``. (PR: :issue:`1033`)
- Added support for checking if the media is seekable, and getting the initial
MIME type guess. (PR: :issue:`1033`)
Mopidy.js client library
------------------------
This version has been released to npm as Mopidy.js v0.5.0.
- Reexport When.js library as ``Mopidy.when``, to make it easily available to
users of Mopidy.js. (Fixes: :js:`1`)
- Default to ``wss://`` as the WebSocket protocol if the page is hosted on
``https://``. This has no effect if the ``webSocketUrl`` setting is
specified. (Pull request: :js:`2`)
- Upgrade dependencies.
Development
-----------
- Add new :ref:`contribution guidelines <contributing>`.
- Add new :ref:`development guide <devenv>`.
- Speed up event emitting.
- Changed test runner from nose to py.test. (PR: :issue:`1024`)
v0.19.5 (2014-12-23)
====================
@ -475,6 +932,7 @@ guys. Thanks to everyone that has contributed!
- The dummy backend used for testing many frontends have moved from
:mod:`mopidy.backends.dummy` to :mod:`mopidy.backend.dummy`.
(PR: :issue:`984`)
**Commands**

View File

@ -30,11 +30,11 @@ menu, including the official Spotify player and Mopidy.
If you install Mopidy from apt.mopidy.com, the sound menu should work out of
the box. If you install Mopidy in any other way, you need to make sure that the
file located at ``data/mopidy.desktop`` in the Mopidy git repo is installed as
``/usr/share/applications/mopidy.desktop``, and that the properties ``TryExec``
and ``Exec`` in the file points to an existing executable file, preferably your
Mopidy executable. If this isn't in place, the sound menu will not detect that
Mopidy is running.
file located at ``extra/desktop/mopidy.desktop`` in the Mopidy git repo is
installed as ``/usr/share/applications/mopidy.desktop``, and that the
properties ``TryExec`` and ``Exec`` in the file points to an existing
executable file, preferably your Mopidy executable. If this isn't in place, the
sound menu will not detect that Mopidy is running.
Next, Mopidy's MPRIS frontend must be running for the sound menu to be able to
control Mopidy. The frontend is enabled by default, so as long as you have all

View File

@ -37,15 +37,13 @@ There are two ways Mopidy can be made available as an UPnP MediaRenderer:
Using Mopidy-MPRIS and Rygel, or using Mopidy-MPD and upmpdcli.
.. _upmpdcli:
upmpdcli
--------
`upmpdcli <http://www.lesbonscomptes.com/upmpdcli/>`_ is recommended, since it
is easier to setup, and offers `OpenHome <http://www.openhome.org> ohMedia`_
compatibility. upmpdcli exposes a UPnP MediaRenderer to the network, while
using the MPD protocol to control Mopidy.
is easier to setup, and offers `OpenHome
<http://www.openhome.org/wiki/OhMedia>`_ compatibility. upmpdcli exposes a UPnP
MediaRenderer to the network, while using the MPD protocol to control Mopidy.
1. Install upmpdcli. On Debian/Ubuntu::
@ -68,8 +66,6 @@ using the MPD protocol to control Mopidy.
4. A UPnP renderer should be available now.
.. _rygel:
Rygel
-----

View File

@ -43,7 +43,7 @@ Options
.. cmdoption:: --verbose, -v
Show more output. Repeat up to 3 times for even more.
Show more output. Repeat up to four times for even more.
.. cmdoption:: --save-debug-log

View File

@ -2,7 +2,7 @@
"""Mopidy documentation build configuration file"""
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import os
import sys
@ -34,11 +34,13 @@ class Mock(object):
elif name == 'get_user_config_dir':
# glib.get_user_config_dir()
return str
elif (name[0] == name[0].upper()
elif (name[0] == name[0].upper() and
# gst.Caps
not name.startswith('Caps') and
# gst.PadTemplate
and not name.startswith('PadTemplate')
not name.startswith('PadTemplate') and
# dbus.String()
and not name == 'String'):
not name == 'String'):
return type(name, (), {})
else:
return Mock()
@ -52,6 +54,7 @@ MOCK_MODULES = [
'glib',
'gobject',
'gst',
'gst.pbutils',
'pygst',
'pykka',
'pykka.actor',
@ -111,6 +114,9 @@ modindex_common_prefix = ['mopidy.']
# -- Options for HTML output --------------------------------------------------
# 'sphinx_rtd_theme' is bundled with Sphinx 1.3, which we don't have when
# building the docs as part of the Debian packages on e.g. Debian wheezy.
# html_theme = 'sphinx_rtd_theme'
html_theme = 'default'
html_theme_path = ['_themes']
html_static_path = ['_static']
@ -154,6 +160,7 @@ man_pages = [
extlinks = {
'issue': ('https://github.com/mopidy/mopidy/issues/%s', '#'),
'commit': ('https://github.com/mopidy/mopidy/commit/%s', 'commit '),
'js': ('https://github.com/mopidy/mopidy.js/issues/%s', 'mopidy.js#'),
'mpris': (
'https://github.com/mopidy/mopidy-mpris/issues/%s', 'mopidy-mpris#'),
'discuss': ('https://discuss.mopidy.com/t/%s', 'discuss.mopidy.com/t/'),

View File

@ -70,6 +70,8 @@ Audio configuration
will affect the audio volume if you're streaming the audio from Mopidy
through Shoutcast.
If you want to disable audio mixing set the value to ``none``.
If you want to use a hardware mixer, you need to install a Mopidy extension
which integrates with your sound subsystem. E.g. for ALSA, install
`Mopidy-ALSAMixer <https://github.com/mopidy/mopidy-alsamixer>`_.
@ -93,17 +95,6 @@ Audio configuration
``gst-inspect-0.10`` to see what output properties can be set on the sink.
For example: ``gst-inspect-0.10 shout2send``
.. confval:: audio/visualizer
Visualizer to use.
Can be left blank if no visualizer is desired. Otherwise this expects a
GStreamer visualizer. Typical values are ``monoscope``, ``goom``,
``goom2k1`` or one of the `libvisual`_ visualizers.
.. _libvisual: http://gstreamer.freedesktop.org/data/doc/gstreamer/head/gst-plugins-base-plugins/html/gst-plugins-base-plugins-plugin-libvisual.html
Logging configuration
---------------------
@ -142,6 +133,14 @@ Logging configuration
level to use for that logger, one of ``debug``, ``info``, ``warning``,
``error``, or ``critical``.
.. confval:: logcolors/*
The ``logcolors`` config section can be used to change the log color for
specific parts of Mopidy during development or debugging. Each key in the
config section should match the name of a logger. The value is the color
to use for that logger, one of ``black``, ``red``, ``green``, ``yellow``,
``blue``, ``magenta``, ``cyan`` or ``white``.
.. _the Python logging docs: http://docs.python.org/2/library/logging.config.html

View File

@ -4,147 +4,125 @@
Contributing
************
If you are thinking about making Mopidy better, or you just want to hack on it,
thats great. Here are some tips to get you started.
If you want to contribute to Mopidy, here are some tips to get you started.
Getting started
===============
.. _asking-questions:
#. Make sure you have a `GitHub account <https://github.com/signup/free>`_.
Asking questions
================
#. `Submit <https://github.com/mopidy/mopidy/issues/new>`_ a ticket for your
issue, assuming one does not already exist. Clearly describe the issue
including steps to reproduce when it is a bug.
Please get in touch with us in one of these ways when requesting help with
Mopidy and its extensions:
#. Fork the repository on GitHub.
- Our discussion forum: `discuss.mopidy.com <https://discuss.mopidy.com>`_.
Just sign in and fire away.
- Our IRC channel: `#mopidy <https://webchat.freenode.net/?channels=#mopidy>`_
on `irc.freenode.net <http://freenode.net>`_,
with public `searchable logs <https://botbot.me/freenode/mopidy/>`_. Be
prepared to hang around for a while, as we're not always around to answer
straight away.
Before asking for help, it might be worth your time to read the
:ref:`troubleshooting` page, both so you might find a solution to your problem
but also to be able to provide useful details when asking for help.
Making changes
==============
Helping users
=============
#. Clone your fork on GitHub to your computer.
#. Consider making a Python `virtualenv <http://www.virtualenv.org/>`_ for
Mopidy development to wall of Mopidy and it's dependencies from the rest of
your system. If you do so, create the virtualenv with the
``--system-site-packages`` flag so that Mopidy can use globally installed
dependencies like GStreamer. If you don't use a virtualenv, you may need to
run the following ``pip`` and ``python setup.py`` commands with ``sudo`` to
install stuff globally on your computer.
#. Install dependencies as described in the :ref:`installation` section.
#. Install additional development dependencies::
pip install -r dev-requirements.txt
#. Checkout a new branch (usually based on ``develop``) and name it accordingly
to what you intend to do.
- Features get the prefix ``feature/``
- Bug fixes get the prefix ``fix/``
- Improvements to the documentation get the prefix ``docs/``
If you want to contribute to Mopidy, a great place to start is by helping other
users on IRC and in the discussion forum. This is a contribution we value
highly. As more people help with user support, new users get faster and better
help. For your own benefit, you'll quickly learn what users find confusing,
difficult or lacking, giving you some ideas for where you may contribute
improvements, either to code or documentation. Lastly, this may also free up
time for other contributors to spend more time on fixing bugs or implementing
new features.
.. _run-from-git:
.. _issue-guidelines:
Running Mopidy from Git
Issue guidelines
================
#. If you need help, see :ref:`asking-questions` above. The GitHub issue
tracker is not a support forum.
#. If you are not sure if what you're experiencing is a bug or not, post in the
`discussion forum <https://discuss.mopidy.com>`__ first to verify that it's
a bug.
#. If you are sure that you've found a bug or have a feature request, check if
there's already an issue in the `issue tracker
<https://github.com/mopidy/mopidy/issues>`_. If there is, see if there is
anything you can add to help reproduce or fix the issue.
#. If there is no exising issue matching your bug or feature request, create a
`new issue <https://github.com/mopidy/mopidy/issues/new>`_. Please include
as much relevant information as possible. If it's a bug, including how to
reproduce the bug and any relevant logs or error messages.
Pull request guidelines
=======================
If you want to hack on Mopidy, you should run Mopidy directly from the Git
repo.
#. Before spending any time on making a pull request:
#. Go to the Git repo root::
- If it's a bug, :ref:`file an issue <issue-guidelines>`.
cd mopidy/
- If it's an enhancement, discuss it with other Mopidy developers first,
either in a GitHub issue, on the discussion forum, or on IRC. Making sure
your ideas and solutions are aligned with other contributors greatly
increases the odds of your pull request being quickly accepted.
#. To get a ``mopidy`` executable and register all bundled extensions with
setuptools, run::
#. Create a new branch, based on the ``develop`` branch, for every feature or
bug fix. Keep branches small and on topic, as that makes them far easier to
review. We often use the following naming convention for branches:
python setup.py develop
- Features get the prefix ``feature/``, e.g.
``feature/track-last-modified-as-ms``.
It still works to run ``python mopidy`` directly on the ``mopidy`` Python
package directory, but if you have never run ``python setup.py develop`` the
extensions bundled with Mopidy isn't registered with setuptools, so Mopidy
will start without any frontends or backends, making it quite useless.
- Bug fixes get the prefix ``fix/``, e.g. ``fix/902-consume-track-on-next``.
#. Now you can run the Mopidy command, and it will run using the code
in the Git repo::
- Improvements to the documentation get the prefix ``docs/``, e.g.
``docs/add-ext-mopidy-spotify-tunigo``.
mopidy
#. Follow the :ref:`code style <codestyle>`, especially make sure the
``flake8`` linter does not complain about anything. Travis CI will check
that your pull request is "flake8 clean". See :ref:`code-linting`.
If you do any changes to the code, you'll just need to restart ``mopidy``
to see the changes take effect.
#. Include tests for any new feature or substantial bug fix. See
:ref:`running-tests`.
#. Include documentation for any new feature. See :ref:`writing-docs`.
Testing
=======
#. Feel free to include a changelog entry in your pull request. The changelog
is in :file:`docs/changelog.rst`.
Mopidy has quite good test coverage, and we would like all new code going into
Mopidy to come with tests.
#. Write good commit messages.
#. To run all tests, go to the project directory and run::
- Follow the template "topic: description" for the first line of the commit
message, e.g. "mpd: Switch list command to using list_distinct". See the
commit history for inspiration.
nosetests
- Use the rest of the commit message to explain anything you feel isn't
obvious. It's better to have the details here than in the pull request
description, since the commit message will live forever.
To run tests with test coverage statistics::
- Write in the imperative, present tense: "add" not "added".
nosetests --with-coverage
For more inspiration, feel free to read these blog posts:
Test coverage statistics can also be viewed online at
`coveralls.io <https://coveralls.io/r/mopidy/mopidy>`_.
- `Writing Git commit messages
<http://365git.tumblr.com/post/3308646748/writing-git-commit-messages>`_
#. Always check the code for errors and style issues using flake8::
- `A Note About Git Commit Messages
<http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html>`_
flake8
- `On commit messages
<http://who-t.blogspot.ch/2009/12/on-commit-messages.html>`_
If successful, the command will not print anything at all.
#. Finally, there is the ultimate but a bit slower command. To run both tests,
docs build, and flake8 linting, run::
tox
This will run exactly the same tests as `Travis CI
<https://travis-ci.org/mopidy/mopidy>`_ runs for all our branches and pull
requests. If this command turns green, you can be quite confident that your
pull request will get the green flag from Travis as well, which is a
requirement for it to be merged.
Submitting changes
==================
- One branch per feature or fix. Keep branches small and on topic.
- Follow the :ref:`code style <codestyle>`, especially make sure ``flake8``
does not complain about anything.
- Write good commit messages. Here's three blog posts on how to do it right:
- `Writing Git commit messages
<http://365git.tumblr.com/post/3308646748/writing-git-commit-messages>`_
- `A Note About Git Commit Messages
<http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html>`_
- `On commit messages
<http://who-t.blogspot.ch/2009/12/on-commit-messages.html>`_
- Send a pull request to the ``develop`` branch. See the `GitHub pull request
docs <https://help.github.com/articles/using-pull-requests>`_ for help.
Additional resources
====================
- IRC channel: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
- `Issue tracker <https://github.com/mopidy/mopidy/issues>`_
- `Mailing List <https://groups.google.com/forum/?fromgroups=#!forum/mopidy>`_
- `GitHub documentation <https://help.github.com/>`_
#. Send a pull request to the ``develop`` branch. See the `GitHub pull request
docs <https://help.github.com/articles/using-pull-requests>`_ for help.

View File

@ -1,13 +1,20 @@
.. _debian:
**************
Debian package
**************
***************
Debian packages
***************
The Mopidy Debian package is available from `apt.mopidy.com
The Mopidy Debian package, ``mopidy``, is available from `apt.mopidy.com
<http://apt.mopidy.com/>`__ as well as from Debian, Ubuntu and other
Debian-based Linux distributions.
Some extensions are also available from all of these sources, while others,
like Mopidy-Spotify and its dependencies, are only available from
apt.mopidy.com. This may either be temporary until the package is uploaded to
Debian and with time propagates to the other distributions. It may also be more
long term, like in the Mopidy-Spotify case where there is uncertainities around
licensing and distribution of non-free packages.
Installation
============

593
docs/devenv.rst Normal file
View File

@ -0,0 +1,593 @@
.. _devenv:
***********************
Development environment
***********************
This page describes a common development setup for working with Mopidy and
Mopidy extensions. Of course, there may be other ways that work better for you
and the tools you use, but here's one recommended way to do it.
.. contents::
:local:
Initial setup
=============
The following steps help you get a good initial setup. They build on each other
to some degree, so if you're not very familiar with Python development it might
be wise to proceed in the order laid out here.
.. contents::
:local:
Install Mopidy the regular way
------------------------------
Install Mopidy the regular way. Mopidy has some non-Python dependencies which
may be tricky to install. Thus we recommend to always start with a full regular
Mopidy install, as described in :ref:`installation`. That is, if you're running
e.g. Debian, start with installing Mopidy from Debian packages.
Make a development workspace
----------------------------
Make a directory to be used as a workspace for all your Mopidy development::
mkdir ~/mopidy-dev
It will contain all the Git repositories you'll check out when working on
Mopidy and extensions.
Make a virtualenv
-----------------
Make a Python `virtualenv <https://virtualenv.pypa.io/>`_ for Mopidy
development. The virtualenv will wall off Mopidy and its dependencies from the
rest of your system. All development and installation of Python dependencies,
versions of Mopidy, and extensions are done inside the virtualenv. This way
your regular Mopidy install, which you set up in the first step, is unaffected
by your hacking and will always be working.
Most of us use the `virtualenvwrapper
<https://virtualenvwrapper.readthedocs.org/>`_ to ease working with
virtualenvs, so that's what we'll be using for the examples here. First,
install and setup virtualenvwrapper as described in their docs.
To create a virtualenv named ``mopidy`` which uses Python 2.7, allows access to
system-wide packages like GStreamer, and uses the Mopidy workspace directory as
the "project path", run::
mkvirtualenv -a ~/mopidy-dev --python `which python2.7` \
--system-site-packages mopidy
Now, each time you open a terminal and want to activate the ``mopidy``
virtualenv, run::
workon mopidy
This will both activate the ``mopidy`` virtualenv, and change the current
working directory to ``~/mopidy-dev``.
Clone the repo from GitHub
--------------------------
Once inside the virtualenv, it's time to clone the ``mopidy/mopidy`` Git repo
from GitHub::
git clone https://github.com/mopidy/mopidy.git
When you've cloned the ``mopidy`` Git repo, ``cd`` into it::
cd ~/mopidy-dev/mopidy/
With a fresh clone of the Git repo, you should start out on the ``develop``
branch. This is where all features for the next feature release land. To
confirm that you're on the right branch, run::
git branch
Install development tools
-------------------------
We use a number of Python development tools. The :file:`dev-requirements.txt`
file has comments describing what we use each dependency for, so we might just
as well include the file verbatim here:
.. literalinclude:: ../dev-requirements.txt
Install them all into the active virtualenv by running `pip
<https://pip.pypa.io/>`_::
pip install --upgrade -r dev-requirements.txt
To upgrade the tools in the future, just rerun the exact same command.
Install Mopidy from the Git repo
--------------------------------
Next up, we'll want to run Mopidy from the Git repo. There's two reasons for
this: first of all, it lets you easily change the source code, restart Mopidy,
and see the change take effect. Second, it's a convenient way to keep at the
bleeding edge, testing the latest developments in Mopidy itself or test some
extension against the latest Mopidy changes.
Assuming you're still inside the Git repo, use pip to install Mopidy from the
Git repo in an "editable" form::
pip install --editable .
This will not copy the source code into the virtualenv's ``site-packages``
directory, but instead create a link there pointing to the Git repo. Using
``cdsitepackages`` from virtualenvwrapper, we can quickly show that the
installed :file:`Mopidy.egg-link` file points back to the Git repo::
$ cdsitepackages
$ cat Mopidy.egg-link
/home/user/mopidy-dev/mopidy
.%
$
It will also create a ``mopidy`` executable inside the virtualenv that will
always run the latest code from the Git repo. Using another
virtualenvwrapper command, ``cdvirtualenv``, we can show that too::
$ cdvirtualenv
$ cat bin/mopidy
...
The executable should contain something like this, using :mod:`pkg_resources`
to look up Mopidy's "console script" entry point::
#!/home/user/virtualenvs/mopidy/bin/python2
# EASY-INSTALL-ENTRY-SCRIPT: 'Mopidy==0.19.5','console_scripts','mopidy'
__requires__ = 'Mopidy==0.19.5'
import sys
from pkg_resources import load_entry_point
if __name__ == '__main__':
sys.exit(
load_entry_point('Mopidy==0.19.5', 'console_scripts', 'mopidy')()
)
.. note::
It still works to run ``python mopidy`` directly on the
:file:`~/mopidy-dev/mopidy/mopidy/` Python package directory, but if
you don't run the ``pip install`` command above, the extensions bundled
with Mopidy will not be registered with :mod:`pkg_resources`, making Mopidy
quite useless.
Third, the ``pip install`` command will register the bundled Mopidy
extensions so that Mopidy may find them through :mod:`pkg_resources`. The
result of this can be seen in the Git repo, in a new directory called
:file:`Mopidy.egg-info`, which is ignored by Git. The
:file:`Mopidy.egg-info/entry_points.txt` file is of special interest as it
shows both how the above executable and the bundled extensions are connected to
the Mopidy source code:
.. code-block:: ini
[console_scripts]
mopidy = mopidy.__main__:main
[mopidy.ext]
http = mopidy.http:Extension
local = mopidy.local:Extension
mpd = mopidy.mpd:Extension
softwaremixer = mopidy.softwaremixer:Extension
stream = mopidy.stream:Extension
.. warning::
It's not uncommon to clean up in the Git repo now and then, e.g. by running
``git clean``.
If you do this, then the :file:`Mopidy.egg-info` directory will be removed,
and :mod:`pkg_resources` will no longer know how to locate the "console
script" entry point or the bundled Mopidy extensions.
The fix is simply to run the install command again::
pip install --editable .
Finally, we can go back to the workspace, again using a virtualenvwrapper
tool::
cdproject
.. _running-from-git:
Running Mopidy from Git
=======================
As long as the virtualenv is activated, you can start Mopidy from any
directory. Simply run::
mopidy
To stop it again, press :kbd:`Ctrl+C`.
Every time you change code in Mopidy or an extension and want to see it
live, you must restart Mopidy.
If you want to iterate quickly while developing, it may sound a bit tedious to
restart Mopidy for every minor change. Then it's useful to have tests to
exercise your code...
.. _running-tests:
Running tests
=============
Mopidy has quite good test coverage, and we would like all new code going into
Mopidy to come with tests.
.. contents::
:local:
Test it all
-----------
You need to know at least one command; the one that runs all the tests::
tox
This will run exactly the same tests as `Travis CI
<https://travis-ci.org/mopidy/mopidy>`_ runs for all our branches and pull
requests. If this command turns green, you can be quite confident that your
pull request will get the green flag from Travis as well, which is a
requirement for it to be merged.
As this is the ultimate test command, it's also the one taking the most time to
run; up to a minute, depending on your system. But, if you have patience, this
is all you need to know. Always run this command before pushing your changes to
GitHub.
If you take a look at the tox config file, :file:`tox.ini`, you'll see that tox
runs tests in multiple environments, including a ``flake8`` environment that
lints the source code for issues and a ``docs`` environment that tests that the
documentation can be built. You can also limit tox to just test specific
environments using the ``-e`` option, e.g. to run just unit tests::
tox -e py27
To learn more, see the `tox documentation <http://tox.readthedocs.org/>`_ .
Running unit tests
------------------
Under the hood, ``tox -e py27`` will use `pytest <http://pytest.org/>`_ as the
test runner. We can also use it directly to run all tests::
py.test
py.test has lots of possibilities, so you'll have to dive into their docs and
plugins to get full benefit from it. To get you interested, here are some
examples.
We can limit to just tests in a single directory to save time::
py.test tests/http/
With the help of the pytest-xdist plugin, we can run tests with four Python
processes in parallel, which usually cuts the test time in half or more::
py.test -n 4
Another useful feature from pytest-xdist, is the possiblity to stop on the
first test failure, watch the file system for changes, and then rerun the
tests. This makes for a very quick code-test cycle::
py.test -f # or --looponfail
With the help of the pytest-cov plugin, we can get a report on what parts of
the given module, ``mopidy`` in this example, are covered by the test suite::
py.test --cov=mopidy --cov-report=term-missing
.. note::
Up to date test coverage statistics can also be viewed online at
`coveralls.io <https://coveralls.io/r/mopidy/mopidy>`_.
If we want to speed up the test suite, we can even get a list of the ten
slowest tests::
py.test --durations=10
By now, you should be convinced that running py.test directly during
development can be very useful.
Continuous integration
----------------------
Mopidy uses the free service `Travis CI <https://travis-ci.org/mopidy/mopidy>`_
for automatically running the test suite when code is pushed to GitHub. This
works both for the main Mopidy repo, but also for any forks. This way, any
contributions to Mopidy through GitHub will automatically be tested by Travis
CI, and the build status will be visible in the GitHub pull request interface,
making it easier to evaluate the quality of pull requests.
For each successful build, Travis submits code coverage data to `coveralls.io
<https://coveralls.io/r/mopidy/mopidy>`_. If you're out of work, coveralls might
help you find areas in the code which could need better test coverage.
In addition, we run a Jenkins CI server at https://ci.mopidy.com/ that runs all
tests on multiple platforms (Ubuntu, OS X, x86, arm) for every commit we push
to the ``develop`` branch in the main Mopidy repo on GitHub. Thus, new code
isn't tested by Jenkins before it is merged into the ``develop`` branch, which
is a bit late, but good enough to get broad testing before new code is
released.
.. _code-linting:
Style checking and linting
--------------------------
We're quite pedantic about :ref:`codestyle` and try hard to keep the Mopidy
code base a very clean and nice place to work in.
Luckily, you can get very far by using the `flake8
<http://flake8.readthedocs.org/>`_ linter to check your code for issues before
submitting a pull request. Mopidy passes all of flake8's checks, with only a
very few exceptions configured in :file:`setup.cfg`. You can either run the
``flake8`` tox environment, like Travis CI will do on your pull request::
tox -e flake8
Or you can run flake8 directly::
flake8
If successful, the command will not print anything at all.
.. note::
In some rare cases it doesn't make sense to listen to flake8's warnings. In
those cases, ignore the check by appending ``# noqa: <warning code>`` to
the source line that triggers the warning. The ``# noqa`` part will make
flake8 skip all checks on the line, while the warning code will help other
developers lookup what you are ignoring.
.. _writing-docs:
Writing documentation
=====================
To write documentation, we use `Sphinx <http://sphinx-doc.org/>`_. See their
site for lots of documentation on how to use Sphinx.
.. note::
To generate a few graphs which are part of the documentation, you need some
additional dependencies. You can install them from APT with::
sudo apt-get install python-pygraphviz graphviz
To build the documentation, go into the :file:`docs/` directory::
cd ~/mopidy-dev/mopidy/docs/
Then, to see all available build targets, run::
make
To generate an HTML version of the documentation, run::
make html
The generated HTML will be available at :file:`_build/html/index.html`. To open
it in a browser you can run either of the following commands, depending on your
OS::
xdg-open _build/html/index.html # Linux
open _build/html/index.html # OS X
The documentation at https://docs.mopidy.com/ is hosted by `Read the Docs
<https://www.readhtedocs.org/>`_, which automatically updates the documentation
when a change is pushed to the ``mopidy/mopidy`` repo at GitHub.
Working on extensions
=====================
Much of the above also applies to Mopidy extensions, though they're often a bit
simpler. They don't have documentation sites and their test suites are either
small and fast, or sadly missing entirely. Most of them use tox and flake8, and
py.test can be used to run their test suites.
.. contents::
:local:
Installing extensions
---------------------
As always, the ``mopidy`` virtualenv should be active when working on
extensions::
workon mopidy
Just like with non-development Mopidy installations, you can install extensions
using pip::
pip install Mopidy-Scrobbler
Installing an extension from its Git repo works the same way as with Mopidy
itself. First, go to the Mopidy workspace::
cdproject # or cd ~/mopidy-dev/
Clone the desired Mopidy extension::
git clone https://github.com/mopidy/mopidy-spotify.git
Change to the newly created extension directory::
cd mopidy-spotify/
Then, install the extension in "editable" mode, so that it can be imported from
anywhere inside the virtualenv and the extension is registered and discoverable
through :mod:`pkg_resources`::
pip install --editable .
Every extension will have a ``README.rst`` file. It may contain information
about extra dependencies required, development process, etc. Extensions usually
have a changelog in the readme file.
Upgrading extensions
--------------------
Extensions often have a much quicker life cycle than Mopidy itself, often with
daily releases in periods of active development. To find outdated extensions in
your virtualenv, you can run::
pip search mopidy
This will list all available Mopidy extensions and compare the installed
versions with the latest available ones.
To upgrade an extension installed with pip, simply use pip::
pip install --upgrade Mopidy-Scrobbler
To upgrade an extension installed from a Git repo, it's usually enough to pull
the new changes in::
cd ~/mopidy-dev/mopidy-spotify/
git pull
Of course, if you have local modifications, you'll need to stash these away on
a branch or similar first.
Depending on the changes to the extension, it may be necessary to update the
metadata about the extension package by installing it in "editable" mode
again::
pip install --editable .
Contribution workflow
=====================
Before you being, make sure you've read the :ref:`contributing` page and the
guidelines there. This section will focus more on the practical workflow.
For the examples, we're making a change to Mopidy. Approximately the same
workflow should work for most Mopidy extensions too.
.. contents::
:local:
Setting up Git remotes
----------------------
Assuming we already have a local Git clone of the upstream Git repo in
:file:`~/mopidy-dev/mopidy/`, we can run ``git remote -v`` to list the
configured remotes of the repo::
$ git remote -v
origin https://github.com/mopidy/mopidy.git (fetch)
origin https://github.com/mopidy/mopidy.git (push)
For clarity, we can rename the ``origin`` remote to ``upstream``::
$ git remote rename origin upstream
$ git remote -v
upstream https://github.com/mopidy/mopidy.git (fetch)
upstream https://github.com/mopidy/mopidy.git (push)
If you haven't already, `fork the repository
<https://help.github.com/articles/fork-a-repo/>`_ to your own GitHub account.
Then, add the new fork as a remote to your local clone::
git remote add myuser git@github.com:myuser/mopidy.git
The end result is that you have both the upstream repo and your own fork as
remotes::
$ git remote -v
myuser git@github.com:myuser/mopidy.git (fetch)
myuser git@github.com:myuser/mopidy.git (push)
upstream https://github.com/mopidy/mopidy.git (fetch)
upstream https://github.com/mopidy/mopidy.git (push)
Creating a branch
-----------------
Fetch the latest data from all remotes without affecting your working
directory::
git remote update
Now, we are ready to create and checkout a new branch off of the upstream
``develop`` branch for our work::
git checkout -b fix/666-crash-on-foo upstream/develop
Do the work, while remembering to adhere to code style, test the changes, make
necessary updates to the documentation, and making small commits with good
commit messages. All as described in :ref:`contributing` and elsewhere in
the :ref:`devenv` guide.
Creating a pull request
-----------------------
When everything is done and committed, push the branch to your fork on GitHub::
git push myuser fix/666-crash-on-foo
Go to the repository on GitHub where you want the change merged, in this case
https://github.com/mopidy/mopidy, and `create a pull request
<https://help.github.com/articles/creating-a-pull-request/>`_.
Updating a pull request
-----------------------
When the pull request is created, `Travis CI
<https://travis-ci.org/mopidy/mopidy>`__ will run all tests on it. If something
fails, you'll get notified by email. You might as well just fix the issues
right away, as we won't merge a pull request without a green Travis build. See
:ref:`running-tests` on how to run the same tests locally as Travis CI runs on
your pull request.
When you've fixed the issues, you can update the pull request simply by pushing
more commits to the same branch in your fork::
git push myuser fix/666-crash-on-foo
Likewise, when you get review comments from other developers on your pull
request, you're expected to create additional commits which addresses the
comments. Push them to your branch so that the pull request is updated.
.. note::
Setup the remote as the default push target for your branch::
git branch --set-upstream-to myuser/fix/666-crash-on-foo
Then you can push more commits without specifying the remote::
git push

View File

@ -90,6 +90,17 @@ Mopidy-Local
Bundled with Mopidy. See :ref:`ext-local`.
Mopidy-Local-Images
===================
https://github.com/tkem/mopidy-local-images
Extension which plugs into Mopidy-Local to allow Web clients access to
album art embedded in local media files. Not to be used on its own,
but acting as a proxy between ``mopidy local scan`` and the actual
local library provider being used.
Mopidy-Local-SQLite
===================

View File

@ -73,17 +73,21 @@ See :ref:`config` for general help on configuring Mopidy.
.. confval:: http/static_dir
**Deprecated:** This config is deprecated and will be removed in a future
version of Mopidy.
Which directory the HTTP server should serve at "/"
Change this to have Mopidy serve e.g. files for your JavaScript client.
"/mopidy" will continue to work as usual even if you change this setting.
"/mopidy" will continue to work as usual even if you change this setting,
but any other Mopidy webclient installed with pip to be served at
"/ext_name" will stop working if you set this config.
This config value isn't deprecated yet, but you're strongly encouraged to
make Mopidy extensions which use the the :ref:`http-server-api` to host
static files on Mopidy's web server instead of using
:confval:`http/static_dir`. That way, installation of your web client will
be a lot easier for your end users, and multiple web clients can easily
share the same web server.
You're strongly encouraged to make Mopidy extensions which use the the
:ref:`http-server-api` to host static files on Mopidy's web server instead
of using :confval:`http/static_dir`. That way, installation of your web
client will be a lot easier for your end users, and multiple web clients
can easily share the same web server.
.. confval:: http/zeroconf

View File

@ -86,6 +86,10 @@ See :ref:`config` for general help on configuring Mopidy.
Number of milliseconds before giving up scanning a file and moving on to
the next file.
.. confval:: local/scan_follow_symlinks
If we should follow symlinks found in :confval:`local/media_dir`
.. confval:: local/scan_flush_threshold
Number of tracks to wait before telling library it should try and store

BIN
docs/ext/local_images.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

55
docs/ext/m3u.rst Normal file
View File

@ -0,0 +1,55 @@
.. _ext-m3u:
**********
Mopidy-M3U
**********
Mopidy-M3U is an extension for reading and writing M3U playlists stored
on disk. It is bundled with Mopidy and enabled by default.
This backend handles URIs starting with ``m3u:``.
.. _m3u-migration:
Migrating from Mopidy-Local playlists
=====================================
Mopidy-M3U was split out of the Mopidy-Local extension in Mopidy 1.0. To
migrate your playlists from Mopidy-Local, simply move them from the
:confval:`local/playlists_dir` directory to the :confval:`m3u/playlists_dir`
directory. Assuming you have not changed the default config, run the following
commands to migrate::
mkdir -p ~/.local/share/mopidy/m3u/
mv ~/.local/share/mopidy/local/playlists/* ~/.local/share/mopidy/m3u/
Editing playlists
=================
There is a core playlist API in place for editing playlists. This is supported
by a few Mopidy clients, but not through Mopidy's MPD server yet.
It is possible to edit playlists by editing the M3U files located in the
:confval:`m3u/playlists_dir` directory, usually
:file:`~/.local/share/mopidy/m3u/`, by hand with a text editor. See `Wikipedia
<https://en.wikipedia.org/wiki/M3U>`__ for a short description of the quite
simple M3U playlist format.
Configuration
=============
See :ref:`config` for general help on configuring Mopidy.
.. literalinclude:: ../../mopidy/m3u/ext.conf
:language: ini
.. confval:: m3u/enabled
If the M3U extension should be enabled or not.
.. confval:: m3u/playlists_dir
Path to directory with M3U files.

BIN
docs/ext/mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@ -99,3 +99,10 @@ See :ref:`config` for general help on configuring Mopidy.
``$hostname`` and ``$port`` can be used in the name.
Set to an empty string to disable Zeroconf for MPD.
.. confval:: mpd/command_blacklist
List of MPD commands which are disabled by the server. By default this
setting blacklists ``listall`` and ``listallinfo``. These commands don't
fit well with many of Mopidy's backends and are better left disabled unless
you know what you are doing.

View File

@ -30,17 +30,39 @@ To install, run::
pip install Mopidy-API-Explorer
Mopidy-HTTP-Kuechenradio
=========================
Mopidy-Local-Images
===================
https://github.com/tkem/mopidy-http-kuechenradio
https://github.com/tkem/mopidy-local-images
A deliberately simple Mopidy Web client for mobile devices. Made with jQuery
Mobile by Thomas Kemmer.
Not a full-featured Web client, but rather a local library and Web
extension which allows other Web clients access to album art embedded
in local media files.
.. image:: /ext/local_images.jpg
:width: 640
:height: 480
To install, run::
pip install Mopidy-HTTP-Kuechenradio
pip install Mopidy-Local-Images
Mopidy-Mobile
=============
https://github.com/tkem/mopidy-mobile
A Mopidy Web client extension and hybrid mobile app, made with Ionic,
AngularJS and Apache Cordova by Thomas Kemmer.
.. image:: /ext/mobile.png
:width: 1024
:height: 606
To install, run::
pip install Mopidy-Mobile
Mopidy-Moped

View File

@ -159,7 +159,7 @@ class that will connect the rest of the dots.
::
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import re
from setuptools import setup, find_packages
@ -189,11 +189,6 @@ class that will connect the rest of the dots.
'Pykka >= 1.1',
'pysoundspot',
],
test_suite='nose.collector',
tests_require=[
'nose',
'mock >= 1.0',
],
entry_points={
'mopidy.ext': [
'soundspot = mopidy_soundspot:Extension',
@ -255,7 +250,7 @@ default config in documentation without duplicating it.
This is ``mopidy_soundspot/__init__.py``::
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import logging
import os
@ -312,12 +307,6 @@ This is ``mopidy_soundspot/__init__.py``::
from .backend import SoundspotBackend
registry.add('backend', SoundspotBackend)
# Register a custom GStreamer element
from .mixer import SoundspotMixer
gobject.type_register(SoundspotMixer)
gst.element_register(
SoundspotMixer, 'soundspotmixer', gst.RANK_MARGINAL)
# Or nothing to register e.g. command extension
pass
@ -421,17 +410,6 @@ examples, see the :ref:`http-server-api` docs or explore with
:ref:`http-explore-extension` extension.
Example GStreamer element
=========================
If you want to extend Mopidy's GStreamer pipeline with new custom GStreamer
elements, you'll need to register them in GStreamer before they can be used.
Basically, you just implement your GStreamer element in Python and then make
your :meth:`~mopidy.ext.Extension.setup` method register all your custom
GStreamer elements.
Running an extension
====================
@ -449,7 +427,7 @@ Python conventions
In general, it would be nice if Mopidy extensions followed the same
:ref:`codestyle` as Mopidy itself, as they're part of the same ecosystem. Among
other things, the code style guide explains why all the above examples start
with ``from __future__ import unicode_literals``.
with ``from __future__ import absolute_import, unicode_literals``.
Use of Mopidy APIs

View File

@ -94,6 +94,7 @@ Extensions
:maxdepth: 2
ext/local
ext/m3u
ext/stream
ext/http
ext/mpd
@ -132,10 +133,11 @@ Development
===========
.. toctree::
:maxdepth: 1
:maxdepth: 2
contributing
devtools
devenv
releasing
codestyle
extensiondev

View File

@ -14,14 +14,27 @@ If you are running Arch Linux, you can install Mopidy using the
To upgrade Mopidy to future releases, just upgrade your system using::
yaourt -Syu
#. Optional: If you want to use any Mopidy extensions, like Spotify support or
Last.fm scrobbling, AUR also has `packages for several Mopidy extensions
<https://aur.archlinux.org/packages/?K=mopidy>`_.
For a full list of available Mopidy extensions, including those not
installable from AUR, see :ref:`ext`.
yaourt -Syua
#. Finally, you need to set a couple of :doc:`config values </config>`, and
then you're ready to :doc:`run Mopidy </running>`.
Installing extensions
=====================
If you want to use any Mopidy extensions, like Spotify support or Last.fm
scrobbling, AUR also has `packages for lots of Mopidy extensions
<https://aur.archlinux.org/packages/?K=mopidy>`_.
You can also install any Mopidy extension directly from PyPI with ``pip``. To
list all the extensions available from PyPI, run::
pip search mopidy
Note that extensions installed from PyPI will only automatically install Python
dependencies. Please refer to the extension's documentation for information
about any other requirements needed for the extension to work properly.
For a full list of available Mopidy extensions, including those not installable
from AUR, see :ref:`ext`.

View File

@ -52,20 +52,6 @@ from scratch, we have a guide for installing Debian/Raspbian and Mopidy. See
sudo apt-get update
sudo apt-get install mopidy
#. Optional: If you want to use any Mopidy extensions, like Spotify support or
Last.fm scrobbling, you need to install additional packages.
To list all the extensions available from apt.mopidy.com, you can run::
apt-cache search mopidy
To install one of the listed packages, e.g. ``mopidy-spotify``, simply run::
sudo apt-get install mopidy-spotify
For a full list of available Mopidy extensions, including those not
installable from apt.mopidy.com, see :ref:`ext`.
#. Before continuing, make sure you've read the :ref:`debian` section to learn
about the differences between running Mopidy as a system service and
manually as your own system user.
@ -78,3 +64,71 @@ figure it out for itself, run the following to upgrade right away::
sudo apt-get update
sudo apt-get dist-upgrade
Installing extensions
=====================
If you want to use any Mopidy extensions, like Spotify support or Last.fm
scrobbling, you need to install additional packages.
To list all the extensions available from apt.mopidy.com, you can run::
apt-cache search mopidy
To install one of the listed packages, e.g. ``mopidy-spotify``, simply run::
sudo apt-get install mopidy-spotify
You can also install any Mopidy extension directly from PyPI with ``pip``. To
list all the extensions available from PyPI, run::
pip search mopidy
Note that extensions installed from PyPI will only automatically install Python
dependencies. Please refer to the extension's documentation for information
about any other requirements needed for the extension to work properly.
For a full list of available Mopidy extensions, including those not
installable from apt.mopidy.com, see :ref:`ext`.
Missing extensions
==================
If you've installed a Mopidy extension with pip, restarted Mopidy, and Mopidy
doesn't find the extension, there's probably a simple explanation and solution.
Mopidy installed with APT can detect and use Mopidy extensions installed with
both APT and pip. APT installs Mopidy as :file:`/usr/bin/mopidy`.
Mopidy installed with pip can only detect Mopidy extensions installed with pip.
pip usually installs Mopidy as :file:`/usr/local/bin/mopidy`.
If you have Mopidy installed from both APT and pip, then the pip-installed
Mopidy will probably shadow the APT-installed Mopidy because
:file:`/usr/local/bin` usually has precedence over :file:`/usr/bin` in the
``PATH`` environment variable. To check if this is the case on your system, you
can use ``which`` to see what installation of Mopidy you use when you run
``mopidy`` in your shell::
$ which mopidy
/usr/local/bin/mopidy
If this is the case on your system, the recommended solution is to check that
you have Mopidy installed from APT too::
$ /usr/bin/mopidy --version
Mopidy 0.19.5
And then uninstall the pip-installed Mopidy::
sudo pip uninstall mopidy
Depending on what shell you use, the shell may still try to use
:file:`/usr/local/bin/mopidy` even if it no longer exists. Check again with
``which mopidy`` what your shell believes is the right ``mopidy`` executable to
run. If the shell is still confused, you may need to restart it, or in the case
of zsh, run ``rehash`` to update the shell.
For more details on why this works this way, see :ref:`debian`.

View File

@ -7,8 +7,9 @@ Installation
There are several ways to install Mopidy. What way is best depends upon your OS
and/or distribution.
If you want to contribute to the development of Mopidy, you should first read
the general installation instructions, then have a look at :ref:`run-from-git`.
If you want to contribute to the development of Mopidy, you should first follow
the instructions here to install a regular install of Mopidy, then continue
with reading :ref:`contributing` and :ref:`devenv`.
.. toctree::

View File

@ -57,16 +57,77 @@ If you are running OS X, you can install everything needed with Homebrew.
brew install mopidy
#. Optional: If you want to use any Mopidy extensions, like Spotify support or
Last.fm scrobbling, the Homebrew tap has formulas for several Mopidy
extensions as well.
To list all the extensions available from our tap, you can run::
brew search mopidy
For a full list of available Mopidy extensions, including those not
installable from Homebrew, see :ref:`ext`.
#. Finally, you need to set a couple of :doc:`config values </config>`, and
then you're ready to :doc:`run Mopidy </running>`.
Installing extensions
=====================
If you want to use any Mopidy extensions, like Spotify support or Last.fm
scrobbling, the Homebrew tap has formulas for several Mopidy extensions as
well. Extensions installed from Homebrew will come complete with all
dependencies, both Python and non-Python ones.
To list all the extensions available from our tap, you can run::
brew search mopidy
You can also install any Mopidy extension directly from PyPI with ``pip``, just
like on Linux. To list all the extensions available from PyPI, run::
pip search mopidy
Note that extensions installed from PyPI will only automatically install Python
dependencies. Please refer to the extension's documentation for information
about any other requirements needed for the extension to work properly.
For a full list of available Mopidy extensions, including those not installable
from Homebrew, see :ref:`ext`.
Running Mopidy automatically on login
=====================================
On OS X, you can use launchd to start Mopidy automatically at login.
If you installed Mopidy from Homebrew, simply run ``brew info mopidy`` and
follow the instructions in the "Caveats" section::
$ brew info mopidy
...
==> Caveats
To have launchd start mopidy at login:
ln -sfv /usr/local/opt/mopidy/*.plist ~/Library/LaunchAgents
Then to load mopidy now:
launchctl load ~/Library/LaunchAgents/homebrew.mopidy.mopidy.plist
Or, if you don't want/need launchctl, you can just run:
mopidy
If you happen to be on OS X, but didn't install Mopidy with Homebrew, you can
get the same effect by adding the file
:file:`~/Library/LaunchAgents/mopidy.plist` with the following contents::
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>mopidy</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/mopidy</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
You might need to adjust the path to the ``mopidy`` executable,
``/usr/local/bin/mopidy``, to match your system.
Then, to start Mopidy with launchd right away::
launchctl load ~/Library/LaunchAgents/mopidy.plist

View File

@ -6,7 +6,10 @@ Install from source
If you are on Linux, but can't install :ref:`from the APT archive
<debian-install>` or :ref:`from AUR <arch-install>`, you can install Mopidy
from source by hand.
from PyPI using the ``pip`` installer.
If you are looking to contribute or wish to install from source using ``git``
please follow the directions :ref:`here <contributing>`.
#. First of all, you need Python 2.7. Check if you have Python and what
version by running::
@ -69,46 +72,32 @@ from source by hand.
sudo pip install -U mopidy
To upgrade Mopidy to future releases, just rerun this command.
This will use ``pip`` to install the latest release of `Mopidy from PyPI
<https://pypi.python.org/pypi/Mopidy>`_. To upgrade Mopidy to future
releases, just rerun this command.
Alternatively, if you want to track Mopidy development closer, you may
install a snapshot of Mopidy's ``develop`` Git branch using pip::
sudo pip install --allow-unverified=mopidy mopidy==dev
#. Optional: If you want Spotify support in Mopidy, you'll need to install
libspotify and the Mopidy-Spotify extension.
#. Download and install the latest version of libspotify for your OS and CPU
architecture from `Spotify
<https://developer.spotify.com/technologies/libspotify/>`_.
For libspotify 12.1.51 for 64-bit Linux the process is as follows::
wget https://developer.spotify.com/download/libspotify/libspotify-12.1.51-Linux-x86_64-release.tar.gz
tar zxfv libspotify-12.1.51-Linux-x86_64-release.tar.gz
cd libspotify-12.1.51-Linux-x86_64-release/
sudo make install prefix=/usr/local
Remember to adjust the above example for the latest libspotify version
supported by pyspotify, your OS, and your CPU architecture.
#. If you're on Fedora, you must add a configuration file so libspotify.so
can be found::
echo /usr/local/lib | sudo tee /etc/ld.so.conf.d/libspotify.conf
sudo ldconfig
#. Then install the latest release of Mopidy-Spotify using pip::
sudo pip install -U mopidy-spotify
#. Optional: If you want to scrobble your played tracks to Last.fm, you need
to install Mopidy-Scrobbler::
sudo pip install -U mopidy-scrobbler
#. For a full list of available Mopidy extensions, see :ref:`ext`.
#. Finally, you need to set a couple of :doc:`config values </config>`, and
then you're ready to :doc:`run Mopidy </running>`.
Installing extensions
=====================
If you want to use any Mopidy extensions, like Spotify support or Last.fm
scrobbling, you need to install additional Mopidy extensions.
You can install any Mopidy extension directly from PyPI with ``pip``. To list
all the extensions available from PyPI, run::
pip search mopidy
Note that extensions installed from PyPI will only automatically install Python
dependencies. Please refer to the extension's documentation for information
about any other requirements needed for the extension to work properly.
For a full list of available Mopidy extensions see :ref:`ext`.

View File

@ -1,51 +1,10 @@
*****************
Development tools
*****************
******************
Release procedures
******************
Here you'll find description of the development tools we use.
Continuous integration
======================
Mopidy uses the free service `Travis CI <https://travis-ci.org/mopidy/mopidy>`_
for automatically running the test suite when code is pushed to GitHub. This
works both for the main Mopidy repo, but also for any forks. This way, any
contributions to Mopidy through GitHub will automatically be tested by Travis
CI, and the build status will be visible in the GitHub pull request interface,
making it easier to evaluate the quality of pull requests.
In addition, we run a Jenkins CI server at http://ci.mopidy.com/ that runs all
test on multiple platforms (Ubuntu, OS X, x86, arm) for every commit we push to
the ``develop`` branch in the main Mopidy repo on GitHub. Thus, new code isn't
tested by Jenkins before it is merged into the ``develop`` branch, which is a
bit late, but good enough to get broad testing before new code is released.
In addition to running tests, the Jenkins CI server also gathers coverage
statistics and uses flake8 to check for errors and possible improvements in our
code. So, if you're out of work, the code coverage and flake8 data at the CI
server should give you a place to start.
Documentation writing
=====================
To write documentation, we use `Sphinx <http://sphinx-doc.org/>`_. See their
site for lots of documentation on how to use Sphinx. To generate HTML from the
documentation files, you need some additional dependencies.
You can install them through Debian/Ubuntu package management::
sudo apt-get install python-sphinx python-pygraphviz graphviz
Then, to generate docs::
cd docs/
make # For help on available targets
make html # To generate HTML docs
The documentation at http://docs.mopidy.com/ is automatically updated when a
documentation update is pushed to ``mopidy/mopidy`` at GitHub.
Here we try to keep an up to date record of how Mopidy releases are made. This
documentation serves both as a checklist, to reduce the project's dependency on
key individuals, and as a stepping stone to more automation.
Creating releases

View File

@ -4,9 +4,9 @@
Troubleshooting
***************
If you run into problems with Mopidy, we usually hang around at ``#mopidy`` at
`irc.freenode.net <http://freenode.net/>`_ and also have a `mailing list at
Google Groups <https://groups.google.com/forum/?fromgroups=#!forum/mopidy>`_.
If you run into problems with Mopidy, we usually hang around at ``#mopidy`` on
`irc.freenode.net <http://freenode.net/>`_ and also have a `discussion forum
<https://discuss.mopidy.com/c/mopidy>`_.
If you stumble into a bug or have a feature request, please create an issue in
the `issue tracker <https://github.com/mopidy/mopidy/issues>`_.
@ -89,4 +89,12 @@ level 3, you can run::
GST_DEBUG=3 mopidy -v
This will produce a lot of output, but given some GStreamer knowledge this is
very useful for debugging GStreamer pipeline issues.
very useful for debugging GStreamer pipeline issues. Additionally
:envvar:`GST_DEBUG_FILE=gstreamer.log` can be used to redirect the debug
logging to a file instead of standard out.
Lastly :envvar:`GST_DEBUG_DUMP_DOT_DIR` can be used to get descriptions of the
current pipeline in dot format. Currently we trigger a dump of the pipeline on
every completed state change::
GST_DEBUG_DUMP_DOT_DIR=. mopidy

View File

@ -1,23 +1,39 @@
.. _versioning:
**********
Versioning
**********
Mopidy uses `Semantic Versioning <http://semver.org/>`_, but since we're still
pre-1.0 that doesn't mean much yet.
Mopidy follows `Semantic Versioning <http://semver.org/>`_. In summary this
means that our version numbers have three parts, MAJOR.MINOR.PATCH, which
change according to the following rules:
- When we *make incompatible API changes*, we increase the MAJOR number.
- When we *add features* in a backwards-compatible manner, we increase the
MINOR number.
- When we *fix bugs* in a backwards-compatible manner, we increase the PATCH
number.
The promise is that if you make a Mopidy extension for Mopidy 1.0, it should
work unchanged with any Mopidy 1.x release, but probably not with 2.0. When a
new major version is released, you must review the incompatible changes and
update your extension accordingly.
Release schedule
================
We intend to have about one feature release every month in periods of active
development. The feature releases are numbered 0.x.0. The features added is a
mix of what we feel is most important/requested of the missing features, and
features we develop just because we find them fun to make, even though they may
be useful for very few users or for a limited use case.
development. The features added is a mix of what we feel is most
important/requested of the missing features, and features we develop just
because we find them fun to make, even though they may be useful for very few
users or for a limited use case.
Bugfix releases, numbered 0.x.y, will be released whenever we discover bugs
that are too serious to wait for the next feature release. We will only release
bugfix releases for the last feature release. E.g. when 0.14.0 is released, we
will no longer provide bugfix releases for the 0.13 series. In other words,
there will be just a single supported release at any point in time. This is to
not spread our limited resources too thin.
Bugfix releases will be released whenever we discover bugs that are too serious
to wait for the next feature release. We will only release bugfix releases for
the last feature release. E.g. when 1.2.0 is released, we will no longer
provide bugfix releases for the 1.1.x series. In other words, there will be just
a single supported release at any point in time. This is to not spread our
limited resources too thin.

24
extra/mopidyctl/mopidyctl Executable file
View File

@ -0,0 +1,24 @@
#!/bin/sh
SELF=$(basename $0)
DAEMON="/usr/bin/mopidy"
DAEMON_USER="mopidy"
CONFIG_FILES="/usr/share/mopidy/conf.d:/etc/mopidy/mopidy.conf"
CMD="$DAEMON --config $CONFIG_FILES $@"
if [ $# -eq 0 ]; then
echo "Usage: $SELF [options]" 1>&2
echo "Examples:" 1>&2
echo " $SELF --help" 1>&2
echo " $SELF config" 1>&2
echo " $SELF local scan" 1>&2
exit 1
fi
if [ $(id -u) -ne 0 ]; then
echo "$SELF must be run as root" 1>&2
exit 2
fi
echo "Running \"$CMD\" as user $DAEMON_USER" 1>&2
su -s /bin/sh -c "$CMD" -- $DAEMON_USER

View File

@ -0,0 +1,17 @@
.\" Manpage for mopidyctl
.TH "MOPIDYCTL" "8" "October 11, 2014" "1.0" "mopidyctl"
.SH NAME
mopidyctl \- manage the Mopidy music server system service
.SH SYNOPSIS
.B mopidyctl
[any mopidy(1) option]
.SH DESCRIPTION
The \fBmopidyctl\fP command runs \fBmopidy\fP subcommands in the
same environment as the Mopidy system service is running in. That is, as the
same user and with the same config as the Mopidy system service is using.
.SH OPTIONS
mopidyctl(8) takes the same options as mopidy(1).
.SH SEE ALSO
mopidy(1)
.SH COPYRIGHT
2014, Stein Magnus Jodal and contributors

View File

@ -0,0 +1,16 @@
[Unit]
Description=Mopidy music server
After=avahi-daemon.service
After=dbus.service
After=network.target
After=nss-lookup.target
After=pulseaudio.service
After=remote-fs.target
After=sound.target
[Service]
User=mopidy
ExecStart=/usr/bin/mopidy --config /usr/share/mopidy/conf.d:/etc/mopidy/mopidy.conf
[Install]
WantedBy=multi-user.target

63
fabfile.py vendored
View File

@ -1,63 +0,0 @@
from fabric.api import execute, local, settings, task
@task
def docs():
local('make -C docs/ html')
@task
def autodocs():
auto(docs)
@task
def test(path=None):
path = path or 'tests/'
local('nosetests ' + path)
@task
def autotest(path=None):
auto(test, path=path)
@task
def coverage(path=None):
path = path or 'tests/'
local(
'nosetests --with-coverage --cover-package=mopidy '
'--cover-branches --cover-html ' + path)
@task
def autocoverage(path=None):
auto(coverage, path=path)
@task
def lint(path=None):
path = path or '.'
local('flake8 $(find %s -iname "*.py")' % path)
@task
def autolint(path=None):
auto(lint, path=path)
def auto(task, *args, **kwargs):
while True:
local('clear')
with settings(warn_only=True):
execute(task, *args, **kwargs)
local(
'inotifywait -q -e create -e modify -e delete '
'--exclude ".*\.(pyc|sw.)" -r docs/ mopidy/ tests/')
@task
def update_authors():
# Keep authors in the order of appearance and use awk to filter out dupes
local(
"git log --format='- %aN <%aE>' --reverse | awk '!x[$0]++' > AUTHORS")

View File

@ -1,101 +0,0 @@
/*global module:false*/
module.exports = function (grunt) {
grunt.initConfig({
pkg: grunt.file.readJSON("package.json"),
meta: {
banner: "/*! Mopidy.js v<%= pkg.version %> - built " +
"<%= grunt.template.today('yyyy-mm-dd') %>\n" +
" * http://www.mopidy.com/\n" +
" * Copyright (c) <%= grunt.template.today('yyyy') %> " +
"Stein Magnus Jodal and contributors\n" +
" * Licensed under the Apache License, Version 2.0 */\n",
files: {
own: ["Gruntfile.js", "src/**/*.js", "test/**/*-test.js"],
main: "src/mopidy.js",
concat: "../mopidy/http/data/mopidy.js",
minified: "../mopidy/http/data/mopidy.min.js"
}
},
buster: {
all: {}
},
browserify: {
test_mopidy: {
files: {
"test/lib/mopidy.js": "<%= meta.files.main %>"
},
options: {
postBundleCB: function (err, src, next) {
next(err, grunt.template.process("<%= meta.banner %>") + src);
},
standalone: "Mopidy"
}
},
test_when: {
files: {
"test/lib/when.js": "node_modules/when/when.js"
},
options: {
standalone: "when"
}
},
dist: {
files: {
"<%= meta.files.concat %>": "<%= meta.files.main %>"
},
options: {
postBundleCB: function (err, src, next) {
next(err, grunt.template.process("<%= meta.banner %>") + src);
},
standalone: "Mopidy"
}
}
},
jshint: {
options: {
curly: true,
eqeqeq: true,
immed: true,
indent: 4,
latedef: true,
newcap: true,
noarg: true,
sub: true,
quotmark: "double",
undef: true,
unused: true,
eqnull: true,
browser: true,
devel: true,
globals: {}
},
files: "<%= meta.files.own %>"
},
uglify: {
options: {
banner: "<%= meta.banner %>"
},
all: {
files: {
"<%= meta.files.minified %>": ["<%= meta.files.concat %>"]
}
}
},
watch: {
files: "<%= meta.files.own %>",
tasks: ["default"]
}
});
grunt.registerTask("test_build", ["browserify:test_when", "browserify:test_mopidy"]);
grunt.registerTask("test", ["jshint", "test_build", "buster"]);
grunt.registerTask("build", ["test", "browserify:dist", "uglify"]);
grunt.registerTask("default", ["build"]);
grunt.loadNpmTasks("grunt-buster");
grunt.loadNpmTasks("grunt-browserify");
grunt.loadNpmTasks("grunt-contrib-jshint");
grunt.loadNpmTasks("grunt-contrib-uglify");
grunt.loadNpmTasks("grunt-contrib-watch");
};

View File

@ -1,121 +0,0 @@
Mopidy.js
=========
Mopidy.js is a JavaScript library that is installed as a part of Mopidy's HTTP
frontend or from npm. The library makes Mopidy's core API available from the
browser or a Node.js environment, using JSON-RPC messages over a WebSocket to
communicate with Mopidy.
Getting it for browser use
--------------------------
Regular and minified versions of Mopidy.js, ready for use, is installed
together with Mopidy. When the HTTP frontend is running, the files are
available at:
- http://localhost:6680/mopidy/mopidy.js
- http://localhost:6680/mopidy/mopidy.min.js
You may need to adjust hostname and port for your local setup.
In the source repo, you can find the files at:
- `mopidy/http/data/mopidy.js`
- `mopidy/http/data/mopidy.min.js`
Getting it for Node.js use
--------------------------
If you want to use Mopidy.js from Node.js instead of a browser, you can install
Mopidy.js using npm:
npm install mopidy
After npm completes, you can import Mopidy.js using ``require()``:
var Mopidy = require("mopidy");
Using the library
-----------------
See the [Mopidy.js documentation](http://docs.mopidy.com/en/latest/api/js/).
Building from source
--------------------
1. Install [Node.js](http://nodejs.org/) and npm. If you're running Ubuntu:
sudo apt-get install nodejs-legacy npm
2. Enter the `js/` in Mopidy's Git repo dir and install all dependencies:
cd js/
npm install
That's it.
You can now run the tests:
npm test
To run tests automatically when you save a file:
npm start
To run tests, concatenate, minify the source, and update the JavaScript files
in `mopidy/http/data/`:
npm run-script build
To run other [grunt](http://gruntjs.com/) targets which isn't predefined in
`package.json` and thus isn't available through `npm run-script`:
PATH=./node_modules/.bin:$PATH grunt foo
Changelog
---------
### 0.4.0 (2014-06-24)
- Add support for method calls with by-name arguments. The old calling
convention, "by-position-only", is still the default, but this will change in
the future. A warning is printed to the console if you don't explicitly
select a calling convention. See the docs for details.
### 0.3.0 (2014-06-16)
- Upgrade to when.js 3, which brings great performance improvements and better
debugging facilities. If you maintain a Mopidy client, you should review the
[differences between when.js 2 and 3](https://github.com/cujojs/when/blob/master/docs/api.md#upgrading-to-30-from-2x)
and the
[when.js debugging guide](https://github.com/cujojs/when/blob/master/docs/api.md#debugging-promises).
- All promise rejection values are now of the Error type. This ensures that all
JavaScript VMs will show a useful stack trace if a rejected promise's value
is used to throw an exception. To allow catch clauses to handle different
errors differently, server side errors are of the type `Mopidy.ServerError`,
and connection related errors are of the type `Mopidy.ConnectionError`.
### 0.2.0 (2014-01-04)
- **Backwards incompatible change for Node.js users:**
`var Mopidy = require('mopidy').Mopidy;` must be changed to
`var Mopidy = require('mopidy');`
- Add support for [Browserify](http://browserify.org/).
- Upgrade dependencies.
### 0.1.1 (2013-09-17)
- Upgrade dependencies.
### 0.1.0 (2013-03-31)
- Initial release as a Node.js module to the
[npm registry](https://npmjs.org/).

View File

@ -1,15 +0,0 @@
var config = module.exports;
config.browser_tests = {
environment: "browser",
libs: ["test/lib/*.js"],
testHelpers: ["test/**/*-helper.js"],
tests: ["test/**/*-test.js"]
};
config.node_tests = {
environment: "node",
sources: ["src/**/*.js"],
testHelpers: ["test/**/*-helper.js"],
tests: ["test/**/*-test.js"]
};

View File

@ -1 +0,0 @@
module.exports = { Client: window.WebSocket };

View File

@ -1,4 +0,0 @@
{
"browser": "browser.js",
"main": "server.js"
}

View File

@ -1 +0,0 @@
module.exports = require('faye-websocket');

View File

@ -1,60 +0,0 @@
{
"name": "mopidy",
"version": "0.4.0",
"description": "Client lib for controlling a Mopidy music server over a WebSocket",
"keywords": [
"mopidy",
"music",
"client",
"websocket",
"json-rpc"
],
"homepage": "http://www.mopidy.com/",
"bugs": "https://github.com/mopidy/mopidy/issues",
"license": "Apache-2.0",
"author": {
"name": "Stein Magnus Jodal",
"email": "stein.magnus@jodal.no",
"url": "http://www.jodal.no"
},
"contributors": [
{
"name": "Stein Magnus Jodal",
"email": "stein.magnus@jodal.no",
"url": "http://www.jodal.no"
},
{
"name": "Paul Connolley",
"email": "paul.connolley@gmail.com"
}
],
"main": "src/mopidy.js",
"repository": {
"type": "git",
"url": "git://github.com/mopidy/mopidy.git"
},
"scripts": {
"test": "grunt test",
"build": "grunt build",
"start": "grunt watch"
},
"dependencies": {
"bane": "~1.1.0",
"faye-websocket": "~0.7.2",
"when": "~3.2.3"
},
"devDependencies": {
"buster": "~0.7.13",
"browserify": "~3",
"grunt": "~0.4.5",
"grunt-buster": "~0.3.1",
"grunt-browserify": "~1.3.2",
"grunt-contrib-jshint": "~0.10.0",
"grunt-contrib-uglify": "~0.5.0",
"grunt-contrib-watch": "~0.6.1",
"phantomjs": "~1.9.7-8"
},
"engines": {
"node": "*"
}
}

View File

@ -1,331 +0,0 @@
/*global module:true, require:false*/
var bane = require("bane");
var websocket = require("../lib/websocket/");
var when = require("when");
function Mopidy(settings) {
if (!(this instanceof Mopidy)) {
return new Mopidy(settings);
}
this._console = this._getConsole(settings || {});
this._settings = this._configure(settings || {});
this._backoffDelay = this._settings.backoffDelayMin;
this._pendingRequests = {};
this._webSocket = null;
bane.createEventEmitter(this);
this._delegateEvents();
if (this._settings.autoConnect) {
this.connect();
}
}
Mopidy.ConnectionError = function (message) {
this.name = "ConnectionError";
this.message = message;
};
Mopidy.ConnectionError.prototype = new Error();
Mopidy.ConnectionError.prototype.constructor = Mopidy.ConnectionError;
Mopidy.ServerError = function (message) {
this.name = "ServerError";
this.message = message;
};
Mopidy.ServerError.prototype = new Error();
Mopidy.ServerError.prototype.constructor = Mopidy.ServerError;
Mopidy.WebSocket = websocket.Client;
Mopidy.prototype._getConsole = function (settings) {
if (typeof settings.console !== "undefined") {
return settings.console;
}
var con = typeof console !== "undefined" && console || {};
con.log = con.log || function () {};
con.warn = con.warn || function () {};
con.error = con.error || function () {};
return con;
};
Mopidy.prototype._configure = function (settings) {
var currentHost = (typeof document !== "undefined" &&
document.location.host) || "localhost";
settings.webSocketUrl = settings.webSocketUrl ||
"ws://" + currentHost + "/mopidy/ws";
if (settings.autoConnect !== false) {
settings.autoConnect = true;
}
settings.backoffDelayMin = settings.backoffDelayMin || 1000;
settings.backoffDelayMax = settings.backoffDelayMax || 64000;
if (typeof settings.callingConvention === "undefined") {
this._console.warn(
"Mopidy.js is using the default calling convention. The " +
"default will change in the future. You should explicitly " +
"specify which calling convention you use.");
}
settings.callingConvention = (
settings.callingConvention || "by-position-only");
return settings;
};
Mopidy.prototype._delegateEvents = function () {
// Remove existing event handlers
this.off("websocket:close");
this.off("websocket:error");
this.off("websocket:incomingMessage");
this.off("websocket:open");
this.off("state:offline");
// Register basic set of event handlers
this.on("websocket:close", this._cleanup);
this.on("websocket:error", this._handleWebSocketError);
this.on("websocket:incomingMessage", this._handleMessage);
this.on("websocket:open", this._resetBackoffDelay);
this.on("websocket:open", this._getApiSpec);
this.on("state:offline", this._reconnect);
};
Mopidy.prototype.connect = function () {
if (this._webSocket) {
if (this._webSocket.readyState === Mopidy.WebSocket.OPEN) {
return;
} else {
this._webSocket.close();
}
}
this._webSocket = this._settings.webSocket ||
new Mopidy.WebSocket(this._settings.webSocketUrl);
this._webSocket.onclose = function (close) {
this.emit("websocket:close", close);
}.bind(this);
this._webSocket.onerror = function (error) {
this.emit("websocket:error", error);
}.bind(this);
this._webSocket.onopen = function () {
this.emit("websocket:open");
}.bind(this);
this._webSocket.onmessage = function (message) {
this.emit("websocket:incomingMessage", message);
}.bind(this);
};
Mopidy.prototype._cleanup = function (closeEvent) {
Object.keys(this._pendingRequests).forEach(function (requestId) {
var resolver = this._pendingRequests[requestId];
delete this._pendingRequests[requestId];
var error = new Mopidy.ConnectionError("WebSocket closed");
error.closeEvent = closeEvent;
resolver.reject(error);
}.bind(this));
this.emit("state:offline");
};
Mopidy.prototype._reconnect = function () {
this.emit("reconnectionPending", {
timeToAttempt: this._backoffDelay
});
setTimeout(function () {
this.emit("reconnecting");
this.connect();
}.bind(this), this._backoffDelay);
this._backoffDelay = this._backoffDelay * 2;
if (this._backoffDelay > this._settings.backoffDelayMax) {
this._backoffDelay = this._settings.backoffDelayMax;
}
};
Mopidy.prototype._resetBackoffDelay = function () {
this._backoffDelay = this._settings.backoffDelayMin;
};
Mopidy.prototype.close = function () {
this.off("state:offline", this._reconnect);
this._webSocket.close();
};
Mopidy.prototype._handleWebSocketError = function (error) {
this._console.warn("WebSocket error:", error.stack || error);
};
Mopidy.prototype._send = function (message) {
switch (this._webSocket.readyState) {
case Mopidy.WebSocket.CONNECTING:
return when.reject(
new Mopidy.ConnectionError("WebSocket is still connecting"));
case Mopidy.WebSocket.CLOSING:
return when.reject(
new Mopidy.ConnectionError("WebSocket is closing"));
case Mopidy.WebSocket.CLOSED:
return when.reject(
new Mopidy.ConnectionError("WebSocket is closed"));
default:
var deferred = when.defer();
message.jsonrpc = "2.0";
message.id = this._nextRequestId();
this._pendingRequests[message.id] = deferred.resolver;
this._webSocket.send(JSON.stringify(message));
this.emit("websocket:outgoingMessage", message);
return deferred.promise;
}
};
Mopidy.prototype._nextRequestId = (function () {
var lastUsed = -1;
return function () {
lastUsed += 1;
return lastUsed;
};
}());
Mopidy.prototype._handleMessage = function (message) {
try {
var data = JSON.parse(message.data);
if (data.hasOwnProperty("id")) {
this._handleResponse(data);
} else if (data.hasOwnProperty("event")) {
this._handleEvent(data);
} else {
this._console.warn(
"Unknown message type received. Message was: " +
message.data);
}
} catch (error) {
if (error instanceof SyntaxError) {
this._console.warn(
"WebSocket message parsing failed. Message was: " +
message.data);
} else {
throw error;
}
}
};
Mopidy.prototype._handleResponse = function (responseMessage) {
if (!this._pendingRequests.hasOwnProperty(responseMessage.id)) {
this._console.warn(
"Unexpected response received. Message was:", responseMessage);
return;
}
var error;
var resolver = this._pendingRequests[responseMessage.id];
delete this._pendingRequests[responseMessage.id];
if (responseMessage.hasOwnProperty("result")) {
resolver.resolve(responseMessage.result);
} else if (responseMessage.hasOwnProperty("error")) {
error = new Mopidy.ServerError(responseMessage.error.message);
error.code = responseMessage.error.code;
error.data = responseMessage.error.data;
resolver.reject(error);
this._console.warn("Server returned error:", responseMessage.error);
} else {
error = new Error("Response without 'result' or 'error' received");
error.data = {response: responseMessage};
resolver.reject(error);
this._console.warn(
"Response without 'result' or 'error' received. Message was:",
responseMessage);
}
};
Mopidy.prototype._handleEvent = function (eventMessage) {
var type = eventMessage.event;
var data = eventMessage;
delete data.event;
this.emit("event:" + this._snakeToCamel(type), data);
};
Mopidy.prototype._getApiSpec = function () {
return this._send({method: "core.describe"})
.then(this._createApi.bind(this))
.catch(this._handleWebSocketError);
};
Mopidy.prototype._createApi = function (methods) {
var byPositionOrByName = (
this._settings.callingConvention === "by-position-or-by-name");
var caller = function (method) {
return function () {
var message = {method: method};
if (arguments.length === 0) {
return this._send(message);
}
if (!byPositionOrByName) {
message.params = Array.prototype.slice.call(arguments);
return this._send(message);
}
if (arguments.length > 1) {
return when.reject(new Error(
"Expected zero arguments, a single array, " +
"or a single object."));
}
if (!Array.isArray(arguments[0]) &&
arguments[0] !== Object(arguments[0])) {
return when.reject(new TypeError(
"Expected an array or an object."));
}
message.params = arguments[0];
return this._send(message);
}.bind(this);
}.bind(this);
var getPath = function (fullName) {
var path = fullName.split(".");
if (path.length >= 1 && path[0] === "core") {
path = path.slice(1);
}
return path;
};
var createObjects = function (objPath) {
var parentObj = this;
objPath.forEach(function (objName) {
objName = this._snakeToCamel(objName);
parentObj[objName] = parentObj[objName] || {};
parentObj = parentObj[objName];
}.bind(this));
return parentObj;
}.bind(this);
var createMethod = function (fullMethodName) {
var methodPath = getPath(fullMethodName);
var methodName = this._snakeToCamel(methodPath.slice(-1)[0]);
var object = createObjects(methodPath.slice(0, -1));
object[methodName] = caller(fullMethodName);
object[methodName].description = methods[fullMethodName].description;
object[methodName].params = methods[fullMethodName].params;
}.bind(this);
Object.keys(methods).forEach(createMethod);
this.emit("state:online");
};
Mopidy.prototype._snakeToCamel = function (name) {
return name.replace(/(_[a-z])/g, function (match) {
return match.toUpperCase().replace("_", "");
});
};
module.exports = Mopidy;

View File

@ -1,29 +0,0 @@
/*
* PhantomJS 1.6 does not support Function.prototype.bind, so we polyfill it.
*
* Implementation from:
* https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Function/bind
*/
if (!Function.prototype.bind) {
Function.prototype.bind = function (oThis) {
if (typeof this !== "function") {
// closest thing possible to the ECMAScript 5 internal IsCallable function
throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable");
}
var aArgs = Array.prototype.slice.call(arguments, 1),
fToBind = this,
fNOP = function () {},
fBound = function () {
return fToBind.apply(this instanceof fNOP && oThis
? this
: oThis,
aArgs.concat(Array.prototype.slice.call(arguments)));
};
fNOP.prototype = this.prototype;
fBound.prototype = new fNOP();
return fBound;
};
}

View File

@ -1,964 +0,0 @@
/*global require:false */
if (typeof module === "object" && typeof require === "function") {
var buster = require("buster");
var Mopidy = require("../src/mopidy");
var when = require("when");
}
var assert = buster.assert;
var refute = buster.refute;
buster.testCase("Mopidy", {
setUp: function () {
// Sinon.JS doesn't manage to stub PhantomJS' WebSocket implementation,
// so we replace it with a dummy temporarily.
var fakeWebSocket = function () {
return {
send: function () {},
close: function () {}
};
};
fakeWebSocket.CONNECTING = 0;
fakeWebSocket.OPEN = 1;
fakeWebSocket.CLOSING = 2;
fakeWebSocket.CLOSED = 3;
this.realWebSocket = Mopidy.WebSocket;
Mopidy.WebSocket = fakeWebSocket;
this.webSocketConstructorStub = this.stub(Mopidy, "WebSocket");
this.webSocket = {
close: this.stub(),
send: this.stub()
};
this.mopidy = new Mopidy({
callingConvention: "by-position-or-by-name",
webSocket: this.webSocket
});
},
tearDown: function () {
Mopidy.WebSocket = this.realWebSocket;
},
"constructor": {
"connects when autoConnect is true": function () {
new Mopidy({
autoConnect: true,
callingConvention: "by-position-or-by-name"
});
var currentHost = typeof document !== "undefined" &&
document.location.host || "localhost";
assert.calledOnceWith(this.webSocketConstructorStub,
"ws://" + currentHost + "/mopidy/ws");
},
"does not connect when autoConnect is false": function () {
new Mopidy({
autoConnect: false,
callingConvention: "by-position-or-by-name"
});
refute.called(this.webSocketConstructorStub);
},
"does not connect when passed a WebSocket": function () {
new Mopidy({
callingConvention: "by-position-or-by-name",
webSocket: {}
});
refute.called(this.webSocketConstructorStub);
},
"defaults to by-position-only calling convention": function () {
var console = {
warn: function () {}
};
var mopidy = new Mopidy({
console: console,
webSocket: this.webSocket,
});
assert.equals(
mopidy._settings.callingConvention,
"by-position-only");
},
"warns if no calling convention explicitly selected": function () {
var console = {
warn: function () {}
};
var stub = this.stub(console, "warn");
new Mopidy({console: console});
assert.calledOnceWith(
stub,
"Mopidy.js is using the default calling convention. The " +
"default will change in the future. You should explicitly " +
"specify which calling convention you use.");
},
"does not warn if calling convention chosen explicitly": function () {
var console = {
warn: function () {}
};
var stub = this.stub(console, "warn");
new Mopidy({
callingConvention: "by-position-or-by-name",
console: console
});
refute.called(stub);
},
"works without 'new' keyword": function () {
var mopidyConstructor = Mopidy; // To trick jshint into submission
var mopidy = mopidyConstructor({
callingConvention: "by-position-or-by-name",
webSocket: {}
});
assert.isObject(mopidy);
assert(mopidy instanceof Mopidy);
}
},
".connect": {
"connects when autoConnect is false": function () {
var mopidy = new Mopidy({
autoConnect: false,
callingConvention: "by-position-or-by-name"
});
refute.called(this.webSocketConstructorStub);
mopidy.connect();
var currentHost = typeof document !== "undefined" &&
document.location.host || "localhost";
assert.calledOnceWith(this.webSocketConstructorStub,
"ws://" + currentHost + "/mopidy/ws");
},
"does nothing when the WebSocket is open": function () {
this.webSocket.readyState = Mopidy.WebSocket.OPEN;
var mopidy = new Mopidy({
callingConvention: "by-position-or-by-name",
webSocket: this.webSocket
});
mopidy.connect();
refute.called(this.webSocket.close);
refute.called(this.webSocketConstructorStub);
}
},
"WebSocket events": {
"emits 'websocket:close' when connection is closed": function () {
var spy = this.spy();
this.mopidy.off("websocket:close");
this.mopidy.on("websocket:close", spy);
var closeEvent = {};
this.webSocket.onclose(closeEvent);
assert.calledOnceWith(spy, closeEvent);
},
"emits 'websocket:error' when errors occurs": function () {
var spy = this.spy();
this.mopidy.off("websocket:error");
this.mopidy.on("websocket:error", spy);
var errorEvent = {};
this.webSocket.onerror(errorEvent);
assert.calledOnceWith(spy, errorEvent);
},
"emits 'websocket:incomingMessage' when a message arrives": function () {
var spy = this.spy();
this.mopidy.off("websocket:incomingMessage");
this.mopidy.on("websocket:incomingMessage", spy);
var messageEvent = {data: "this is a message"};
this.webSocket.onmessage(messageEvent);
assert.calledOnceWith(spy, messageEvent);
},
"emits 'websocket:open' when connection is opened": function () {
var spy = this.spy();
this.mopidy.off("websocket:open");
this.mopidy.on("websocket:open", spy);
this.webSocket.onopen();
assert.calledOnceWith(spy);
}
},
"._cleanup": {
setUp: function () {
this.mopidy.off("state:offline");
},
"is called on 'websocket:close' event": function () {
var closeEvent = {};
var stub = this.stub(this.mopidy, "_cleanup");
this.mopidy._delegateEvents();
this.mopidy.emit("websocket:close", closeEvent);
assert.calledOnceWith(stub, closeEvent);
},
"rejects all pending requests": function (done) {
var closeEvent = {};
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0);
var promise1 = this.mopidy._send({method: "foo"});
var promise2 = this.mopidy._send({method: "bar"});
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 2);
this.mopidy._cleanup(closeEvent);
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0);
when.settle([promise1, promise2]).done(
done(function (descriptors) {
assert.equals(descriptors.length, 2);
descriptors.forEach(function (d) {
assert.equals(d.state, "rejected");
assert(d.reason instanceof Error);
assert(d.reason instanceof Mopidy.ConnectionError);
assert.equals(d.reason.message, "WebSocket closed");
assert.same(d.reason.closeEvent, closeEvent);
});
})
);
},
"emits 'state:offline' event when done": function () {
var spy = this.spy();
this.mopidy.on("state:offline", spy);
this.mopidy._cleanup({});
assert.calledOnceWith(spy);
}
},
"._reconnect": {
"is called when the state changes to offline": function () {
var stub = this.stub(this.mopidy, "_reconnect");
this.mopidy._delegateEvents();
this.mopidy.emit("state:offline");
assert.calledOnceWith(stub);
},
"tries to connect after an increasing backoff delay": function () {
var clock = this.useFakeTimers();
var connectStub = this.stub(this.mopidy, "connect");
var pendingSpy = this.spy();
this.mopidy.on("reconnectionPending", pendingSpy);
var reconnectingSpy = this.spy();
this.mopidy.on("reconnecting", reconnectingSpy);
refute.called(connectStub);
this.mopidy._reconnect();
assert.calledOnceWith(pendingSpy, {timeToAttempt: 1000});
clock.tick(0);
refute.called(connectStub);
clock.tick(1000);
assert.calledOnceWith(reconnectingSpy);
assert.calledOnce(connectStub);
pendingSpy.reset();
reconnectingSpy.reset();
this.mopidy._reconnect();
assert.calledOnceWith(pendingSpy, {timeToAttempt: 2000});
assert.calledOnce(connectStub);
clock.tick(0);
assert.calledOnce(connectStub);
clock.tick(1000);
assert.calledOnce(connectStub);
clock.tick(1000);
assert.calledOnceWith(reconnectingSpy);
assert.calledTwice(connectStub);
pendingSpy.reset();
reconnectingSpy.reset();
this.mopidy._reconnect();
assert.calledOnceWith(pendingSpy, {timeToAttempt: 4000});
assert.calledTwice(connectStub);
clock.tick(0);
assert.calledTwice(connectStub);
clock.tick(2000);
assert.calledTwice(connectStub);
clock.tick(2000);
assert.calledOnceWith(reconnectingSpy);
assert.calledThrice(connectStub);
},
"tries to connect at least about once per minute": function () {
var clock = this.useFakeTimers();
var connectStub = this.stub(this.mopidy, "connect");
var pendingSpy = this.spy();
this.mopidy.on("reconnectionPending", pendingSpy);
this.mopidy._backoffDelay = this.mopidy._settings.backoffDelayMax;
refute.called(connectStub);
this.mopidy._reconnect();
assert.calledOnceWith(pendingSpy, {timeToAttempt: 64000});
clock.tick(0);
refute.called(connectStub);
clock.tick(64000);
assert.calledOnce(connectStub);
pendingSpy.reset();
this.mopidy._reconnect();
assert.calledOnceWith(pendingSpy, {timeToAttempt: 64000});
assert.calledOnce(connectStub);
clock.tick(0);
assert.calledOnce(connectStub);
clock.tick(64000);
assert.calledTwice(connectStub);
}
},
"._resetBackoffDelay": {
"is called on 'websocket:open' event": function () {
var stub = this.stub(this.mopidy, "_resetBackoffDelay");
this.mopidy._delegateEvents();
this.mopidy.emit("websocket:open");
assert.calledOnceWith(stub);
},
"resets the backoff delay to the minimum value": function () {
this.mopidy._backoffDelay = this.mopidy._backoffDelayMax;
this.mopidy._resetBackoffDelay();
assert.equals(this.mopidy._backoffDelay,
this.mopidy._settings.backoffDelayMin);
}
},
"close": {
"unregisters reconnection hooks": function () {
this.stub(this.mopidy, "off");
this.mopidy.close();
assert.calledOnceWith(
this.mopidy.off, "state:offline", this.mopidy._reconnect);
},
"closes the WebSocket": function () {
this.mopidy.close();
assert.calledOnceWith(this.mopidy._webSocket.close);
}
},
"._handleWebSocketError": {
"is called on 'websocket:error' event": function () {
var error = {};
var stub = this.stub(this.mopidy, "_handleWebSocketError");
this.mopidy._delegateEvents();
this.mopidy.emit("websocket:error", error);
assert.calledOnceWith(stub, error);
},
"without stack logs the error to the console": function () {
var stub = this.stub(this.mopidy._console, "warn");
var error = {};
this.mopidy._handleWebSocketError(error);
assert.calledOnceWith(stub, "WebSocket error:", error);
},
"with stack logs the error to the console": function () {
var stub = this.stub(this.mopidy._console, "warn");
var error = {stack: "foo"};
this.mopidy._handleWebSocketError(error);
assert.calledOnceWith(stub, "WebSocket error:", error.stack);
}
},
"._send": {
"adds JSON-RPC fields to the message": function () {
this.stub(this.mopidy, "_nextRequestId").returns(1);
var stub = this.stub(JSON, "stringify");
this.mopidy._send({method: "foo"});
assert.calledOnceWith(stub, {
jsonrpc: "2.0",
id: 1,
method: "foo"
});
},
"adds a resolver to the pending requests queue": function () {
this.stub(this.mopidy, "_nextRequestId").returns(1);
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0);
this.mopidy._send({method: "foo"});
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 1);
assert.isFunction(this.mopidy._pendingRequests[1].resolve);
},
"sends message on the WebSocket": function () {
refute.called(this.mopidy._webSocket.send);
this.mopidy._send({method: "foo"});
assert.calledOnce(this.mopidy._webSocket.send);
},
"emits a 'websocket:outgoingMessage' event": function () {
var spy = this.spy();
this.mopidy.on("websocket:outgoingMessage", spy);
this.stub(this.mopidy, "_nextRequestId").returns(1);
this.mopidy._send({method: "foo"});
assert.calledOnceWith(spy, {
jsonrpc: "2.0",
id: 1,
method: "foo"
});
},
"immediately rejects request if CONNECTING": function (done) {
this.mopidy._webSocket.readyState = Mopidy.WebSocket.CONNECTING;
var promise = this.mopidy._send({method: "foo"});
refute.called(this.mopidy._webSocket.send);
promise.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert(error instanceof Mopidy.ConnectionError);
assert.equals(
error.message, "WebSocket is still connecting");
})
);
},
"immediately rejects request if CLOSING": function (done) {
this.mopidy._webSocket.readyState = Mopidy.WebSocket.CLOSING;
var promise = this.mopidy._send({method: "foo"});
refute.called(this.mopidy._webSocket.send);
promise.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert(error instanceof Mopidy.ConnectionError);
assert.equals(error.message, "WebSocket is closing");
})
);
},
"immediately rejects request if CLOSED": function (done) {
this.mopidy._webSocket.readyState = Mopidy.WebSocket.CLOSED;
var promise = this.mopidy._send({method: "foo"});
refute.called(this.mopidy._webSocket.send);
promise.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert(error instanceof Mopidy.ConnectionError);
assert.equals(error.message, "WebSocket is closed");
})
);
}
},
"._nextRequestId": {
"returns an ever increasing ID": function () {
var base = this.mopidy._nextRequestId();
assert.equals(this.mopidy._nextRequestId(), base + 1);
assert.equals(this.mopidy._nextRequestId(), base + 2);
assert.equals(this.mopidy._nextRequestId(), base + 3);
}
},
"._handleMessage": {
"is called on 'websocket:incomingMessage' event": function () {
var messageEvent = {};
var stub = this.stub(this.mopidy, "_handleMessage");
this.mopidy._delegateEvents();
this.mopidy.emit("websocket:incomingMessage", messageEvent);
assert.calledOnceWith(stub, messageEvent);
},
"passes JSON-RPC responses on to _handleResponse": function () {
var stub = this.stub(this.mopidy, "_handleResponse");
var message = {
jsonrpc: "2.0",
id: 1,
result: null
};
var messageEvent = {data: JSON.stringify(message)};
this.mopidy._handleMessage(messageEvent);
assert.calledOnceWith(stub, message);
},
"passes events on to _handleEvent": function () {
var stub = this.stub(this.mopidy, "_handleEvent");
var message = {
event: "track_playback_started",
track: {}
};
var messageEvent = {data: JSON.stringify(message)};
this.mopidy._handleMessage(messageEvent);
assert.calledOnceWith(stub, message);
},
"logs unknown messages": function () {
var stub = this.stub(this.mopidy._console, "warn");
var messageEvent = {data: JSON.stringify({foo: "bar"})};
this.mopidy._handleMessage(messageEvent);
assert.calledOnceWith(stub,
"Unknown message type received. Message was: " +
messageEvent.data);
},
"logs JSON parsing errors": function () {
var stub = this.stub(this.mopidy._console, "warn");
var messageEvent = {data: "foobarbaz"};
this.mopidy._handleMessage(messageEvent);
assert.calledOnceWith(stub,
"WebSocket message parsing failed. Message was: " +
messageEvent.data);
}
},
"._handleResponse": {
"logs unexpected responses": function () {
var stub = this.stub(this.mopidy._console, "warn");
var responseMessage = {
jsonrpc: "2.0",
id: 1337,
result: null
};
this.mopidy._handleResponse(responseMessage);
assert.calledOnceWith(stub,
"Unexpected response received. Message was:", responseMessage);
},
"removes the matching request from the pending queue": function () {
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0);
this.mopidy._send({method: "bar"});
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 1);
this.mopidy._handleResponse({
jsonrpc: "2.0",
id: Object.keys(this.mopidy._pendingRequests)[0],
result: "baz"
});
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0);
},
"resolves requests which get results back": function (done) {
var promise = this.mopidy._send({method: "bar"});
var responseResult = {};
var responseMessage = {
jsonrpc: "2.0",
id: Object.keys(this.mopidy._pendingRequests)[0],
result: responseResult
};
this.mopidy._handleResponse(responseMessage);
promise.then(done(function (result) {
assert.equals(result, responseResult);
}), done(function () {
assert(false);
}));
},
"rejects and logs requests which get errors back": function (done) {
var stub = this.stub(this.mopidy._console, "warn");
var promise = this.mopidy._send({method: "bar"});
var responseError = {
code: -32601,
message: "Method not found",
data: {}
};
var responseMessage = {
jsonrpc: "2.0",
id: Object.keys(this.mopidy._pendingRequests)[0],
error: responseError
};
this.mopidy._handleResponse(responseMessage);
assert.calledOnceWith(stub,
"Server returned error:", responseError);
promise.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert.equals(error.code, responseError.code);
assert.equals(error.message, responseError.message);
assert.equals(error.data, responseError.data);
})
);
},
"rejects and logs requests which get errors without data": function (done) {
var stub = this.stub(this.mopidy._console, "warn");
var promise = this.mopidy._send({method: "bar"});
var responseError = {
code: -32601,
message: "Method not found"
// 'data' key intentionally missing
};
var responseMessage = {
jsonrpc: "2.0",
id: Object.keys(this.mopidy._pendingRequests)[0],
error: responseError
};
this.mopidy._handleResponse(responseMessage);
assert.calledOnceWith(stub,
"Server returned error:", responseError);
promise.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert(error instanceof Mopidy.ServerError);
assert.equals(error.code, responseError.code);
assert.equals(error.message, responseError.message);
refute.defined(error.data);
})
);
},
"rejects and logs responses without result or error": function (done) {
var stub = this.stub(this.mopidy._console, "warn");
var promise = this.mopidy._send({method: "bar"});
var responseMessage = {
jsonrpc: "2.0",
id: Object.keys(this.mopidy._pendingRequests)[0]
};
this.mopidy._handleResponse(responseMessage);
assert.calledOnceWith(stub,
"Response without 'result' or 'error' received. Message was:",
responseMessage);
promise.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert.equals(
error.message,
"Response without 'result' or 'error' received");
assert.equals(error.data.response, responseMessage);
})
);
}
},
"._handleEvent": {
"emits server side even on Mopidy object": function () {
var spy = this.spy();
this.mopidy.on(spy);
var track = {};
var message = {
event: "track_playback_started",
track: track
};
this.mopidy._handleEvent(message);
assert.calledOnceWith(spy,
"event:trackPlaybackStarted", {track: track});
}
},
"._getApiSpec": {
"is called on 'websocket:open' event": function () {
var stub = this.stub(this.mopidy, "_getApiSpec");
this.mopidy._delegateEvents();
this.mopidy.emit("websocket:open");
assert.calledOnceWith(stub);
},
"gets Api description from server and calls _createApi": function (done) {
var methods = {};
var sendStub = this.stub(this.mopidy, "_send");
sendStub.returns(when.resolve(methods));
var _createApiStub = this.stub(this.mopidy, "_createApi");
this.mopidy._getApiSpec().then(done(function () {
assert.calledOnceWith(sendStub, {method: "core.describe"});
assert.calledOnceWith(_createApiStub, methods);
}));
}
},
"._createApi": {
"can create an API with methods on the root object": function () {
refute.defined(this.mopidy.hello);
refute.defined(this.mopidy.hi);
this.mopidy._createApi({
hello: {
description: "Says hello",
params: []
},
hi: {
description: "Says hi",
params: []
}
});
assert.isFunction(this.mopidy.hello);
assert.equals(this.mopidy.hello.description, "Says hello");
assert.equals(this.mopidy.hello.params, []);
assert.isFunction(this.mopidy.hi);
assert.equals(this.mopidy.hi.description, "Says hi");
assert.equals(this.mopidy.hi.params, []);
},
"can create an API with methods on a sub-object": function () {
refute.defined(this.mopidy.hello);
this.mopidy._createApi({
"hello.world": {
description: "Says hello to the world",
params: []
}
});
assert.defined(this.mopidy.hello);
assert.isFunction(this.mopidy.hello.world);
},
"strips off 'core' from method paths": function () {
refute.defined(this.mopidy.hello);
this.mopidy._createApi({
"core.hello.world": {
description: "Says hello to the world",
params: []
}
});
assert.defined(this.mopidy.hello);
assert.isFunction(this.mopidy.hello.world);
},
"converts snake_case to camelCase": function () {
refute.defined(this.mopidy.mightyGreetings);
this.mopidy._createApi({
"mighty_greetings.hello_world": {
description: "Says hello to the world",
params: []
}
});
assert.defined(this.mopidy.mightyGreetings);
assert.isFunction(this.mopidy.mightyGreetings.helloWorld);
},
"triggers 'state:online' event when API is ready for use": function () {
var spy = this.spy();
this.mopidy.on("state:online", spy);
this.mopidy._createApi({});
assert.calledOnceWith(spy);
},
"by-position-only calling convention": {
setUp: function () {
this.mopidy = new Mopidy({
webSocket: this.webSocket,
callingConvention: "by-position-only"
});
this.mopidy._createApi({
foo: {
params: ["bar", "baz"]
}
});
this.sendStub = this.stub(this.mopidy, "_send");
},
"sends no params if no arguments passed to function": function () {
this.mopidy.foo();
assert.calledOnceWith(this.sendStub, {method: "foo"});
},
"sends messages with function arguments unchanged": function () {
this.mopidy.foo(31, 97);
assert.calledOnceWith(this.sendStub, {
method: "foo",
params: [31, 97]
});
},
},
"by-position-or-by-name calling convention": {
setUp: function () {
this.mopidy = new Mopidy({
webSocket: this.webSocket,
callingConvention: "by-position-or-by-name"
});
this.mopidy._createApi({
foo: {
params: ["bar", "baz"]
}
});
this.sendStub = this.stub(this.mopidy, "_send");
},
"must be turned on manually": function () {
assert.equals(
this.mopidy._settings.callingConvention,
"by-position-or-by-name");
},
"sends no params if no arguments passed to function": function () {
this.mopidy.foo();
assert.calledOnceWith(this.sendStub, {method: "foo"});
},
"sends by-position if argument is a list": function () {
this.mopidy.foo([31, 97]);
assert.calledOnceWith(this.sendStub, {
method: "foo",
params: [31, 97]
});
},
"sends by-name if argument is an object": function () {
this.mopidy.foo({bar: 31, baz: 97});
assert.calledOnceWith(this.sendStub, {
method: "foo",
params: {bar: 31, baz: 97}
});
},
"rejects with error if more than one argument": function (done) {
var promise = this.mopidy.foo([1, 2], {c: 3, d: 4});
refute.called(this.sendStub);
promise.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert.equals(
error.message,
"Expected zero arguments, a single array, " +
"or a single object.");
})
);
},
"rejects with error if string": function (done) {
var promise = this.mopidy.foo("hello");
refute.called(this.sendStub);
promise.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert(error instanceof TypeError);
assert.equals(
error.message, "Expected an array or an object.");
})
);
},
"rejects with error if number": function (done) {
var promise = this.mopidy.foo(1337);
refute.called(this.sendStub);
promise.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert(error instanceof TypeError);
assert.equals(
error.message, "Expected an array or an object.");
})
);
}
}
}
});

View File

@ -1,24 +1,33 @@
from __future__ import unicode_literals
from __future__ import absolute_import, print_function, unicode_literals
import platform
import sys
import textwrap
import warnings
from distutils.version import StrictVersion as SV
import pykka
if not (2, 7) <= sys.version_info < (3,):
sys.exit(
'Mopidy requires Python >= 2.7, < 3, but found %s' %
'.'.join(map(str, sys.version_info[:3])))
'ERROR: Mopidy requires Python 2.7, but found %s.' %
platform.python_version())
if (isinstance(pykka.__version__, basestring)
and not SV('1.1') <= SV(pykka.__version__) < SV('2.0')):
sys.exit(
'Mopidy requires Pykka >= 1.1, < 2, but found %s' % pykka.__version__)
try:
import gobject # noqa
except ImportError:
print(textwrap.dedent("""
ERROR: The gobject Python package was not found.
Mopidy requires GStreamer (and GObject) to work. These are C libraries
with a number of dependencies themselves, and cannot be installed with
the regular Python tools like pip.
Please see http://docs.mopidy.com/en/latest/installation/ for
instructions on how to install the required dependencies.
"""))
raise
warnings.filterwarnings('ignore', 'could not open display')
__version__ = '0.19.5'
__version__ = '1.0.0'

View File

@ -1,4 +1,4 @@
from __future__ import print_function, unicode_literals
from __future__ import absolute_import, print_function, unicode_literals
import logging
import os

View File

@ -1,8 +1,7 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
# flake8: noqa
from .actor import Audio
from .dummy import DummyAudio
from .listener import AudioListener
from .constants import PlaybackState
from .utils import (

View File

@ -1,15 +1,18 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import logging
import os
import gobject
import pygst
pygst.require('0.10')
import gst # noqa
import gst.pbutils
import pykka
from mopidy import exceptions
from mopidy.audio import playlists, utils
from mopidy.audio.constants import PlaybackState
from mopidy.audio.listener import AudioListener
@ -18,6 +21,11 @@ from mopidy.utils import process
logger = logging.getLogger(__name__)
# This logger is only meant for debug logging of low level gstreamer info such
# as callbacks, event, messages and direct interaction with GStreamer such as
# set_state on a pipeline.
gst_logger = logging.getLogger('mopidy.audio.gst')
playlists.register_typefinders()
playlists.register_elements()
@ -40,225 +48,270 @@ MB = 1 << 20
# GST_PLAY_FLAG_DEINTERLACE (1<<9)
# GST_PLAY_FLAG_SOFT_COLORBALANCE (1<<10)
# Default flags to use for playbin: AUDIO, SOFT_VOLUME, DOWNLOAD
PLAYBIN_FLAGS = (1 << 1) | (1 << 4) | (1 << 7)
PLAYBIN_VIS_FLAGS = PLAYBIN_FLAGS | (1 << 3)
# Default flags to use for playbin: AUDIO, SOFT_VOLUME
# TODO: consider removing soft volume when we do multi outputs and handling it
# ourselves.
PLAYBIN_FLAGS = (1 << 1) | (1 << 4)
class Audio(pykka.ThreadingActor):
"""
Audio output through `GStreamer <http://gstreamer.freedesktop.org/>`_.
"""
class _Signals(object):
"""Helper for tracking gobject signal registrations"""
def __init__(self):
self._ids = {}
#: The GStreamer state mapped to :class:`mopidy.audio.PlaybackState`
state = PlaybackState.STOPPED
def connect(self, element, event, func, *args):
"""Connect a function + args to signal event on an element.
def __init__(self, config, mixer):
super(Audio, self).__init__()
Each event may only be handled by one callback in this implementation.
"""
assert (element, event) not in self._ids
self._ids[(element, event)] = element.connect(event, func, *args)
self._config = config
self._mixer = mixer
self._target_state = gst.STATE_NULL
self._buffering = False
def disconnect(self, element, event):
"""Disconnect whatever handler we have for and element+event pair.
self._playbin = None
self._signal_ids = {} # {(element, event): signal_id}
self._appsrc = None
self._appsrc_caps = None
self._appsrc_need_data_callback = None
self._appsrc_enough_data_callback = None
self._appsrc_seek_data_callback = None
def on_start(self):
try:
self._setup_preferences()
self._setup_playbin()
self._setup_output()
self._setup_mixer()
self._setup_visualizer()
self._setup_message_processor()
except gobject.GError as ex:
logger.exception(ex)
process.exit_process()
def on_stop(self):
self._teardown_message_processor()
self._teardown_mixer()
self._teardown_playbin()
def _connect(self, element, event, *args):
"""Helper to keep track of signal ids based on element+event"""
self._signal_ids[(element, event)] = element.connect(event, *args)
def _disconnect(self, element, event):
"""Helper to disconnect signals created with _connect helper."""
signal_id = self._signal_ids.pop((element, event), None)
Does nothing it the handler has already been removed.
"""
signal_id = self._ids.pop((element, event), None)
if signal_id is not None:
element.disconnect(signal_id)
def _setup_preferences(self):
# Fix for https://github.com/mopidy/mopidy/issues/604
registry = gst.registry_get_default()
jacksink = registry.find_feature(
'jackaudiosink', gst.TYPE_ELEMENT_FACTORY)
if jacksink:
jacksink.set_rank(gst.RANK_SECONDARY)
def clear(self):
"""Clear all registered signal handlers."""
for element, event in self._ids.keys():
element.disconnect(self._ids.pop((element, event)))
def _setup_playbin(self):
playbin = gst.element_factory_make('playbin2')
playbin.set_property('flags', PLAYBIN_FLAGS)
playbin.set_property('buffer-size', 2 * 1024 * 1024)
playbin.set_property('buffer-duration', 2 * gst.SECOND)
# TODO: expose this as a property on audio?
class _Appsrc(object):
"""Helper class for dealing with appsrc based playback."""
def __init__(self):
self._signals = _Signals()
self.reset()
self._connect(playbin, 'about-to-finish', self._on_about_to_finish)
self._connect(playbin, 'notify::source', self._on_new_source)
self._connect(playbin, 'source-setup', self._on_source_setup)
def reset(self):
"""Reset the helper.
self._playbin = playbin
Should be called whenever the source changes and we are not setting up
a new appsrc.
"""
self.prepare(None, None, None, None)
def _on_about_to_finish(self, element):
source, self._appsrc = self._appsrc, None
if source is None:
return
self._appsrc_caps = None
def prepare(self, caps, need_data, enough_data, seek_data):
"""Store info we will need when the appsrc element gets installed."""
self._signals.clear()
self._source = None
self._caps = caps
self._need_data_callback = need_data
self._seek_data_callback = seek_data
self._enough_data_callback = enough_data
self._disconnect(source, 'need-data')
self._disconnect(source, 'enough-data')
self._disconnect(source, 'seek-data')
def configure(self, source):
"""Configure the supplied source for use.
def _on_new_source(self, element, pad):
uri = element.get_property('uri')
if not uri or not uri.startswith('appsrc://'):
return
source = element.get_property('source')
source.set_property('caps', self._appsrc_caps)
Should be called whenever we get a new appsrc.
"""
source.set_property('caps', self._caps)
source.set_property('format', b'time')
source.set_property('stream-type', b'seekable')
source.set_property('max-bytes', 1 * MB)
source.set_property('min-percent', 50)
self._connect(source, 'need-data', self._appsrc_on_need_data)
self._connect(source, 'enough-data', self._appsrc_on_enough_data)
self._connect(source, 'seek-data', self._appsrc_on_seek_data)
if self._need_data_callback:
self._signals.connect(source, 'need-data', self._on_signal,
self._need_data_callback)
if self._seek_data_callback:
self._signals.connect(source, 'seek-data', self._on_signal,
self._seek_data_callback)
if self._enough_data_callback:
self._signals.connect(source, 'enough-data', self._on_signal, None,
self._enough_data_callback)
self._appsrc = source
self._source = source
def _on_source_setup(self, element, source):
scheme = 'http'
hostname = self._config['proxy']['hostname']
port = 80
def push(self, buffer_):
if self._source is None:
return False
if hasattr(source.props, 'proxy') and hostname:
if self._config['proxy']['port']:
port = self._config['proxy']['port']
if self._config['proxy']['scheme']:
scheme = self._config['proxy']['scheme']
if buffer_ is None:
gst_logger.debug('Sending appsrc end-of-stream event.')
return self._source.emit('end-of-stream') == gst.FLOW_OK
else:
return self._source.emit('push-buffer', buffer_) == gst.FLOW_OK
proxy = "%s://%s:%d" % (scheme, hostname, port)
source.set_property('proxy', proxy)
source.set_property('proxy-id', self._config['proxy']['username'])
source.set_property('proxy-pw', self._config['proxy']['password'])
def _appsrc_on_need_data(self, appsrc, gst_length_hint):
length_hint = utils.clocktime_to_millisecond(gst_length_hint)
if self._appsrc_need_data_callback is not None:
self._appsrc_need_data_callback(length_hint)
def _on_signal(self, element, clocktime, func):
# This shim is used to ensure we always return true, and also handles
# that not all the callbacks have a time argument.
if clocktime is None:
func()
else:
func(utils.clocktime_to_millisecond(clocktime))
return True
def _appsrc_on_enough_data(self, appsrc):
if self._appsrc_enough_data_callback is not None:
self._appsrc_enough_data_callback()
return True
def _appsrc_on_seek_data(self, appsrc, gst_position):
position = utils.clocktime_to_millisecond(gst_position)
if self._appsrc_seek_data_callback is not None:
self._appsrc_seek_data_callback(position)
return True
# TODO: expose this as a property on audio when #790 gets further along.
class _Outputs(gst.Bin):
def __init__(self):
gst.Bin.__init__(self)
def _teardown_playbin(self):
self._disconnect(self._playbin, 'about-to-finish')
self._disconnect(self._playbin, 'notify::source')
self._disconnect(self._playbin, 'source-setup')
self._playbin.set_state(gst.STATE_NULL)
self._tee = gst.element_factory_make('tee')
self.add(self._tee)
def _setup_output(self):
output_desc = self._config['audio']['output']
# Queue element to buy us time between the about to finish event and
# the actual switch, i.e. about to switch can block for longer thanks
# to this queue.
# TODO: make the min-max values a setting?
# TODO: this does not belong in this class.
queue = gst.element_factory_make('queue')
queue.set_property('max-size-buffers', 0)
queue.set_property('max-size-bytes', 0)
queue.set_property('max-size-time', 5 * gst.SECOND)
queue.set_property('min-threshold-time', 3 * gst.SECOND)
self.add(queue)
queue.link(self._tee)
ghost_pad = gst.GhostPad('sink', queue.get_pad('sink'))
self.add_pad(ghost_pad)
# Add an always connected fakesink which respects the clock so the tee
# doesn't fail even if we don't have any outputs.
fakesink = gst.element_factory_make('fakesink')
fakesink.set_property('sync', True)
self._add(fakesink)
def add_output(self, description):
# XXX This only works for pipelines not in use until #790 gets done.
try:
output = gst.parse_bin_from_description(
output_desc, ghost_unconnected_pads=True)
self._playbin.set_property('audio-sink', output)
logger.info('Audio output set to "%s"', output_desc)
description, ghost_unconnected_pads=True)
except gobject.GError as ex:
logger.error(
'Failed to create audio output "%s": %s', output_desc, ex)
process.exit_process()
'Failed to create audio output "%s": %s', description, ex)
raise exceptions.AudioException(bytes(ex))
def _setup_mixer(self):
if self._config['audio']['mixer'] != 'software':
return
self._mixer.audio = self.actor_ref.proxy()
self._connect(self._playbin, 'notify::volume', self._on_mixer_change)
self._connect(self._playbin, 'notify::mute', self._on_mixer_change)
self._add(output)
logger.info('Audio output set to "%s"', description)
# The Mopidy startup procedure will set the initial volume of a mixer,
# but this happens before the audio actor is injected into the software
# mixer and has no effect. Thus, we need to set the initial volume
# again.
initial_volume = self._config['audio']['mixer_volume']
if initial_volume is not None:
self._mixer.set_volume(initial_volume)
def _add(self, element):
# All tee branches need a queue in front of them.
queue = gst.element_factory_make('queue')
self.add(element)
self.add(queue)
queue.link(element)
self._tee.link(queue)
def _on_mixer_change(self, element, gparamspec):
self._mixer.trigger_events_for_changed_values()
def _teardown_mixer(self):
if self._config['audio']['mixer'] != 'software':
return
self._disconnect(self._playbin, 'notify::volume')
self._disconnect(self._playbin, 'notify::mute')
self._mixer.audio = None
class SoftwareMixer(object):
pykka_traversable = True
def _setup_visualizer(self):
visualizer_element = self._config['audio']['visualizer']
if not visualizer_element:
return
try:
visualizer = gst.element_factory_make(visualizer_element)
self._playbin.set_property('vis-plugin', visualizer)
self._playbin.set_property('flags', PLAYBIN_VIS_FLAGS)
logger.info('Audio visualizer set to "%s"', visualizer_element)
except gobject.GError as ex:
logger.error(
'Failed to create audio visualizer "%s": %s',
visualizer_element, ex)
def __init__(self, mixer):
self._mixer = mixer
self._element = None
self._last_volume = None
self._last_mute = None
self._signals = _Signals()
def _setup_message_processor(self):
bus = self._playbin.get_bus()
def setup(self, element, mixer_ref):
self._element = element
self._signals.connect(element, 'notify::volume', self._volume_changed)
self._signals.connect(element, 'notify::mute', self._mute_changed)
self._mixer.setup(mixer_ref)
def teardown(self):
self._signals.clear()
self._mixer.teardown()
def get_volume(self):
return int(round(self._element.get_property('volume') * 100))
def set_volume(self, volume):
self._element.set_property('volume', volume / 100.0)
def get_mute(self):
return self._element.get_property('mute')
def set_mute(self, mute):
return self._element.set_property('mute', bool(mute))
def _volume_changed(self, element, property_):
old_volume, self._last_volume = self._last_volume, self.get_volume()
if old_volume != self._last_volume:
gst_logger.debug('Notify volume: %s', self._last_volume / 100.0)
self._mixer.trigger_volume_changed(self._last_volume)
def _mute_changed(self, element, property_):
old_mute, self._last_mute = self._last_mute, self.get_mute()
if old_mute != self._last_mute:
gst_logger.debug('Notify mute: %s', self._last_mute)
self._mixer.trigger_mute_changed(self._last_mute)
class _Handler(object):
def __init__(self, audio):
self._audio = audio
self._element = None
self._pad = None
self._message_handler_id = None
self._event_handler_id = None
def setup_message_handling(self, element):
self._element = element
bus = element.get_bus()
bus.add_signal_watch()
self._connect(bus, 'message', self._on_message)
self._message_handler_id = bus.connect('message', self.on_message)
def _teardown_message_processor(self):
bus = self._playbin.get_bus()
self._disconnect(bus, 'message')
def setup_event_handling(self, pad):
self._pad = pad
self._event_handler_id = pad.add_event_probe(self.on_event)
def teardown_message_handling(self):
bus = self._element.get_bus()
bus.remove_signal_watch()
bus.disconnect(self._message_handler_id)
self._message_handler_id = None
def _on_message(self, bus, msg):
if msg.type == gst.MESSAGE_STATE_CHANGED and msg.src == self._playbin:
self._on_playbin_state_changed(*msg.parse_state_changed())
def teardown_event_handling(self):
self._pad.remove_event_probe(self._event_handler_id)
self._event_handler_id = None
def on_message(self, bus, msg):
if msg.type == gst.MESSAGE_STATE_CHANGED and msg.src == self._element:
self.on_playbin_state_changed(*msg.parse_state_changed())
elif msg.type == gst.MESSAGE_BUFFERING:
self._on_buffering(msg.parse_buffering())
self.on_buffering(msg.parse_buffering(), msg.structure)
elif msg.type == gst.MESSAGE_EOS:
self._on_end_of_stream()
self.on_end_of_stream()
elif msg.type == gst.MESSAGE_ERROR:
self._on_error(*msg.parse_error())
self.on_error(*msg.parse_error())
elif msg.type == gst.MESSAGE_WARNING:
self._on_warning(*msg.parse_warning())
self.on_warning(*msg.parse_warning())
elif msg.type == gst.MESSAGE_ASYNC_DONE:
self.on_async_done()
elif msg.type == gst.MESSAGE_TAG:
self.on_tag(msg.parse_tag())
elif msg.type == gst.MESSAGE_ELEMENT:
if gst.pbutils.is_missing_plugin_message(msg):
self.on_missing_plugin(msg)
def on_event(self, pad, event):
if event.type == gst.EVENT_NEWSEGMENT:
self.on_new_segment(*event.parse_new_segment())
elif event.type == gst.EVENT_SINK_MESSAGE:
# Handle stream changed messages when they reach our output bin.
# If we listen for it on the bus we get one per tee branch.
msg = event.parse_sink_message()
if msg.structure.has_name('playbin2-stream-changed'):
self.on_stream_changed(msg.structure['uri'])
return True
def on_playbin_state_changed(self, old_state, new_state, pending_state):
gst_logger.debug('Got state-changed message: old=%s new=%s pending=%s',
old_state.value_name, new_state.value_name,
pending_state.value_name)
def _on_playbin_state_changed(self, old_state, new_state, pending_state):
if new_state == gst.STATE_READY and pending_state == gst.STATE_NULL:
# XXX: We're not called on the last state change when going down to
# NULL, so we rewrite the second to last call to get the expected
@ -273,43 +326,210 @@ class Audio(pykka.ThreadingActor):
return # Ignore READY state as it's GStreamer specific
new_state = _GST_STATE_MAPPING[new_state]
old_state, self.state = self.state, new_state
old_state, self._audio.state = self._audio.state, new_state
target_state = _GST_STATE_MAPPING[self._target_state]
target_state = _GST_STATE_MAPPING[self._audio._target_state]
if target_state == new_state:
target_state = None
logger.debug(
'Triggering event: state_changed(old_state=%s, new_state=%s, '
'target_state=%s)', old_state, new_state, target_state)
logger.debug('Audio event: state_changed(old_state=%s, new_state=%s, '
'target_state=%s)', old_state, new_state, target_state)
AudioListener.send('state_changed', old_state=old_state,
new_state=new_state, target_state=target_state)
if new_state == PlaybackState.STOPPED:
logger.debug('Audio event: stream_changed(uri=None)')
AudioListener.send('stream_changed', uri=None)
def _on_buffering(self, percent):
if percent < 10 and not self._buffering:
self._playbin.set_state(gst.STATE_PAUSED)
self._buffering = True
if 'GST_DEBUG_DUMP_DOT_DIR' in os.environ:
gst.DEBUG_BIN_TO_DOT_FILE(
self._audio._playbin, gst.DEBUG_GRAPH_SHOW_ALL, 'mopidy')
def on_buffering(self, percent, structure=None):
if structure and structure.has_field('buffering-mode'):
if structure['buffering-mode'] == gst.BUFFERING_LIVE:
return # Live sources stall in paused.
level = logging.getLevelName('TRACE')
if percent < 10 and not self._audio._buffering:
self._audio._playbin.set_state(gst.STATE_PAUSED)
self._audio._buffering = True
level = logging.DEBUG
if percent == 100:
self._buffering = False
if self._target_state == gst.STATE_PLAYING:
self._playbin.set_state(gst.STATE_PLAYING)
self._audio._buffering = False
if self._audio._target_state == gst.STATE_PLAYING:
self._audio._playbin.set_state(gst.STATE_PLAYING)
level = logging.DEBUG
logger.debug('Buffer %d%% full', percent)
gst_logger.log(level, 'Got buffering message: percent=%d%%', percent)
def _on_end_of_stream(self):
logger.debug('Triggering reached_end_of_stream event')
def on_end_of_stream(self):
gst_logger.debug('Got end-of-stream message.')
logger.debug('Audio event: reached_end_of_stream()')
self._audio._tags = {}
AudioListener.send('reached_end_of_stream')
def _on_error(self, error, debug):
logger.error(
'%s Debug message: %s',
str(error).decode('utf-8'), debug.decode('utf-8') or 'None')
self.stop_playback()
def on_error(self, error, debug):
gst_logger.error(str(error).decode('utf-8'))
if debug:
gst_logger.debug(debug.decode('utf-8'))
# TODO: is this needed?
self._audio.stop_playback()
def _on_warning(self, error, debug):
logger.warning(
'%s Debug message: %s',
str(error).decode('utf-8'), debug.decode('utf-8') or 'None')
def on_warning(self, error, debug):
gst_logger.warning(str(error).decode('utf-8'))
if debug:
gst_logger.debug(debug.decode('utf-8'))
def on_async_done(self):
gst_logger.debug('Got async-done.')
def on_tag(self, taglist):
tags = utils.convert_taglist(taglist)
self._audio._tags.update(tags)
logger.debug('Audio event: tags_changed(tags=%r)', tags.keys())
AudioListener.send('tags_changed', tags=tags.keys())
def on_missing_plugin(self, msg):
desc = gst.pbutils.missing_plugin_message_get_description(msg)
debug = gst.pbutils.missing_plugin_message_get_installer_detail(msg)
gst_logger.debug('Got missing-plugin message: description:%s', desc)
logger.warning('Could not find a %s to handle media.', desc)
if gst.pbutils.install_plugins_supported():
logger.info('You might be able to fix this by running: '
'gst-installer "%s"', debug)
# TODO: store the missing plugins installer info in a file so we can
# can provide a 'mopidy install-missing-plugins' if the system has the
# required helper installed?
def on_new_segment(self, update, rate, format_, start, stop, position):
gst_logger.debug('Got new-segment event: update=%s rate=%s format=%s '
'start=%s stop=%s position=%s', update, rate,
format_.value_name, start, stop, position)
position_ms = position // gst.MSECOND
logger.debug('Audio event: position_changed(position=%s)', position_ms)
AudioListener.send('position_changed', position=position_ms)
def on_stream_changed(self, uri):
gst_logger.debug('Got stream-changed message: uri=%s', uri)
logger.debug('Audio event: stream_changed(uri=%s)', uri)
AudioListener.send('stream_changed', uri=uri)
# TODO: create a player class which replaces the actors internals
class Audio(pykka.ThreadingActor):
"""
Audio output through `GStreamer <http://gstreamer.freedesktop.org/>`_.
"""
#: The GStreamer state mapped to :class:`mopidy.audio.PlaybackState`
state = PlaybackState.STOPPED
#: The software mixing interface :class:`mopidy.audio.actor.SoftwareMixer`
mixer = None
def __init__(self, config, mixer):
super(Audio, self).__init__()
self._config = config
self._target_state = gst.STATE_NULL
self._buffering = False
self._tags = {}
self._playbin = None
self._outputs = None
self._about_to_finish_callback = None
self._handler = _Handler(self)
self._appsrc = _Appsrc()
self._signals = _Signals()
if mixer and self._config['audio']['mixer'] == 'software':
self.mixer = SoftwareMixer(mixer)
def on_start(self):
try:
self._setup_preferences()
self._setup_playbin()
self._setup_output()
self._setup_mixer()
except gobject.GError as ex:
logger.exception(ex)
process.exit_process()
def on_stop(self):
self._teardown_mixer()
self._teardown_playbin()
def _setup_preferences(self):
# TODO: move out of audio actor?
# Fix for https://github.com/mopidy/mopidy/issues/604
registry = gst.registry_get_default()
jacksink = registry.find_feature(
'jackaudiosink', gst.TYPE_ELEMENT_FACTORY)
if jacksink:
jacksink.set_rank(gst.RANK_SECONDARY)
def _setup_playbin(self):
playbin = gst.element_factory_make('playbin2')
playbin.set_property('flags', PLAYBIN_FLAGS)
# TODO: turn into config values...
playbin.set_property('buffer-size', 2 * 1024 * 1024)
playbin.set_property('buffer-duration', 2 * gst.SECOND)
self._signals.connect(playbin, 'source-setup', self._on_source_setup)
self._signals.connect(playbin, 'about-to-finish',
self._on_about_to_finish)
self._playbin = playbin
self._handler.setup_message_handling(playbin)
def _teardown_playbin(self):
self._handler.teardown_message_handling()
self._handler.teardown_event_handling()
self._signals.disconnect(self._playbin, 'about-to-finish')
self._signals.disconnect(self._playbin, 'source-setup')
self._playbin.set_state(gst.STATE_NULL)
def _setup_output(self):
# We don't want to use outputs for regular testing, so just install
# an unsynced fakesink when someone asks for a 'testoutput'.
if self._config['audio']['output'] == 'testoutput':
self._outputs = gst.element_factory_make('fakesink')
else:
self._outputs = _Outputs()
try:
self._outputs.add_output(self._config['audio']['output'])
except exceptions.AudioException:
process.exit_process() # TODO: move this up the chain
self._handler.setup_event_handling(self._outputs.get_pad('sink'))
self._playbin.set_property('audio-sink', self._outputs)
def _setup_mixer(self):
if self.mixer:
self.mixer.setup(self._playbin, self.actor_ref.proxy().mixer)
def _teardown_mixer(self):
if self.mixer:
self.mixer.teardown()
def _on_about_to_finish(self, element):
gst_logger.debug('Got about-to-finish event.')
if self._about_to_finish_callback:
logger.debug('Running about to finish callback.')
self._about_to_finish_callback()
def _on_source_setup(self, element, source):
gst_logger.debug('Got source-setup: element=%s', source)
if source.get_factory().get_name() == 'appsrc':
self._appsrc.configure(source)
else:
self._appsrc.reset()
utils.setup_proxy(source, self._config['proxy'])
def set_uri(self, uri):
"""
@ -320,8 +540,20 @@ class Audio(pykka.ThreadingActor):
:param uri: the URI to play
:type uri: string
"""
# XXX: Hack to workaround issue on Mac OS X where volume level
# does not persist between track changes. mopidy/mopidy#886
if self.mixer is not None:
current_volume = self.mixer.get_volume()
else:
current_volume = None
self._tags = {} # TODO: add test for this somehow
self._playbin.set_property('uri', uri)
if self.mixer is not None and current_volume is not None:
self.mixer.set_volume(current_volume)
def set_appsrc(
self, caps, need_data=None, enough_data=None, seek_data=None):
"""
@ -340,29 +572,27 @@ class Audio(pykka.ThreadingActor):
to continue playback
:type seek_data: callable which takes time position in ms
"""
if isinstance(caps, unicode):
caps = caps.encode('utf-8')
self._appsrc_caps = gst.Caps(caps)
self._appsrc_need_data_callback = need_data
self._appsrc_enough_data_callback = enough_data
self._appsrc_seek_data_callback = seek_data
self._appsrc.prepare(
gst.Caps(bytes(caps)), need_data, enough_data, seek_data)
self._playbin.set_property('uri', 'appsrc://')
def emit_data(self, buffer_):
"""
Call this to deliver raw audio data to be played.
Note that the uri must be set to ``appsrc://`` for this to work.
If the buffer is :class:`None`, the end-of-stream token is put on the
playbin. We will get a GStreamer message when the stream playback
reaches the token, and can then do any end-of-stream related tasks.
Returns true if data was delivered.
Note that the URI must be set to ``appsrc://`` for this to work.
Returns :class:`True` if data was delivered.
:param buffer_: buffer to pass to appsrc
:type buffer_: :class:`gst.Buffer`
:type buffer_: :class:`gst.Buffer` or :class:`None`
:rtype: boolean
"""
if not self._appsrc:
return False
return self._appsrc.emit('push-buffer', buffer_) == gst.FLOW_OK
return self._appsrc.push(buffer_)
def emit_end_of_stream(self):
"""
@ -371,8 +601,24 @@ class Audio(pykka.ThreadingActor):
We will get a GStreamer message when the stream playback reaches the
token, and can then do any end-of-stream related tasks.
.. deprecated:: 1.0
Use :meth:`emit_data` with a :class:`None` buffer instead.
"""
self._playbin.get_property('source').emit('end-of-stream')
self._appsrc.push(None)
def set_about_to_finish_callback(self, callback):
"""
Configure audio to use an about-to-finish callback.
This should be used to achieve gapless playback. For this to work the
callback *MUST* call :meth:`set_uri` with the new URI to play and
block until this call has been made. :meth:`prepare_change` is not
needed before :meth:`set_uri` in this one special case.
:param callable callback: Callback to run when we need the next URI.
"""
self._about_to_finish_callback = callback
def get_position(self):
"""
@ -384,6 +630,8 @@ class Audio(pykka.ThreadingActor):
gst_position = self._playbin.query_position(gst.FORMAT_TIME)[0]
return utils.clocktime_to_millisecond(gst_position)
except gst.QueryError:
# TODO: take state into account for this and possibly also return
# None as the unknown value instead of zero?
logger.debug('Position query failed')
return 0
@ -395,9 +643,12 @@ class Audio(pykka.ThreadingActor):
:type position: int
:rtype: :class:`True` if successful, else :class:`False`
"""
# TODO: double check seek flags in use.
gst_position = utils.millisecond_to_clocktime(position)
return self._playbin.seek_simple(
result = self._playbin.seek_simple(
gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, gst_position)
gst_logger.debug('Sent flushing seek: position=%s', gst_position)
return result
def start_playback(self):
"""
@ -435,6 +686,25 @@ class Audio(pykka.ThreadingActor):
self._buffering = False
return self._set_state(gst.STATE_NULL)
def wait_for_state_change(self):
"""Block until any pending state changes are complete.
Should only be used by tests.
"""
self._playbin.get_state()
def enable_sync_handler(self):
"""Enable manual processing of messages from bus.
Should only be used by tests.
"""
def sync_handler(bus, message):
self._handler.on_message(bus, message)
return gst.BUS_DROP
bus = self._playbin.get_bus()
bus.set_sync_handler(sync_handler)
def _set_state(self, state):
"""
Internal method for setting the raw GStreamer state.
@ -458,65 +728,18 @@ class Audio(pykka.ThreadingActor):
"""
self._target_state = state
result = self._playbin.set_state(state)
gst_logger.debug('State change to %s: result=%s', state.value_name,
result.value_name)
if result == gst.STATE_CHANGE_FAILURE:
logger.warning(
'Setting GStreamer state to %s failed', state.value_name)
return False
elif result == gst.STATE_CHANGE_ASYNC:
logger.debug(
'Setting GStreamer state to %s is async', state.value_name)
return True
else:
logger.debug(
'Setting GStreamer state to %s is OK', state.value_name)
return True
def get_volume(self):
"""
Get volume level of the software mixer.
Example values:
0:
Minimum volume.
100:
Maximum volume.
:rtype: int in range [0..100]
"""
return int(round(self._playbin.get_property('volume') * 100))
def set_volume(self, volume):
"""
Set volume level of the software mixer.
:param volume: the volume in the range [0..100]
:type volume: int
:rtype: :class:`True` if successful, else :class:`False`
"""
self._playbin.set_property('volume', volume / 100.0)
return True
def get_mute(self):
"""
Get mute status of the software mixer.
:rtype: :class:`True` if muted, :class:`False` if unmuted,
:class:`None` if no mixer is installed.
"""
return self._playbin.get_property('mute')
def set_mute(self, mute):
"""
Mute or unmute of the software mixer.
:param mute: Whether to mute the mixer or not.
:type mute: bool
:rtype: :class:`True` if successful, else :class:`False`
"""
self._playbin.set_property('mute', bool(mute))
# TODO: at this point we could already emit stopped event instead
# of faking it in the message handling when result=OK
return True
# TODO: bake this into setup appsrc perhaps?
def set_metadata(self, track):
"""
Set track metadata for currently playing song.
@ -547,4 +770,22 @@ class Audio(pykka.ThreadingActor):
taglist[gst.TAG_ALBUM] = track.album.name
event = gst.event_new_tag(taglist)
# TODO: check if we get this back on our own bus?
self._playbin.send_event(event)
gst_logger.debug('Sent tag event: track=%s', track.uri)
def get_current_tags(self):
"""
Get the currently playing media's tags.
If no tags have been found, or nothing is playing this returns an empty
dictionary. For each set of tags we collect a tags_changed event is
emitted with the keys of the changes tags. After such calls users may
call this function to get the updated values.
:rtype: {key: [values]} dict for the current media.
"""
# TODO: should this be a (deep) copy? most likely yes
# TODO: should we return None when stopped?
# TODO: support only fetching keys we care about?
return self._tags

View File

@ -1,4 +1,4 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
class PlaybackState(object):

View File

@ -1,68 +0,0 @@
"""A dummy audio actor for use in tests.
This class implements the audio API in the simplest way possible. It is used in
tests of the core and backends.
"""
from __future__ import unicode_literals
import pykka
from .constants import PlaybackState
from .listener import AudioListener
class DummyAudio(pykka.ThreadingActor):
def __init__(self):
super(DummyAudio, self).__init__()
self.state = PlaybackState.STOPPED
self._position = 0
def set_on_end_of_track(self, callback):
pass
def set_uri(self, uri):
pass
def set_appsrc(self, *args, **kwargs):
pass
def emit_data(self, buffer_):
pass
def emit_end_of_stream(self):
pass
def get_position(self):
return self._position
def set_position(self, position):
self._position = position
return True
def start_playback(self):
return self._change_state(PlaybackState.PLAYING)
def pause_playback(self):
return self._change_state(PlaybackState.PAUSED)
def prepare_change(self):
return True
def stop_playback(self):
return self._change_state(PlaybackState.STOPPED)
def get_volume(self):
return 0
def set_volume(self, volume):
pass
def set_metadata(self, track):
pass
def _change_state(self, new_state):
old_state, self.state = self.state, new_state
AudioListener.send(
'state_changed', old_state=old_state, new_state=new_state)
return True

View File

@ -1,4 +1,4 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
from mopidy import listener
@ -27,6 +27,26 @@ class AudioListener(listener.Listener):
"""
pass
def stream_changed(self, uri):
"""
Called whenever the audio stream changes.
*MAY* be implemented by actor.
:param string uri: URI the stream has started playing.
"""
pass
def position_changed(self, position):
"""
Called whenever the position of the stream changes.
*MAY* be implemented by actor.
:param int position: Position in milliseconds.
"""
pass
def state_changed(self, old_state, new_state, target_state):
"""
Called after the playback state have changed.
@ -55,3 +75,21 @@ class AudioListener(listener.Listener):
field or :class:`None` if this is a final state.
"""
pass
def tags_changed(self, tags):
"""
Called whenever the current audio stream's tags change.
This event signals that some track metadata has been updated. This can
be metadata such as artists, titles, organization, or details about the
actual audio such as bit-rates, numbers of channels etc.
For the available tag keys please refer to GStreamer documentation for
tags.
*MAY* be implemented by actor.
:param tags: The tags that have just been updated.
:type tags: :class:`set` of strings
"""
pass

View File

@ -1,6 +1,5 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import ConfigParser as configparser
import io
import gobject
@ -9,6 +8,8 @@ import pygst
pygst.require('0.10')
import gst # noqa
from mopidy.compat import configparser
try:
import xml.etree.cElementTree as elementtree
except ImportError:
@ -57,11 +58,11 @@ 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'):
if found_header or line.startswith(b'#EXTM3U'):
found_header = True
else:
continue
if not line.startswith('#') and line.strip():
if not line.startswith(b'#') and line.strip():
yield line.strip()

View File

@ -1,47 +1,37 @@
from __future__ import unicode_literals
from __future__ import absolute_import, division, unicode_literals
import datetime
import os
import time
import collections
import pygst
pygst.require('0.10')
import gst # noqa
import gst.pbutils
from mopidy import exceptions
from mopidy.models import Album, Artist, Track
from mopidy.utils import encoding, path
from mopidy.audio import utils
from mopidy.utils import encoding
_missing_plugin_desc = gst.pbutils.missing_plugin_message_get_description
_Result = collections.namedtuple(
'Result', ('uri', 'tags', 'duration', 'seekable', 'mime'))
_RAW_AUDIO = gst.Caps(b'audio/x-raw-int; audio/x-raw-float')
# TODO: replace with a scan(uri, timeout=1000, proxy_config=None)?
class Scanner(object):
"""
Helper to get tags and other relevant info from URIs.
:param timeout: timeout for scanning a URI in ms
:type event: int
:param min_duration: minimum duration of scanned URI in ms, -1 for all.
:param proxy_config: dictionary containing proxy config strings.
:type event: int
"""
def __init__(self, timeout=1000, min_duration=100):
self._timeout_ms = timeout
self._min_duration_ms = min_duration
sink = gst.element_factory_make('fakesink')
audio_caps = gst.Caps(b'audio/x-raw-int; audio/x-raw-float')
pad_added = lambda src, pad: pad.link(sink.get_pad('sink'))
self._uribin = gst.element_factory_make('uridecodebin')
self._uribin.set_property('caps', audio_caps)
self._uribin.connect('pad-added', pad_added)
self._pipe = gst.element_factory_make('pipeline')
self._pipe.add(self._uribin)
self._pipe.add(sink)
self._bus = self._pipe.get_bus()
self._bus.set_flushing(True)
def __init__(self, timeout=1000, proxy_config=None):
self._timeout_ms = int(timeout)
self._proxy_config = proxy_config or {}
def scan(self, uri):
"""
@ -49,150 +39,124 @@ class Scanner(object):
:param uri: URI of the resource to scan.
:type event: string
:return: Dictionary of tags, duration, mtime and uri information.
:return: A named tuple containing
``(uri, tags, duration, seekable, mime)``.
``tags`` is a dictionary of lists for all the tags we found.
``duration`` is the length of the URI in milliseconds, or
:class:`None` if the URI has no duration. ``seekable`` is boolean.
indicating if a seek would succeed.
"""
tags, duration, seekable, mime = None, None, None, None
pipeline = _setup_pipeline(uri, self._proxy_config)
try:
self._setup(uri)
tags = self._collect() # Ensure collect before queries.
data = {'uri': uri, 'tags': tags,
'mtime': self._query_mtime(uri),
'duration': self._query_duration()}
_start_pipeline(pipeline)
tags, mime = _process(pipeline, self._timeout_ms)
duration = _query_duration(pipeline)
seekable = _query_seekable(pipeline)
finally:
self._reset()
pipeline.set_state(gst.STATE_NULL)
del pipeline
if self._min_duration_ms is None:
return data
elif data['duration'] >= self._min_duration_ms * gst.MSECOND:
return data
raise exceptions.ScannerError('Rejecting file with less than %dms '
'audio data.' % self._min_duration_ms)
def _setup(self, uri):
"""Primes the pipeline for collection."""
self._pipe.set_state(gst.STATE_READY)
self._uribin.set_property(b'uri', uri)
self._bus.set_flushing(False)
result = self._pipe.set_state(gst.STATE_PAUSED)
if result == gst.STATE_CHANGE_NO_PREROLL:
# Live sources don't pre-roll, so set to playing to get data.
self._pipe.set_state(gst.STATE_PLAYING)
def _collect(self):
"""Polls for messages to collect data."""
start = time.time()
timeout_s = self._timeout_ms / float(1000)
tags = {}
while time.time() - start < timeout_s:
if not self._bus.have_pending():
continue
message = self._bus.pop()
if message.type == gst.MESSAGE_ERROR:
raise exceptions.ScannerError(
encoding.locale_decode(message.parse_error()[0]))
elif message.type == gst.MESSAGE_EOS:
return tags
elif message.type == gst.MESSAGE_ASYNC_DONE:
if message.src == self._pipe:
return tags
elif message.type == gst.MESSAGE_TAG:
# Taglists are not really dicts, hence the lack of .items() and
# explicit .keys. We only keep the last tag for each key, as we
# assume this is the best, some formats will produce multiple
# taglists. Lastly we force everything to lists for conformity.
taglist = message.parse_tag()
for key in taglist.keys():
value = taglist[key]
if not isinstance(value, list):
value = [value]
tags[key] = value
raise exceptions.ScannerError('Timeout after %dms' % self._timeout_ms)
def _reset(self):
"""Ensures we cleanup child elements and flush the bus."""
self._bus.set_flushing(True)
self._pipe.set_state(gst.STATE_NULL)
def _query_duration(self):
try:
return self._pipe.query_duration(gst.FORMAT_TIME, None)[0]
except gst.QueryError:
return None
def _query_mtime(self, uri):
if not uri.startswith('file:'):
return None
return os.path.getmtime(path.uri_to_path(uri))
return _Result(uri, tags, duration, seekable, mime)
def _artists(tags, artist_name, artist_id=None):
# Name missing, don't set artist
if not tags.get(artist_name):
return None
# One artist name and id, provide artist with id.
if len(tags[artist_name]) == 1 and artist_id in tags:
return [Artist(name=tags[artist_name][0],
musicbrainz_id=tags[artist_id][0])]
# Multiple artist, provide artists without id.
return [Artist(name=name) for name in tags[artist_name]]
# Turns out it's _much_ faster to just create a new pipeline for every as
# decodebins and other elements don't seem to take well to being reused.
def _setup_pipeline(uri, proxy_config=None):
src = gst.element_make_from_uri(gst.URI_SRC, uri)
if not src:
raise exceptions.ScannerError('GStreamer can not open: %s' % uri)
typefind = gst.element_factory_make('typefind')
decodebin = gst.element_factory_make('decodebin2')
sink = gst.element_factory_make('fakesink')
pipeline = gst.element_factory_make('pipeline')
pipeline.add_many(src, typefind, decodebin, sink)
gst.element_link_many(src, typefind, decodebin)
if proxy_config:
utils.setup_proxy(src, proxy_config)
decodebin.set_property('caps', _RAW_AUDIO)
decodebin.connect('pad-added', _pad_added, sink)
typefind.connect('have-type', _have_type, decodebin)
return pipeline
def _date(tags):
if not tags.get(gst.TAG_DATE):
return None
def _have_type(element, probability, caps, decodebin):
decodebin.set_property('sink-caps', caps)
msg = gst.message_new_application(element, caps.get_structure(0))
element.get_bus().post(msg)
def _pad_added(element, pad, sink):
return pad.link(sink.get_pad('sink'))
def _start_pipeline(pipeline):
if pipeline.set_state(gst.STATE_PAUSED) == gst.STATE_CHANGE_NO_PREROLL:
pipeline.set_state(gst.STATE_PLAYING)
def _query_duration(pipeline):
try:
date = tags[gst.TAG_DATE][0]
return datetime.date(date.year, date.month, date.day).isoformat()
except ValueError:
duration = pipeline.query_duration(gst.FORMAT_TIME, None)[0]
except gst.QueryError:
return None
if duration < 0:
return None
else:
return duration // gst.MSECOND
def audio_data_to_track(data):
"""Convert taglist data + our extras to a track."""
tags = data['tags']
album_kwargs = {}
track_kwargs = {}
track_kwargs['composers'] = _artists(tags, gst.TAG_COMPOSER)
track_kwargs['performers'] = _artists(tags, gst.TAG_PERFORMER)
track_kwargs['artists'] = _artists(
tags, gst.TAG_ARTIST, 'musicbrainz-artistid')
album_kwargs['artists'] = _artists(
tags, gst.TAG_ALBUM_ARTIST, 'musicbrainz-albumartistid')
def _query_seekable(pipeline):
query = gst.query_new_seeking(gst.FORMAT_TIME)
pipeline.query(query)
return query.parse_seeking()[1]
track_kwargs['genre'] = '; '.join(tags.get(gst.TAG_GENRE, []))
track_kwargs['name'] = '; '.join(tags.get(gst.TAG_TITLE, []))
if not track_kwargs['name']:
track_kwargs['name'] = '; '.join(tags.get(gst.TAG_ORGANIZATION, []))
track_kwargs['comment'] = '; '.join(tags.get('comment', []))
if not track_kwargs['comment']:
track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_LOCATION, []))
if not track_kwargs['comment']:
track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_COPYRIGHT, []))
def _process(pipeline, timeout_ms):
clock = pipeline.get_clock()
bus = pipeline.get_bus()
timeout = timeout_ms * gst.MSECOND
tags, mime, missing_description = {}, None, None
track_kwargs['track_no'] = tags.get(gst.TAG_TRACK_NUMBER, [None])[0]
track_kwargs['disc_no'] = tags.get(gst.TAG_ALBUM_VOLUME_NUMBER, [None])[0]
track_kwargs['bitrate'] = tags.get(gst.TAG_BITRATE, [None])[0]
track_kwargs['musicbrainz_id'] = tags.get('musicbrainz-trackid', [None])[0]
types = (gst.MESSAGE_ELEMENT | gst.MESSAGE_APPLICATION | gst.MESSAGE_ERROR
| gst.MESSAGE_EOS | gst.MESSAGE_ASYNC_DONE | gst.MESSAGE_TAG)
album_kwargs['name'] = tags.get(gst.TAG_ALBUM, [None])[0]
album_kwargs['num_tracks'] = tags.get(gst.TAG_TRACK_COUNT, [None])[0]
album_kwargs['num_discs'] = tags.get(gst.TAG_ALBUM_VOLUME_COUNT, [None])[0]
album_kwargs['musicbrainz_id'] = tags.get('musicbrainz-albumid', [None])[0]
start = clock.get_time()
while timeout > 0:
message = bus.timed_pop_filtered(timeout, types)
track_kwargs['date'] = _date(tags)
track_kwargs['last_modified'] = int(data.get('mtime') or 0)
track_kwargs['length'] = max(
0, (data.get(gst.TAG_DURATION) or 0)) // gst.MSECOND
if message is None:
break
elif message.type == gst.MESSAGE_ELEMENT:
if gst.pbutils.is_missing_plugin_message(message):
missing_description = encoding.locale_decode(
_missing_plugin_desc(message))
elif message.type == gst.MESSAGE_APPLICATION:
mime = message.structure.get_name()
if mime.startswith('text/') or mime == 'application/xml':
return tags, mime
elif message.type == gst.MESSAGE_ERROR:
error = encoding.locale_decode(message.parse_error()[0])
if missing_description:
error = '%s (%s)' % (missing_description, error)
raise exceptions.ScannerError(error)
elif message.type == gst.MESSAGE_EOS:
return tags, mime
elif message.type == gst.MESSAGE_ASYNC_DONE:
if message.src == pipeline:
return tags, mime
elif message.type == gst.MESSAGE_TAG:
taglist = message.parse_tag()
# Note that this will only keep the last tag.
tags.update(utils.convert_taglist(taglist))
# Clear out any empty values we found
track_kwargs = {k: v for k, v in track_kwargs.items() if v}
album_kwargs = {k: v for k, v in album_kwargs.items() if v}
timeout -= clock.get_time() - start
track_kwargs['uri'] = data['uri']
track_kwargs['album'] = Album(**album_kwargs)
return Track(**track_kwargs)
raise exceptions.ScannerError('Timeout after %dms' % timeout_ms)

View File

@ -1,9 +1,18 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import datetime
import logging
import numbers
import pygst
pygst.require('0.10')
import gst # noqa
from mopidy import compat
from mopidy.models import Album, Artist, Track
logger = logging.getLogger(__name__)
def calculate_duration(num_samples, sample_rate):
"""Determine duration of samples using GStreamer helper for precise
@ -18,7 +27,7 @@ def create_buffer(data, capabilites=None, timestamp=None, duration=None):
"""
buffer_ = gst.Buffer(data)
if capabilites:
if isinstance(capabilites, basestring):
if isinstance(capabilites, compat.string_types):
capabilites = gst.caps_from_string(capabilites)
buffer_.set_caps(capabilites)
if timestamp:
@ -54,3 +63,138 @@ def supported_uri_schemes(uri_schemes):
supported_schemes.add(uri)
return supported_schemes
def _artists(tags, artist_name, artist_id=None):
# Name missing, don't set artist
if not tags.get(artist_name):
return None
# One artist name and id, provide artist with id.
if len(tags[artist_name]) == 1 and artist_id in tags:
return [Artist(name=tags[artist_name][0],
musicbrainz_id=tags[artist_id][0])]
# Multiple artist, provide artists without id.
return [Artist(name=name) for name in tags[artist_name]]
# TODO: split based on "stream" and "track" based conversion? i.e. handle data
# from radios in it's own helper instead?
def convert_tags_to_track(tags):
"""Convert our normalized tags to a track.
:param tags: dictionary of tag keys with a list of values
:type tags: :class:`dict`
:rtype: :class:`mopidy.models.Track`
"""
album_kwargs = {}
track_kwargs = {}
track_kwargs['composers'] = _artists(tags, gst.TAG_COMPOSER)
track_kwargs['performers'] = _artists(tags, gst.TAG_PERFORMER)
track_kwargs['artists'] = _artists(
tags, gst.TAG_ARTIST, 'musicbrainz-artistid')
album_kwargs['artists'] = _artists(
tags, gst.TAG_ALBUM_ARTIST, 'musicbrainz-albumartistid')
track_kwargs['genre'] = '; '.join(tags.get(gst.TAG_GENRE, []))
track_kwargs['name'] = '; '.join(tags.get(gst.TAG_TITLE, []))
if not track_kwargs['name']:
track_kwargs['name'] = '; '.join(tags.get(gst.TAG_ORGANIZATION, []))
track_kwargs['comment'] = '; '.join(tags.get('comment', []))
if not track_kwargs['comment']:
track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_LOCATION, []))
if not track_kwargs['comment']:
track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_COPYRIGHT, []))
track_kwargs['track_no'] = tags.get(gst.TAG_TRACK_NUMBER, [None])[0]
track_kwargs['disc_no'] = tags.get(gst.TAG_ALBUM_VOLUME_NUMBER, [None])[0]
track_kwargs['bitrate'] = tags.get(gst.TAG_BITRATE, [None])[0]
track_kwargs['musicbrainz_id'] = tags.get('musicbrainz-trackid', [None])[0]
album_kwargs['name'] = tags.get(gst.TAG_ALBUM, [None])[0]
album_kwargs['num_tracks'] = tags.get(gst.TAG_TRACK_COUNT, [None])[0]
album_kwargs['num_discs'] = tags.get(gst.TAG_ALBUM_VOLUME_COUNT, [None])[0]
album_kwargs['musicbrainz_id'] = tags.get('musicbrainz-albumid', [None])[0]
if tags.get(gst.TAG_DATE) and tags.get(gst.TAG_DATE)[0]:
track_kwargs['date'] = tags[gst.TAG_DATE][0].isoformat()
# Clear out any empty values we found
track_kwargs = {k: v for k, v in track_kwargs.items() if v}
album_kwargs = {k: v for k, v in album_kwargs.items() if v}
# Only bother with album if we have a name to show.
if album_kwargs.get('name'):
track_kwargs['album'] = Album(**album_kwargs)
return Track(**track_kwargs)
def setup_proxy(element, config):
"""Configure a GStreamer element with proxy settings.
:param element: element to setup proxy in.
:type element: :class:`gst.GstElement`
:param config: proxy settings to use.
:type config: :class:`dict`
"""
if not hasattr(element.props, 'proxy') or not config.get('hostname'):
return
proxy = "%s://%s:%d" % (config.get('scheme', 'http'),
config.get('hostname'),
config.get('port', 80))
element.set_property('proxy', proxy)
element.set_property('proxy-id', config.get('username'))
element.set_property('proxy-pw', config.get('password'))
def convert_taglist(taglist):
"""Convert a :class:`gst.Taglist` to plain Python types.
Knows how to convert:
- Dates
- Buffers
- Numbers
- Strings
- Booleans
Unknown types will be ignored and debug logged. Tag keys are all strings
defined as part GStreamer under GstTagList_.
.. _GstTagList: http://gstreamer.freedesktop.org/data/doc/gstreamer/\
0.10.36/gstreamer/html/gstreamer-GstTagList.html
:param taglist: A GStreamer taglist to be converted.
:type taglist: :class:`gst.Taglist`
:rtype: dictionary of tag keys with a list of values.
"""
result = {}
# Taglists are not really dicts, hence the lack of .items() and
# explicit use of .keys()
for key in taglist.keys():
result.setdefault(key, [])
values = taglist[key]
if not isinstance(values, list):
values = [values]
for value in values:
if isinstance(value, gst.Date):
try:
date = datetime.date(value.year, value.month, value.day)
result[key].append(date)
except ValueError:
logger.debug('Ignoring invalid date: %r = %r', key, value)
elif isinstance(value, gst.Buffer):
result[key].append(bytes(value))
elif isinstance(value, (basestring, bool, numbers.Number)):
result[key].append(value)
else:
logger.debug('Ignoring unknown data: %r = %r', key, value)
return result

View File

@ -1,8 +1,6 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import copy
from mopidy import listener
from mopidy import listener, models
class Backend(object):
@ -70,12 +68,12 @@ class LibraryProvider(object):
root_directory = None
"""
:class:`models.Ref.directory` instance with a URI and name set
:class:`mopidy.models.Ref.directory` instance with a URI and name set
representing the root of this library's browse tree. URIs must
use one of the schemes supported by the backend, and name should
be set to a human friendly value.
*MUST be set by any class that implements :meth:`LibraryProvider.browse`.*
*MUST be set by any class that implements* :meth:`LibraryProvider.browse`.
"""
def __init__(self, backend):
@ -92,14 +90,34 @@ class LibraryProvider(object):
"""
return []
# TODO: replace with search(query, exact=True, ...)
def find_exact(self, query=None, uris=None):
def get_distinct(self, field, query=None):
"""
See :meth:`mopidy.core.LibraryController.find_exact`.
See :meth:`mopidy.core.LibraryController.get_distinct`.
*MAY be implemented by subclass.*
Default implementation will simply return an empty set.
"""
pass
return set()
def get_images(self, uris):
"""
See :meth:`mopidy.core.LibraryController.get_images`.
*MAY be implemented by subclass.*
Default implementation will simply call lookup and try and use the
album art for any tracks returned. Most extensions should replace this
with something smarter or simply return an empty dictionary.
"""
result = {}
for uri in uris:
image_uris = set()
for track in self.lookup(uri):
if track.album and track.album.images:
image_uris.update(track.album.images)
result[uri] = [models.Image(uri=u) for u in image_uris]
return result
def lookup(self, uri):
"""
@ -117,11 +135,14 @@ class LibraryProvider(object):
"""
pass
def search(self, query=None, uris=None):
def search(self, query=None, uris=None, exact=False):
"""
See :meth:`mopidy.core.LibraryController.search`.
*MAY be implemented by subclass.*
.. versionadded:: 1.0
The ``exact`` param which replaces the old ``find_exact``.
"""
pass
@ -150,31 +171,66 @@ class PlaybackProvider(object):
"""
return self.audio.pause_playback().get()
def play(self, track):
def play(self):
"""
Play given track.
Start playback.
*MAY be reimplemented by subclass.*
:param track: the track to play
:type track: :class:`mopidy.models.Track`
:rtype: :class:`True` if successful, else :class:`False`
"""
self.audio.prepare_change()
self.change_track(track)
return self.audio.start_playback().get()
def prepare_change(self):
"""
Indicate that an URI change is about to happen.
*MAY be reimplemented by subclass.*
It is extremely unlikely it makes sense for any backends to override
this. For most practical purposes it should be considered an internal
call between backends and core that backend authors should not touch.
"""
self.audio.prepare_change().get()
def translate_uri(self, uri):
"""
Convert custom URI scheme to real playable URI.
*MAY be reimplemented by subclass.*
This is very likely the *only* thing you need to override as a backend
author. Typically this is where you convert any Mopidy specific URI
to a real URI and then return it. If you can't convert the URI just
return :class:`None`.
:param uri: the URI to translate
:type uri: string
:rtype: string or :class:`None` if the URI could not be translated
"""
return uri
def change_track(self, track):
"""
Swith to provided track.
*MAY be reimplemented by subclass.*
It is unlikely it makes sense for any backends to override
this. For most practical purposes it should be considered an internal
call between backends and core that backend authors should not touch.
The default implementation will call :meth:`translate_uri` which
is what you want to implement.
:param track: the track to play
:type track: :class:`mopidy.models.Track`
:rtype: :class:`True` if successful, else :class:`False`
"""
self.audio.set_uri(track.uri).get()
uri = self.translate_uri(track.uri)
if not uri:
return False
self.audio.set_uri(uri).get()
return True
def resume(self):
@ -205,6 +261,9 @@ class PlaybackProvider(object):
*MAY be reimplemented by subclass.*
Should not be used for tracking if tracks have been played or when we
are done playing them.
:rtype: :class:`True` if successful, else :class:`False`
"""
return self.audio.stop_playback().get()
@ -222,6 +281,10 @@ class PlaybackProvider(object):
class PlaylistsProvider(object):
"""
A playlist provider exposes a collection of playlists, methods to
create/change/delete playlists in this collection, and lookup of any
playlist the backend knows about.
:param backend: backend the controller is a part of
:type backend: :class:`mopidy.backend.Backend` instance
"""
@ -230,48 +293,80 @@ class PlaylistsProvider(object):
def __init__(self, backend):
self.backend = backend
self._playlists = []
@property
def playlists(self):
def as_list(self):
"""
Currently available playlists.
Get a list of the currently available playlists.
Read/write. List of :class:`mopidy.models.Playlist`.
Returns a list of :class:`~mopidy.models.Ref` objects referring to the
playlists. In other words, no information about the playlists' content
is given.
:rtype: list of :class:`mopidy.models.Ref`
.. versionadded:: 1.0
"""
return copy.copy(self._playlists)
raise NotImplementedError
@playlists.setter # noqa
def playlists(self, playlists):
self._playlists = playlists
def get_items(self, uri):
"""
Get the items in a playlist specified by ``uri``.
Returns a list of :class:`~mopidy.models.Ref` objects referring to the
playlist's items.
If a playlist with the given ``uri`` doesn't exist, it returns
:class:`None`.
:rtype: list of :class:`mopidy.models.Ref`, or :class:`None`
.. versionadded:: 1.0
"""
raise NotImplementedError
def create(self, name):
"""
See :meth:`mopidy.core.PlaylistsController.create`.
Create a new empty playlist with the given name.
Returns a new playlist with the given name and an URI.
*MUST be implemented by subclass.*
:param name: name of the new playlist
:type name: string
:rtype: :class:`mopidy.models.Playlist`
"""
raise NotImplementedError
def delete(self, uri):
"""
See :meth:`mopidy.core.PlaylistsController.delete`.
Delete playlist identified by the URI.
*MUST be implemented by subclass.*
:param uri: URI of the playlist to delete
:type uri: string
"""
raise NotImplementedError
def lookup(self, uri):
"""
See :meth:`mopidy.core.PlaylistsController.lookup`.
Lookup playlist with given URI in both the set of playlists and in any
other playlist source.
Returns the playlists or :class:`None` if not found.
*MUST be implemented by subclass.*
:param uri: playlist URI
:type uri: string
:rtype: :class:`mopidy.models.Playlist` or :class:`None`
"""
raise NotImplementedError
def refresh(self):
"""
See :meth:`mopidy.core.PlaylistsController.refresh`.
Refresh the playlists in :attr:`playlists`.
*MUST be implemented by subclass.*
"""
@ -279,9 +374,18 @@ class PlaylistsProvider(object):
def save(self, playlist):
"""
See :meth:`mopidy.core.PlaylistsController.save`.
Save the given playlist.
The playlist must have an ``uri`` attribute set. To create a new
playlist with an URI, use :meth:`create`.
Returns the saved playlist or :class:`None` on failure.
*MUST be implemented by subclass.*
:param playlist: the playlist to save
:type playlist: :class:`mopidy.models.Playlist`
:rtype: :class:`mopidy.models.Playlist` or :class:`None`
"""
raise NotImplementedError

View File

@ -1,4 +1,4 @@
from __future__ import print_function, unicode_literals
from __future__ import absolute_import, print_function, unicode_literals
import argparse
import collections
@ -13,7 +13,7 @@ import gobject
from mopidy import config as config_lib, exceptions
from mopidy.audio import Audio
from mopidy.core import Core
from mopidy.utils import deps, process, versioning
from mopidy.utils import deps, process, timer, versioning
logger = logging.getLogger(__name__)
@ -267,10 +267,12 @@ class RootCommand(Command):
exit_status_code = 0
try:
mixer = self.start_mixer(config, mixer_class)
mixer = None
if mixer_class is not None:
mixer = self.start_mixer(config, mixer_class)
audio = self.start_audio(config, mixer)
backends = self.start_backends(config, backend_classes, audio)
core = self.start_core(mixer, backends)
core = self.start_core(mixer, backends, audio)
self.start_frontends(config, frontend_classes, core)
loop.run()
except (exceptions.BackendError,
@ -288,7 +290,8 @@ class RootCommand(Command):
self.stop_core()
self.stop_backends(backend_classes)
self.stop_audio()
self.stop_mixer(mixer_class)
if mixer_class is not None:
self.stop_mixer(mixer_class)
process.stop_remaining_actors()
return exit_status_code
@ -297,13 +300,18 @@ class RootCommand(Command):
'Available Mopidy mixers: %s',
', '.join(m.__name__ for m in mixer_classes) or 'none')
if config['audio']['mixer'] == 'none':
logger.debug('Mixer disabled')
return None
selected_mixers = [
m for m in mixer_classes if m.name == config['audio']['mixer']]
if len(selected_mixers) != 1:
logger.error(
'Did not find unique mixer "%s". Alternatives are: %s',
config['audio']['mixer'],
', '.join([m.name for m in mixer_classes]))
', '.join([m.name for m in mixer_classes]) + ', none' or
'none')
process.exit_process()
return selected_mixers[0]
@ -339,8 +347,9 @@ class RootCommand(Command):
backends = []
for backend_class in backend_classes:
try:
backend = backend_class.start(
config=config, audio=audio).proxy()
with timer.time_logger(backend_class.__name__):
backend = backend_class.start(
config=config, audio=audio).proxy()
backends.append(backend)
except exceptions.BackendError as exc:
logger.error(
@ -350,9 +359,9 @@ class RootCommand(Command):
return backends
def start_core(self, mixer, backends):
def start_core(self, mixer, backends, audio):
logger.info('Starting Mopidy core')
return Core.start(mixer=mixer, backends=backends).proxy()
return Core.start(mixer=mixer, backends=backends, audio=audio).proxy()
def start_frontends(self, config, frontend_classes, core):
logger.info(
@ -361,7 +370,8 @@ class RootCommand(Command):
for frontend_class in frontend_classes:
try:
frontend_class.start(config=config, core=core)
with timer.time_logger(frontend_class.__name__):
frontend_class.start(config=config, core=core)
except exceptions.FrontendError as exc:
logger.error(
'Frontend (%s) initialization error: %s',

30
mopidy/compat.py Normal file
View File

@ -0,0 +1,30 @@
import sys
PY2 = sys.version_info[0] == 2
PY3 = sys.version_info[0] == 3
if PY2:
import ConfigParser as configparser # noqa
import Queue as queue # noqa
import thread # noqa
string_types = basestring
text_type = unicode
input = raw_input
def itervalues(dct, **kwargs):
return iter(dct.itervalues(**kwargs))
else:
import configparser # noqa
import queue # noqa
import _thread as thread # noqa
string_types = (str,)
text_type = str
input = input
def itervalues(dct, **kwargs):
return iter(dct.values(**kwargs))

View File

@ -1,12 +1,13 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import ConfigParser as configparser
import io
import itertools
import logging
import os.path
import re
from mopidy import compat
from mopidy.compat import configparser
from mopidy.config import keyring
from mopidy.config.schemas import * # noqa
from mopidy.config.types import * # noqa
@ -21,14 +22,15 @@ _logging_schema['debug_format'] = String()
_logging_schema['debug_file'] = Path()
_logging_schema['config_file'] = Path(optional=True)
_loglevels_schema = LogLevelConfigSchema('loglevels')
_loglevels_schema = MapConfigSchema('loglevels', LogLevel())
_logcolors_schema = MapConfigSchema('logcolors', LogColor())
_audio_schema = ConfigSchema('audio')
_audio_schema['mixer'] = String()
_audio_schema['mixer_track'] = Deprecated()
_audio_schema['mixer_volume'] = Integer(optional=True, minimum=0, maximum=100)
_audio_schema['output'] = String()
_audio_schema['visualizer'] = String(optional=True)
_audio_schema['visualizer'] = Deprecated()
_proxy_schema = ConfigSchema('proxy')
_proxy_schema['scheme'] = String(optional=True,
@ -41,7 +43,8 @@ _proxy_schema['password'] = Secret(optional=True)
# NOTE: if multiple outputs ever comes something like LogLevelConfigSchema
# _outputs_schema = config.AudioOutputConfigSchema()
_schemas = [_logging_schema, _loglevels_schema, _audio_schema, _proxy_schema]
_schemas = [_logging_schema, _loglevels_schema, _logcolors_schema,
_audio_schema, _proxy_schema]
_INITIAL_HELP = """
# For further information about options in this file see:
@ -108,18 +111,16 @@ def format_initial(extensions):
def _load(files, defaults, overrides):
parser = configparser.RawConfigParser()
files = [path.expand_path(f) for f in files]
sources = ['builtin defaults'] + files + ['command line options']
logger.info('Loading config from: %s', ', '.join(sources))
# TODO: simply return path to config file for defaults so we can load it
# all in the same way?
logger.info('Loading config from builtin defaults')
for default in defaults:
if isinstance(default, unicode):
if isinstance(default, compat.text_type):
default = default.encode('utf-8')
parser.readfp(io.BytesIO(default))
# Load config from a series of config files
files = [path.expand_path(f) for f in files]
for name in files:
if os.path.isdir(name):
for filename in os.listdir(name):
@ -137,6 +138,7 @@ def _load(files, defaults, overrides):
for section in parser.sections():
raw_config[section] = dict(parser.items(section))
logger.info('Loading config from command line options')
for section, key, value in overrides:
raw_config.setdefault(section, {})[key] = value
@ -144,7 +146,18 @@ def _load(files, defaults, overrides):
def _load_file(parser, filename):
if not os.path.exists(filename):
logger.debug(
'Loading config from %s failed; it does not exist', filename)
return
if not os.access(filename, os.R_OK):
logger.warning(
'Loading config from %s failed; read permission missing',
filename)
return
try:
logger.info('Loading config from %s', filename)
with io.open(filename, 'rb') as filehandle:
parser.readfp(filehandle)
except configparser.MissingSectionHeaderError as e:
@ -164,13 +177,19 @@ def _validate(raw_config, schemas):
# Get validated config
config = {}
errors = {}
sections = set(raw_config)
for schema in schemas:
sections.discard(schema.name)
values = raw_config.get(schema.name, {})
result, error = schema.deserialize(values)
if error:
errors[schema.name] = error
if result:
config[schema.name] = result
for section in sections:
logger.debug('Ignoring unknown config section: %s', section)
return config, errors

View File

@ -9,11 +9,10 @@ config_file =
mixer = software
mixer_volume =
output = autoaudiosink
visualizer =
[proxy]
scheme =
hostname =
port =
port =
username =
password =

View File

@ -1,4 +1,4 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import logging
@ -9,6 +9,8 @@ try:
except ImportError:
dbus = None
from mopidy import compat
# XXX: Hack to workaround introspection bug caused by gnome-keyring, should be
# fixed by version 3.5 per:
@ -57,7 +59,7 @@ def fetch():
result = []
secrets = service.GetSecrets(items, session, byte_arrays=True)
for item_path, values in secrets.iteritems():
for item_path, values in secrets.items():
session_path, parameters, value, content_type = values
attrs = _item_attributes(bus, item_path)
result.append((attrs['section'], attrs['key'], bytes(value)))
@ -92,7 +94,7 @@ def set(section, key, value):
if not collection:
return False
if isinstance(value, unicode):
if isinstance(value, compat.text_type):
value = value.encode('utf-8')
session = service.OpenSession('plain', EMPTY_STRING)[1]
@ -161,7 +163,7 @@ def _prompt(bus, path):
def _item_attributes(bus, path):
item = _interface(bus, path, 'org.freedesktop.DBus.Properties')
result = item.Get('org.freedesktop.Secret.Item', 'Attributes')
return dict((bytes(k), bytes(v)) for k, v in result.iteritems())
return dict((bytes(k), bytes(v)) for k, v in result.items())
def _interface(bus, path, interface):

View File

@ -1,4 +1,4 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import collections
@ -25,10 +25,10 @@ def _levenshtein(a, b):
if n > m:
return _levenshtein(b, a)
current = xrange(n + 1)
for i in xrange(1, m + 1):
current = range(n + 1)
for i in range(1, m + 1):
previous, current = current, [i] + [0] * n
for j in xrange(1, n + 1):
for j in range(1, n + 1):
add, delete = previous[j] + 1, current[j - 1] + 1
change = previous[j - 1]
if a[j - 1] != b[i - 1]:
@ -94,17 +94,16 @@ class ConfigSchema(collections.OrderedDict):
return result
class LogLevelConfigSchema(object):
"""Special cased schema for handling a config section with loglevels.
class MapConfigSchema(object):
"""Schema for handling multiple unknown keys with the same type.
Expects the config keys to be logger names and the values to be log levels
as understood by the :class:`LogLevel` config value. Does not sub-class
:class:`ConfigSchema`, but implements the same serialize/deserialize
interface.
Does not sub-class :class:`ConfigSchema`, but implements the same
serialize/deserialize interface.
"""
def __init__(self, name):
def __init__(self, name, value_type):
self.name = name
self._config_value = types.LogLevel()
self._value_type = value_type
def deserialize(self, values):
errors = {}
@ -112,7 +111,7 @@ class LogLevelConfigSchema(object):
for key, value in values.items():
try:
result[key] = self._config_value.deserialize(value)
result[key] = self._value_type.deserialize(value)
except ValueError as e: # deserialization failed
result[key] = None
errors[key] = str(e)
@ -121,5 +120,5 @@ class LogLevelConfigSchema(object):
def serialize(self, values, display=False):
result = collections.OrderedDict()
for key in sorted(values.keys()):
result[key] = self._config_value.serialize(values[key], display)
result[key] = self._value_type.serialize(values[key], display)
return result

View File

@ -1,22 +1,23 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import logging
import re
import socket
from mopidy import compat
from mopidy.config import validators
from mopidy.utils import path
from mopidy.utils import log, path
def decode(value):
if isinstance(value, unicode):
if isinstance(value, compat.text_type):
return value
# TODO: only unescape \n \t and \\?
return value.decode('string-escape').decode('utf-8')
def encode(value):
if not isinstance(value, unicode):
if not isinstance(value, compat.text_type):
return value
for char in ('\\', '\n', '\t'): # TODO: more escapes?
value = value.replace(char, char.encode('unicode-escape'))
@ -24,8 +25,8 @@ def encode(value):
class ExpandedPath(bytes):
def __new__(self, original, expanded):
return super(ExpandedPath, self).__new__(self, expanded)
def __new__(cls, original, expanded):
return super(ExpandedPath, cls).__new__(cls, expanded)
def __init__(self, original, expanded):
self.original = original
@ -196,11 +197,22 @@ class List(ConfigValue):
return b'\n ' + b'\n '.join(encode(v) for v in value if v)
class LogColor(ConfigValue):
def deserialize(self, value):
validators.validate_choice(value.lower(), log.COLORS)
return value.lower()
def serialize(self, value, display=False):
if value.lower() in log.COLORS:
return value.lower()
return b''
class LogLevel(ConfigValue):
"""Log level value.
Expects one of ``critical``, ``error``, ``warning``, ``info``, ``debug``
with any casing.
Expects one of ``critical``, ``error``, ``warning``, ``info``, ``debug``,
or ``all``, with any casing.
"""
levels = {
b'critical': logging.CRITICAL,
@ -208,6 +220,7 @@ class LogLevel(ConfigValue):
b'warning': logging.WARNING,
b'info': logging.INFO,
b'debug': logging.DEBUG,
b'all': logging.NOTSET,
}
def deserialize(self, value):
@ -278,7 +291,7 @@ class Path(ConfigValue):
return ExpandedPath(value, expanded)
def serialize(self, value, display=False):
if isinstance(value, unicode):
if isinstance(value, compat.text_type):
raise ValueError('paths should always be bytes')
if isinstance(value, ExpandedPath):
return value.original

View File

@ -1,4 +1,4 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
# TODO: add validate regexp?

View File

@ -1,9 +1,11 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
# flake8: noqa
from .actor import Core
from .history import HistoryController
from .library import LibraryController
from .listener import CoreListener
from .mixer import MixerController
from .playback import PlaybackController, PlaybackState
from .playlists import PlaylistsController
from .tracklist import TracklistController

View File

@ -1,4 +1,4 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import collections
import itertools
@ -7,12 +7,15 @@ import pykka
from mopidy import audio, backend, mixer
from mopidy.audio import PlaybackState
from mopidy.core.history import HistoryController
from mopidy.core.library import LibraryController
from mopidy.core.listener import CoreListener
from mopidy.core.mixer import MixerController
from mopidy.core.playback import PlaybackController
from mopidy.core.playlists import PlaylistsController
from mopidy.core.tracklist import TracklistController
from mopidy.utils import versioning
from mopidy.utils.deprecation import deprecated_property
class Core(
@ -23,6 +26,14 @@ class Core(
"""The library controller. An instance of
:class:`mopidy.core.LibraryController`."""
history = None
"""The playback history controller. An instance of
:class:`mopidy.core.HistoryController`."""
mixer = None
"""The mixer controller. An instance of
:class:`mopidy.core.MixerController`."""
playback = None
"""The playback controller. An instance of
:class:`mopidy.core.PlaybackController`."""
@ -35,38 +46,48 @@ class Core(
"""The tracklist controller. An instance of
:class:`mopidy.core.TracklistController`."""
def __init__(self, mixer=None, backends=None):
def __init__(self, mixer=None, backends=None, audio=None):
super(Core, self).__init__()
self.backends = Backends(backends)
self.library = LibraryController(backends=self.backends, core=self)
self.playback = PlaybackController(
mixer=mixer, backends=self.backends, core=self)
self.playlists = PlaylistsController(
backends=self.backends, core=self)
self.history = HistoryController()
self.mixer = MixerController(mixer=mixer)
self.playback = PlaybackController(backends=self.backends, core=self)
self.playlists = PlaylistsController(backends=self.backends, core=self)
self.tracklist = TracklistController(core=self)
self.audio = audio
def get_uri_schemes(self):
"""Get list of URI schemes we can handle"""
futures = [b.uri_schemes for b in self.backends]
results = pykka.get_all(futures)
uri_schemes = itertools.chain(*results)
return sorted(uri_schemes)
uri_schemes = property(get_uri_schemes)
"""List of URI schemes we can handle"""
uri_schemes = deprecated_property(get_uri_schemes)
"""
.. deprecated:: 1.0
Use :meth:`get_uri_schemes` instead.
"""
def get_version(self):
"""Get version of the Mopidy core API"""
return versioning.get_version()
version = property(get_version)
"""Version of the Mopidy core API"""
version = deprecated_property(get_version)
"""
.. deprecated:: 1.0
Use :meth:`get_version` instead.
"""
def reached_end_of_stream(self):
self.playback.on_end_of_track()
self.playback._on_end_of_track()
def stream_changed(self, uri):
self.playback._on_stream_changed(uri)
def state_changed(self, old_state, new_state, target_state):
# XXX: This is a temporary fix for issue #232 while we wait for a more
@ -78,8 +99,8 @@ class Core(
# We ignore cases when target state is set as this is buffering
# updates (at least for now) and we need to get #234 fixed...
if (new_state == PlaybackState.PAUSED and not target_state
and self.playback.state != PlaybackState.PAUSED):
if (new_state == PlaybackState.PAUSED and not target_state and
self.playback.state != PlaybackState.PAUSED):
self.playback.state = new_state
self.playback._trigger_track_playback_paused()
@ -95,6 +116,22 @@ class Core(
# Forward event from mixer to frontends
CoreListener.send('mute_changed', mute=mute)
def tags_changed(self, tags):
if not self.audio or 'title' not in tags:
return
tags = self.audio.get_current_tags().get()
if not tags:
return
# TODO: this limits us to only streams that set organization, this is
# a hack to make sure we don't emit stream title changes for plain
# tracks. We need a better way to decide if something is a stream.
if 'title' in tags and tags['title'] and 'organization' in tags:
title = tags['title'][0]
self.playback._stream_title = title
CoreListener.send('stream_title_changed', title=title)
class Backends(list):
def __init__(self, backends):
@ -106,7 +143,9 @@ class Backends(list):
self.with_playlists = collections.OrderedDict()
backends_by_scheme = {}
name = lambda b: b.actor_ref.actor_class.__name__
def name(b):
return b.actor_ref.actor_class.__name__
for b in backends:
has_library = b.has_library().get()

58
mopidy/core/history.py Normal file
View File

@ -0,0 +1,58 @@
from __future__ import absolute_import, unicode_literals
import copy
import logging
import time
from mopidy import models
logger = logging.getLogger(__name__)
class HistoryController(object):
def __init__(self):
self._history = []
def _add_track(self, track):
"""Add track to the playback history.
Internal method for :class:`mopidy.core.PlaybackController`.
:param track: track to add
:type track: :class:`mopidy.models.Track`
"""
if not isinstance(track, models.Track):
raise TypeError('Only Track objects can be added to the history')
timestamp = int(time.time() * 1000)
name_parts = []
if track.artists:
name_parts.append(
', '.join([artist.name for artist in track.artists]))
if track.name is not None:
name_parts.append(track.name)
name = ' - '.join(name_parts)
ref = models.Ref.track(uri=track.uri, name=name)
self._history.insert(0, (timestamp, ref))
def get_length(self):
"""Get the number of tracks in the history.
:returns: the history length
:rtype: int
"""
return len(self._history)
def get_history(self):
"""Get the track history.
The timestamps are milliseconds since epoch.
:returns: the track history
:rtype: list of (timestamp, :class:`mopidy.models.Ref`) tuples
"""
return copy.copy(self._history)

View File

@ -1,11 +1,14 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import collections
import logging
import operator
import urlparse
import pykka
logger = logging.getLogger(__name__)
class LibraryController(object):
pykka_traversable = True
@ -60,6 +63,8 @@ class LibraryController(object):
:param string uri: URI to browse
:rtype: list of :class:`mopidy.models.Ref`
.. versionadded:: 0.18
"""
if uri is None:
backends = self.backends.with_library_browse.values()
@ -72,52 +77,64 @@ class LibraryController(object):
return []
return backend.library.browse(uri).get()
def find_exact(self, query=None, uris=None, **kwargs):
def get_distinct(self, field, query=None):
"""
Search the library for tracks where ``field`` is ``values``.
List distinct values for a given field from the library.
If the query is empty, and the backend can support it, all available
tracks are returned.
This has mainly been added to support the list commands the MPD
protocol supports in a more sane fashion. Other frontends are not
recommended to use this method.
If ``uris`` is given, the search is limited to results from within the
URI roots. For example passing ``uris=['file:']`` will limit the search
to the local backend.
:param string field: One of ``artist``, ``albumartist``, ``album``,
``composer``, ``performer``, ``date``or ``genre``.
:param dict query: Query to use for limiting results, see
:meth:`search` for details about the query format.
:rtype: set of values corresponding to the requested field type.
Examples::
# Returns results matching 'a' from any backend
find_exact({'any': ['a']})
find_exact(any=['a'])
# Returns results matching artist 'xyz' from any backend
find_exact({'artist': ['xyz']})
find_exact(artist=['xyz'])
# Returns results matching 'a' and 'b' and artist 'xyz' from any
# backend
find_exact({'any': ['a', 'b'], 'artist': ['xyz']})
find_exact(any=['a', 'b'], artist=['xyz'])
# Returns results matching 'a' if within the given URI roots
# "file:///media/music" and "spotify:"
find_exact(
{'any': ['a']}, uris=['file:///media/music', 'spotify:'])
find_exact(any=['a'], uris=['file:///media/music', 'spotify:'])
:param query: one or more queries to search for
:type query: dict
:param uris: zero or more URI roots to limit the search to
:type uris: list of strings or :class:`None`
:rtype: list of :class:`mopidy.models.SearchResult`
.. versionadded:: 1.0
"""
futures = [b.library.get_distinct(field, query)
for b in self.backends.with_library.values()]
result = set()
for r in pykka.get_all(futures):
result.update(r)
return result
def get_images(self, uris):
"""Lookup the images for the given URIs
Backends can use this to return image URIs for any URI they know about
be it tracks, albums, playlists... The lookup result is a dictionary
mapping the provided URIs to lists of images.
Unknown URIs or URIs the corresponding backend couldn't find anything
for will simply return an empty list for that URI.
:param list uris: list of URIs to find images for
:rtype: {uri: tuple of :class:`mopidy.models.Image`}
.. versionadded:: 1.0
"""
query = query or kwargs
futures = [
backend.library.find_exact(query=query, uris=backend_uris)
backend.library.get_images(backend_uris)
for (backend, backend_uris)
in self._get_backends_to_uris(uris).items()]
return [result for result in pykka.get_all(futures) if result]
in self._get_backends_to_uris(uris).items() if backend_uris]
def lookup(self, uri):
results = {uri: tuple() for uri in uris}
for r in pykka.get_all(futures):
for uri, images in r.items():
results[uri] += tuple(images)
return results
def find_exact(self, query=None, uris=None, **kwargs):
"""Search the library for tracks where ``field`` is ``values``.
.. deprecated:: 1.0
Use :meth:`search` with ``exact`` set.
"""
return self.search(query=query, uris=uris, exact=True, **kwargs)
def lookup(self, uri=None, uris=None):
"""
Lookup the given URI.
@ -125,14 +142,45 @@ class LibraryController(object):
them all.
:param uri: track URI
:type uri: string
:rtype: list of :class:`mopidy.models.Track`
:type uri: string or :class:`None`
:param uris: track URIs
:type uris: list of string or :class:`None`
:rtype: list of :class:`mopidy.models.Track` if uri was set or
a {uri: list of :class:`mopidy.models.Track`} if uris was set.
.. versionadded:: 1.0
The ``uris`` argument.
.. deprecated:: 1.0
The ``uri`` argument. Use ``uris`` instead.
"""
backend = self._get_backend(uri)
if backend:
return backend.library.lookup(uri).get()
else:
return []
none_set = uri is None and uris is None
both_set = uri is not None and uris is not None
if none_set or both_set:
raise ValueError("One of 'uri' or 'uris' must be set")
if uri is not None:
uris = [uri]
futures = {}
result = {}
backends = self._get_backends_to_uris(uris)
# TODO: lookup(uris) to backend APIs
for backend, backend_uris in backends.items():
for u in backend_uris or []:
futures[u] = backend.library.lookup(u)
for u in uris:
if u in futures:
result[u] = futures[u].get()
else:
result[u] = []
if uri:
return result[uri]
return result
def refresh(self, uri=None):
"""
@ -150,12 +198,15 @@ class LibraryController(object):
for b in self.backends.with_library.values()]
pykka.get_all(futures)
def search(self, query=None, uris=None, **kwargs):
def search(self, query=None, uris=None, exact=False, **kwargs):
"""
Search the library for tracks where ``field`` contains ``values``.
If the query is empty, and the backend can support it, all available
tracks are returned.
.. deprecated:: 1.0
Previously, if the query was empty, and the backend could support
it, all available tracks were returned. This has not changed, but
it is strongly discouraged. No new code should rely on this
behavior.
If ``uris`` is given, the search is limited to results from within the
URI roots. For example passing ``uris=['file:']`` will limit the search
@ -186,10 +237,42 @@ class LibraryController(object):
:param uris: zero or more URI roots to limit the search to
:type uris: list of strings or :class:`None`
:rtype: list of :class:`mopidy.models.SearchResult`
.. versionadded:: 1.0
The ``exact`` keyword argument, which replaces :meth:`find_exact`.
"""
query = query or kwargs
futures = [
backend.library.search(query=query, uris=backend_uris)
for (backend, backend_uris)
in self._get_backends_to_uris(uris).items()]
return [result for result in pykka.get_all(futures) if result]
query = _normalize_query(query or kwargs)
futures = {}
for backend, backend_uris in self._get_backends_to_uris(uris).items():
futures[backend] = backend.library.search(
query=query, uris=backend_uris, exact=exact)
results = []
for backend, future in futures.items():
try:
results.append(future.get())
except TypeError:
backend_name = backend.actor_ref.actor_class.__name__
logger.warning(
'%s does not implement library.search() with "exact" '
'support. Please upgrade it.', backend_name)
return [r for r in results if r]
def _normalize_query(query):
broken_client = False
for (field, values) in query.items():
if isinstance(values, basestring):
broken_client = True
query[field] = [values]
if broken_client:
logger.warning(
'A client or frontend made a broken library search. Values in '
'queries must be lists of strings, not a string. Please check what'
' sent this query and file a bug. Query: %s', query)
if not query:
logger.warning(
'A client or frontend made a library search with an empty query. '
'This is strongly discouraged. Please check what sent this query '
'and file a bug.')
return query

View File

@ -1,4 +1,4 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
from mopidy import listener
@ -163,3 +163,11 @@ class CoreListener(listener.Listener):
:type time_position: int
"""
pass
def stream_title_changed(self, title):
"""
Called whenever the currently playing stream title changes.
*MAY* be implemented by actor.
"""
pass

58
mopidy/core/mixer.py Normal file
View File

@ -0,0 +1,58 @@
from __future__ import absolute_import, unicode_literals
import logging
logger = logging.getLogger(__name__)
class MixerController(object):
pykka_traversable = True
def __init__(self, mixer):
self._mixer = mixer
def get_volume(self):
"""Get the volume.
Integer in range [0..100] or :class:`None` if unknown.
The volume scale is linear.
"""
if self._mixer is not None:
return self._mixer.get_volume().get()
def set_volume(self, volume):
"""Set the volume.
The volume is defined as an integer in range [0..100].
The volume scale is linear.
Returns :class:`True` if call is successful, otherwise :class:`False`.
"""
if self._mixer is None:
return False
else:
return self._mixer.set_volume(volume).get()
def get_mute(self):
"""Get mute state.
:class:`True` if muted, :class:`False` unmuted, :class:`None` if
unknown.
"""
if self._mixer is not None:
return self._mixer.get_mute().get()
def set_mute(self, mute):
"""Set mute state.
:class:`True` to mute, :class:`False` to unmute.
Returns :class:`True` if call is successful, otherwise :class:`False`.
"""
if self._mixer is None:
return False
else:
return self._mixer.set_mute(bool(mute)).get()

View File

@ -1,10 +1,12 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import logging
import urlparse
import warnings
from mopidy.audio import PlaybackState
from mopidy.core import listener
from mopidy.utils.deprecation import deprecated_property
logger = logging.getLogger(__name__)
@ -13,164 +15,225 @@ logger = logging.getLogger(__name__)
class PlaybackController(object):
pykka_traversable = True
def __init__(self, mixer, backends, core):
self.mixer = mixer
def __init__(self, backends, core):
self.backends = backends
self.core = core
self._current_tl_track = None
self._stream_title = None
self._state = PlaybackState.STOPPED
self._volume = None
self._mute = False
def _get_backend(self):
if self.current_tl_track is None:
# TODO: take in track instead
track = self.get_current_track()
if track is None:
return None
uri = self.current_tl_track.track.uri
uri = track.uri
uri_scheme = urlparse.urlparse(uri).scheme
return self.backends.with_playback.get(uri_scheme, None)
# Properties
def get_current_tl_track(self):
return self.current_tl_track
"""Get the currently playing or selected track.
current_tl_track = None
Returns a :class:`mopidy.models.TlTrack` or :class:`None`.
"""
return self._current_tl_track
def _set_current_tl_track(self, value):
"""Set the currently playing or selected track.
*Internal:* This is only for use by Mopidy's test suite.
"""
self._current_tl_track = value
current_tl_track = deprecated_property(get_current_tl_track)
"""
The currently playing or selected :class:`mopidy.models.TlTrack`, or
:class:`None`.
.. deprecated:: 1.0
Use :meth:`get_current_tl_track` instead.
"""
def get_current_track(self):
return self.current_tl_track and self.current_tl_track.track
"""
Get the currently playing or selected track.
current_track = property(get_current_track)
"""
The currently playing or selected :class:`mopidy.models.Track`.
Extracted from :meth:`get_current_tl_track` for convenience.
Read-only. Extracted from :attr:`current_tl_track` for convenience.
Returns a :class:`mopidy.models.Track` or :class:`None`.
"""
tl_track = self.get_current_tl_track()
if tl_track is not None:
return tl_track.track
current_track = deprecated_property(get_current_track)
"""
.. deprecated:: 1.0
Use :meth:`get_current_track` instead.
"""
def get_stream_title(self):
"""Get the current stream title or :class:`None`."""
return self._stream_title
def get_state(self):
"""Get The playback state."""
return self._state
def set_state(self, new_state):
(old_state, self._state) = (self.state, new_state)
"""Set the playback state.
Must be :attr:`PLAYING`, :attr:`PAUSED`, or :attr:`STOPPED`.
Possible states and transitions:
.. digraph:: state_transitions
"STOPPED" -> "PLAYING" [ label="play" ]
"STOPPED" -> "PAUSED" [ label="pause" ]
"PLAYING" -> "STOPPED" [ label="stop" ]
"PLAYING" -> "PAUSED" [ label="pause" ]
"PLAYING" -> "PLAYING" [ label="play" ]
"PAUSED" -> "PLAYING" [ label="resume" ]
"PAUSED" -> "STOPPED" [ label="stop" ]
"""
(old_state, self._state) = (self.get_state(), new_state)
logger.debug('Changing state: %s -> %s', old_state, new_state)
self._trigger_playback_state_changed(old_state, new_state)
state = property(get_state, set_state)
state = deprecated_property(get_state, set_state)
"""
The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or
:attr:`STOPPED`.
Possible states and transitions:
.. digraph:: state_transitions
"STOPPED" -> "PLAYING" [ label="play" ]
"STOPPED" -> "PAUSED" [ label="pause" ]
"PLAYING" -> "STOPPED" [ label="stop" ]
"PLAYING" -> "PAUSED" [ label="pause" ]
"PLAYING" -> "PLAYING" [ label="play" ]
"PAUSED" -> "PLAYING" [ label="resume" ]
"PAUSED" -> "STOPPED" [ label="stop" ]
.. deprecated:: 1.0
Use :meth:`get_state` and :meth:`set_state` instead.
"""
def get_time_position(self):
"""Get time position in milliseconds."""
backend = self._get_backend()
if backend:
return backend.playback.get_time_position().get()
else:
return 0
time_position = property(get_time_position)
"""Time position in milliseconds."""
time_position = deprecated_property(get_time_position)
"""
.. deprecated:: 1.0
Use :meth:`get_time_position` instead.
"""
def get_volume(self):
if self.mixer:
return self.mixer.get_volume().get()
else:
# For testing
return self._volume
"""
.. deprecated:: 1.0
Use :meth:`core.mixer.get_volume()
<mopidy.core.MixerController.get_volume>` instead.
"""
warnings.warn(
'playback.get_volume() is deprecated', DeprecationWarning)
return self.core.mixer.get_volume()
def set_volume(self, volume):
if self.mixer:
self.mixer.set_volume(volume)
else:
# For testing
self._volume = volume
"""
.. deprecated:: 1.0
Use :meth:`core.mixer.set_volume()
<mopidy.core.MixerController.set_volume>` instead.
"""
warnings.warn(
'playback.set_volume() is deprecated', DeprecationWarning)
return self.core.mixer.set_volume(volume)
volume = property(get_volume, set_volume)
"""Volume as int in range [0..100] or :class:`None` if unknown. The volume
scale is linear.
volume = deprecated_property(get_volume, set_volume)
"""
.. deprecated:: 1.0
Use :meth:`core.mixer.get_volume()
<mopidy.core.MixerController.get_volume>` and
:meth:`core.mixer.set_volume()
<mopidy.core.MixerController.set_volume>` instead.
"""
def get_mute(self):
if self.mixer:
return self.mixer.get_mute().get()
else:
# For testing
return self._mute
"""
.. deprecated:: 1.0
Use :meth:`core.mixer.get_mute()
<mopidy.core.MixerController.get_mute>` instead.
"""
warnings.warn('playback.get_mute() is deprecated', DeprecationWarning)
return self.core.mixer.get_mute()
def set_mute(self, value):
value = bool(value)
if self.mixer:
self.mixer.set_mute(value)
else:
# For testing
self._mute = value
def set_mute(self, mute):
"""
.. deprecated:: 1.0
Use :meth:`core.mixer.set_mute()
<mopidy.core.MixerController.set_mute>` instead.
"""
warnings.warn('playback.set_mute() is deprecated', DeprecationWarning)
return self.core.mixer.set_mute(mute)
mute = property(get_mute, set_mute)
"""Mute state as a :class:`True` if muted, :class:`False` otherwise"""
mute = deprecated_property(get_mute, set_mute)
"""
.. deprecated:: 1.0
Use :meth:`core.mixer.get_mute()
<mopidy.core.MixerController.get_mute>` and
:meth:`core.mixer.set_mute()
<mopidy.core.MixerController.set_mute>` instead.
"""
# Methods
def change_track(self, tl_track, on_error_step=1):
# TODO: remove this.
def _change_track(self, tl_track, on_error_step=1):
"""
Change to the given track, keeping the current playback state.
:param tl_track: track to change to
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
:param on_error_step: direction to step at play error, 1 for next
track (default), -1 for previous track
track (default), -1 for previous track. **INTERNAL**
:type on_error_step: int, -1 or 1
"""
old_state = self.state
old_state = self.get_state()
self.stop()
self.current_tl_track = tl_track
self._set_current_tl_track(tl_track)
if old_state == PlaybackState.PLAYING:
self.play(on_error_step=on_error_step)
self._play(on_error_step=on_error_step)
elif old_state == PlaybackState.PAUSED:
self.pause()
def on_end_of_track(self):
# TODO: this is not really end of track, this is on_need_next_track
def _on_end_of_track(self):
"""
Tell the playback controller that end of track is reached.
Used by event handler in :class:`mopidy.core.Core`.
"""
if self.state == PlaybackState.STOPPED:
if self.get_state() == PlaybackState.STOPPED:
return
original_tl_track = self.current_tl_track
original_tl_track = self.get_current_tl_track()
next_tl_track = self.core.tracklist.eot_track(original_tl_track)
if next_tl_track:
self.change_track(next_tl_track)
self._change_track(next_tl_track)
else:
self.stop(clear_current_track=True)
self.stop()
self._set_current_tl_track(None)
self.core.tracklist.mark_played(original_tl_track)
self.core.tracklist._mark_played(original_tl_track)
def on_tracklist_change(self):
def _on_tracklist_change(self):
"""
Tell the playback controller that the current playlist has changed.
Used by :class:`mopidy.core.TracklistController`.
"""
if self.current_tl_track not in self.core.tracklist.tl_tracks:
self.stop(clear_current_track=True)
tracklist = self.core.tracklist.get_tl_tracks()
if self.get_current_tl_track() not in tracklist:
self.stop()
self._set_current_tl_track(None)
def _on_stream_changed(self, uri):
self._stream_title = None
def next(self):
"""
@ -179,43 +242,47 @@ class PlaybackController(object):
The current playback state will be kept. If it was playing, playing
will continue. If it was paused, it will still be paused, etc.
"""
original_tl_track = self.current_tl_track
original_tl_track = self.get_current_tl_track()
next_tl_track = self.core.tracklist.next_track(original_tl_track)
if next_tl_track:
self.change_track(next_tl_track)
# TODO: switch to:
# backend.play(track)
# wait for state change?
self._change_track(next_tl_track)
else:
self.stop(clear_current_track=True)
self.stop()
self._set_current_tl_track(None)
self.core.tracklist.mark_played(original_tl_track)
self.core.tracklist._mark_played(original_tl_track)
def pause(self):
"""Pause playback."""
backend = self._get_backend()
if not backend or backend.playback.pause().get():
self.state = PlaybackState.PAUSED
# TODO: switch to:
# backend.track(pause)
# wait for state change?
self.set_state(PlaybackState.PAUSED)
self._trigger_track_playback_paused()
def play(self, tl_track=None, on_error_step=1):
def play(self, tl_track=None):
"""
Play the given track, or if the given track is :class:`None`, play the
currently active track.
:param tl_track: track to play
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
:param on_error_step: direction to step at play error, 1 for next
track (default), -1 for previous track
:type on_error_step: int, -1 or 1
"""
self._play(tl_track, on_error_step=1)
assert on_error_step in (-1, 1)
def _play(self, tl_track=None, on_error_step=1):
if tl_track is None:
if self.state == PlaybackState.PAUSED:
if self.get_state() == PlaybackState.PAUSED:
return self.resume()
if self.current_tl_track is not None:
tl_track = self.current_tl_track
if self.get_current_tl_track() is not None:
tl_track = self.get_current_tl_track()
else:
if on_error_step == 1:
tl_track = self.core.tracklist.next_track(tl_track)
@ -225,21 +292,37 @@ class PlaybackController(object):
if tl_track is None:
return
assert tl_track in self.core.tracklist.tl_tracks
assert tl_track in self.core.tracklist.get_tl_tracks()
if self.state == PlaybackState.PLAYING:
# TODO: switch to:
# backend.play(track)
# wait for state change?
if self.get_state() == PlaybackState.PLAYING:
self.stop()
self.current_tl_track = tl_track
self.state = PlaybackState.PLAYING
self._set_current_tl_track(tl_track)
self.set_state(PlaybackState.PLAYING)
backend = self._get_backend()
success = backend and backend.playback.play(tl_track.track).get()
success = False
if backend:
backend.playback.prepare_change()
try:
success = (
backend.playback.change_track(tl_track.track).get() and
backend.playback.play().get())
except TypeError:
logger.error('%s needs to be updated to work with this '
'version of Mopidy.', backend)
if success:
self.core.tracklist.mark_playing(tl_track)
self.core.tracklist._mark_playing(tl_track)
self.core.history._add_track(tl_track.track)
# TODO: replace with stream-changed
self._trigger_track_playback_started()
else:
self.core.tracklist.mark_unplayable(tl_track)
self.core.tracklist._mark_unplayable(tl_track)
if on_error_step == 1:
# TODO: can cause an endless loop for single track repeat.
self.next()
@ -253,18 +336,25 @@ class PlaybackController(object):
The current playback state will be kept. If it was playing, playing
will continue. If it was paused, it will still be paused, etc.
"""
tl_track = self.current_tl_track
self.change_track(
tl_track = self.get_current_tl_track()
# TODO: switch to:
# self.play(....)
# wait for state change?
self._change_track(
self.core.tracklist.previous_track(tl_track), on_error_step=-1)
def resume(self):
"""If paused, resume playing the current track."""
if self.state != PlaybackState.PAUSED:
if self.get_state() != PlaybackState.PAUSED:
return
backend = self._get_backend()
if backend and backend.playback.resume().get():
self.state = PlaybackState.PLAYING
self.set_state(PlaybackState.PLAYING)
# TODO: trigger via gst messages
self._trigger_track_playback_resumed()
# TODO: switch to:
# backend.resume()
# wait for state change?
def seek(self, time_position):
"""
@ -277,10 +367,11 @@ class PlaybackController(object):
if not self.core.tracklist.tracks:
return False
if self.state == PlaybackState.STOPPED:
if self.current_track and self.current_track.length is None:
return False
if self.get_state() == PlaybackState.STOPPED:
self.play()
elif self.state == PlaybackState.PAUSED:
self.resume()
if time_position < 0:
time_position = 0
@ -297,22 +388,14 @@ class PlaybackController(object):
self._trigger_seeked(time_position)
return success
def stop(self, clear_current_track=False):
"""
Stop playing.
:param clear_current_track: whether to clear the current track _after_
stopping
:type clear_current_track: boolean
"""
if self.state != PlaybackState.STOPPED:
def stop(self):
"""Stop playing."""
if self.get_state() != PlaybackState.STOPPED:
backend = self._get_backend()
time_position_before_stop = self.time_position
time_position_before_stop = self.get_time_position()
if not backend or backend.playback.stop().get():
self.state = PlaybackState.STOPPED
self.set_state(PlaybackState.STOPPED)
self._trigger_track_playback_ended(time_position_before_stop)
if clear_current_track:
self.current_tl_track = None
def _trigger_track_playback_paused(self):
logger.debug('Triggering track playback paused event')
@ -320,7 +403,8 @@ class PlaybackController(object):
return
listener.CoreListener.send(
'track_playback_paused',
tl_track=self.current_tl_track, time_position=self.time_position)
tl_track=self.get_current_tl_track(),
time_position=self.get_time_position())
def _trigger_track_playback_resumed(self):
logger.debug('Triggering track playback resumed event')
@ -328,23 +412,24 @@ class PlaybackController(object):
return
listener.CoreListener.send(
'track_playback_resumed',
tl_track=self.current_tl_track, time_position=self.time_position)
tl_track=self.get_current_tl_track(),
time_position=self.get_time_position())
def _trigger_track_playback_started(self):
logger.debug('Triggering track playback started event')
if self.current_tl_track is None:
if self.get_current_tl_track() is None:
return
listener.CoreListener.send(
'track_playback_started',
tl_track=self.current_tl_track)
tl_track=self.get_current_tl_track())
def _trigger_track_playback_ended(self, time_position_before_stop):
logger.debug('Triggering track playback ended event')
if self.current_tl_track is None:
if self.get_current_tl_track() is None:
return
listener.CoreListener.send(
'track_playback_ended',
tl_track=self.current_tl_track,
tl_track=self.get_current_tl_track(),
time_position=time_position_before_stop)
def _trigger_playback_state_changed(self, old_state, new_state):

View File

@ -1,11 +1,16 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import itertools
import logging
import urlparse
import pykka
from . import listener
from mopidy.core import listener
from mopidy.models import Playlist
from mopidy.utils.deprecation import deprecated_property
logger = logging.getLogger(__name__)
class PlaylistsController(object):
@ -15,20 +20,83 @@ class PlaylistsController(object):
self.backends = backends
self.core = core
def as_list(self):
"""
Get a list of the currently available playlists.
Returns a list of :class:`~mopidy.models.Ref` objects referring to the
playlists. In other words, no information about the playlists' content
is given.
:rtype: list of :class:`mopidy.models.Ref`
.. versionadded:: 1.0
"""
futures = {
b.actor_ref.actor_class.__name__: b.playlists.as_list()
for b in set(self.backends.with_playlists.values())}
results = []
for backend_name, future in futures.items():
try:
results.extend(future.get())
except NotImplementedError:
logger.warning(
'%s does not implement playlists.as_list(). '
'Please upgrade it.', backend_name)
return results
def get_items(self, uri):
"""
Get the items in a playlist specified by ``uri``.
Returns a list of :class:`~mopidy.models.Ref` objects referring to the
playlist's items.
If a playlist with the given ``uri`` doesn't exist, it returns
:class:`None`.
:rtype: list of :class:`mopidy.models.Ref`, or :class:`None`
.. versionadded:: 1.0
"""
uri_scheme = urlparse.urlparse(uri).scheme
backend = self.backends.with_playlists.get(uri_scheme, None)
if backend:
return backend.playlists.get_items(uri).get()
def get_playlists(self, include_tracks=True):
futures = [b.playlists.playlists
for b in self.backends.with_playlists.values()]
results = pykka.get_all(futures)
playlists = list(itertools.chain(*results))
if not include_tracks:
playlists = [p.copy(tracks=[]) for p in playlists]
return playlists
"""
Get the available playlists.
playlists = property(get_playlists)
:rtype: list of :class:`mopidy.models.Playlist`
.. versionchanged:: 1.0
If you call the method with ``include_tracks=False``, the
:attr:`~mopidy.models.Playlist.last_modified` field of the returned
playlists is no longer set.
.. deprecated:: 1.0
Use :meth:`as_list` and :meth:`get_items` instead.
"""
playlist_refs = self.as_list()
if include_tracks:
playlists = {r.uri: self.lookup(r.uri) for r in playlist_refs}
# Use the playlist name from as_list() because it knows about any
# playlist folder hierarchy, which lookup() does not.
return [
playlists[r.uri].copy(name=r.name)
for r in playlist_refs if playlists[r.uri] is not None]
else:
return [
Playlist(uri=r.uri, name=r.name) for r in playlist_refs]
playlists = deprecated_property(get_playlists)
"""
The available playlists.
Read-only. List of :class:`mopidy.models.Playlist`.
.. deprecated:: 1.0
Use :meth:`as_list` and :meth:`get_items` instead.
"""
def create(self, name, uri_scheme=None):
@ -40,7 +108,7 @@ class PlaylistsController(object):
:class:`None` or doesn't match a current backend, the first backend is
asked to create the playlist.
All new playlists should be created by calling this method, and **not**
All new playlists must be created by calling this method, and **not**
by creating new instances of :class:`mopidy.models.Playlist`.
:param name: name of the new playlist
@ -53,7 +121,7 @@ class PlaylistsController(object):
backend = self.backends.with_playlists[uri_scheme]
else:
# TODO: this fallback looks suspicious
backend = self.backends.with_playlists.values()[0]
backend = list(self.backends.with_playlists.values())[0]
playlist = backend.playlists.create(name).get()
listener.CoreListener.send('playlist_changed', playlist=playlist)
return playlist
@ -94,6 +162,9 @@ class PlaylistsController(object):
:param criteria: one or more criteria to match by
:type criteria: dict
:rtype: list of :class:`mopidy.models.Playlist`
.. deprecated:: 1.0
Use :meth:`as_list` and filter yourself.
"""
criteria = criteria or kwargs
matches = self.playlists
@ -145,14 +216,14 @@ class PlaylistsController(object):
Save the playlist.
For a playlist to be saveable, it must have the ``uri`` attribute set.
You should not set the ``uri`` atribute yourself, but use playlist
You must not set the ``uri`` atribute yourself, but use playlist
objects returned by :meth:`create` or retrieved from :attr:`playlists`,
which will always give you saveable playlists.
The method returns the saved playlist. The return playlist may differ
from the saved playlist. E.g. if the playlist name was changed, the
returned playlist may have a different URI. The caller of this method
should throw away the playlist sent to this method, and use the
must throw away the playlist sent to this method, and use the
returned playlist instead.
If the playlist's URI isn't set or doesn't match the URI scheme of a

View File

@ -1,11 +1,13 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import collections
import logging
import random
from mopidy import compat
from mopidy.core import listener
from mopidy.models import TlTrack
from mopidy.utils.deprecation import deprecated_property
logger = logging.getLogger(__name__)
@ -25,114 +27,176 @@ class TracklistController(object):
# Properties
def get_tl_tracks(self):
"""Get tracklist as list of :class:`mopidy.models.TlTrack`."""
return self._tl_tracks[:]
tl_tracks = property(get_tl_tracks)
tl_tracks = deprecated_property(get_tl_tracks)
"""
List of :class:`mopidy.models.TlTrack`.
Read-only.
.. deprecated:: 1.0
Use :meth:`get_tl_tracks` instead.
"""
def get_tracks(self):
"""Get tracklist as list of :class:`mopidy.models.Track`."""
return [tl_track.track for tl_track in self._tl_tracks]
tracks = property(get_tracks)
tracks = deprecated_property(get_tracks)
"""
List of :class:`mopidy.models.Track` in the tracklist.
Read-only.
.. deprecated:: 1.0
Use :meth:`get_tracks` instead.
"""
def get_length(self):
"""Get length of the tracklist."""
return len(self._tl_tracks)
length = property(get_length)
"""Length of the tracklist."""
length = deprecated_property(get_length)
"""
.. deprecated:: 1.0
Use :meth:`get_length` instead.
"""
def get_version(self):
"""
Get the tracklist version.
Integer which is increased every time the tracklist is changed. Is not
reset before Mopidy is restarted.
"""
return self._version
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)
version = deprecated_property(get_version)
"""
The tracklist version.
Read-only. Integer which is increased every time the tracklist is changed.
Is not reset before Mopidy is restarted.
.. deprecated:: 1.0
Use :meth:`get_version` instead.
"""
def get_consume(self):
"""Get consume mode.
:class:`True`
Tracks are removed from the tracklist when they have been played.
:class:`False`
Tracks are not removed from the tracklist.
"""
return getattr(self, '_consume', False)
def set_consume(self, value):
"""Set consume mode.
:class:`True`
Tracks are removed from the tracklist when they have been played.
:class:`False`
Tracks are not removed from the tracklist.
"""
if self.get_consume() != value:
self._trigger_options_changed()
return setattr(self, '_consume', value)
consume = property(get_consume, set_consume)
consume = deprecated_property(get_consume, set_consume)
"""
:class:`True`
Tracks are removed from the tracklist when they have been played.
:class:`False`
Tracks are not removed from the tracklist.
.. deprecated:: 1.0
Use :meth:`get_consume` and :meth:`set_consume` instead.
"""
def get_random(self):
"""Get random mode.
:class:`True`
Tracks are selected at random from the tracklist.
:class:`False`
Tracks are played in the order of the tracklist.
"""
return getattr(self, '_random', False)
def set_random(self, value):
"""Set random mode.
:class:`True`
Tracks are selected at random from the tracklist.
:class:`False`
Tracks are played in the order of the tracklist.
"""
if self.get_random() != value:
self._trigger_options_changed()
if value:
self._shuffled = self.tl_tracks
self._shuffled = self.get_tl_tracks()
random.shuffle(self._shuffled)
return setattr(self, '_random', value)
random = property(get_random, set_random)
random = deprecated_property(get_random, set_random)
"""
:class:`True`
Tracks are selected at random from the tracklist.
:class:`False`
Tracks are played in the order of the tracklist.
.. deprecated:: 1.0
Use :meth:`get_random` and :meth:`set_random` instead.
"""
def get_repeat(self):
"""
Get repeat mode.
:class:`True`
The tracklist is played repeatedly.
:class:`False`
The tracklist is played once.
"""
return getattr(self, '_repeat', False)
def set_repeat(self, value):
"""
Set repeat mode.
To repeat a single track, set both ``repeat`` and ``single``.
:class:`True`
The tracklist is played repeatedly.
:class:`False`
The tracklist is played once.
"""
if self.get_repeat() != value:
self._trigger_options_changed()
return setattr(self, '_repeat', value)
repeat = property(get_repeat, set_repeat)
repeat = deprecated_property(get_repeat, set_repeat)
"""
:class:`True`
The tracklist is played repeatedly. To repeat a single track, select
both :attr:`repeat` and :attr:`single`.
:class:`False`
The tracklist is played once.
.. deprecated:: 1.0
Use :meth:`get_repeat` and :meth:`set_repeat` instead.
"""
def get_single(self):
"""
Get single mode.
:class:`True`
Playback is stopped after current song, unless in ``repeat`` mode.
:class:`False`
Playback continues after current song.
"""
return getattr(self, '_single', False)
def set_single(self, value):
"""
Set single mode.
:class:`True`
Playback is stopped after current song, unless in ``repeat`` mode.
:class:`False`
Playback continues after current song.
"""
if self.get_single() != value:
self._trigger_options_changed()
return setattr(self, '_single', value)
single = property(get_single, set_single)
single = deprecated_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.
.. deprecated:: 1.0
Use :meth:`get_single` and :meth:`set_single` instead.
"""
# Methods
@ -160,9 +224,9 @@ class TracklistController(object):
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
:rtype: :class:`mopidy.models.TlTrack` or :class:`None`
"""
if self.single and self.repeat:
if self.get_single() and self.get_repeat():
return tl_track
elif self.single:
elif self.get_single():
return None
# Current difference between next and EOT handling is that EOT needs to
@ -185,30 +249,30 @@ class TracklistController(object):
:rtype: :class:`mopidy.models.TlTrack` or :class:`None`
"""
if not self.tl_tracks:
if not self.get_tl_tracks():
return None
if self.random and not self._shuffled:
if self.repeat or not tl_track:
if self.get_random() and not self._shuffled:
if self.get_repeat() or not tl_track:
logger.debug('Shuffling tracks')
self._shuffled = self.tl_tracks
self._shuffled = self.get_tl_tracks()
random.shuffle(self._shuffled)
if self.random:
if self.get_random():
try:
return self._shuffled[0]
except IndexError:
return None
if tl_track is None:
return self.tl_tracks[0]
return self.get_tl_tracks()[0]
next_index = self.index(tl_track) + 1
if self.repeat:
next_index %= len(self.tl_tracks)
if self.get_repeat():
next_index %= len(self.get_tl_tracks())
try:
return self.tl_tracks[next_index]
return self.get_tl_tracks()[next_index]
except IndexError:
return None
@ -225,7 +289,7 @@ class TracklistController(object):
: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:
if self.get_repeat() or self.get_consume() or self.get_random():
return tl_track
position = self.index(tl_track)
@ -233,15 +297,18 @@ class TracklistController(object):
if position in (None, 0):
return None
return self.tl_tracks[position - 1]
return self.get_tl_tracks()[position - 1]
def add(self, tracks=None, at_position=None, uri=None):
def add(self, tracks=None, at_position=None, uri=None, uris=None):
"""
Add the track or list of tracks to the tracklist.
If ``uri`` is given instead of ``tracks``, the URI is looked up in the
library and the resulting tracks are added to the tracklist.
If ``uris`` is given instead of ``tracks``, the URIs are looked up in
the library and the resulting tracks are added to the tracklist.
If ``at_position`` is given, the tracks placed at the given position in
the tracklist. If ``at_position`` is not given, the tracks are appended
to the end of the tracklist.
@ -255,12 +322,24 @@ class TracklistController(object):
:param uri: URI for tracks to add
:type uri: string
:rtype: list of :class:`mopidy.models.TlTrack`
"""
assert tracks is not None or uri is not None, \
'tracks or uri must be provided'
if tracks is None and uri is not None:
tracks = self.core.library.lookup(uri)
.. versionadded:: 1.0
The ``uris`` argument.
.. deprecated:: 1.0
The ``tracks`` and ``uri`` arguments. Use ``uris``.
"""
assert tracks is not None or uri is not None or uris is not None, \
'tracks, uri or uris must be provided'
if tracks is None:
if uri is not None:
tracks = self.core.library.lookup(uri=uri)
elif uris is not None:
tracks = []
track_map = self.core.library.lookup(uris=uris)
for uri in uris:
tracks.extend(track_map[uri])
tl_tracks = []
@ -327,16 +406,16 @@ class TracklistController(object):
"""
criteria = criteria or kwargs
matches = self._tl_tracks
for (key, values) in criteria.iteritems():
if (not isinstance(values, collections.Iterable)
or isinstance(values, basestring)):
for (key, values) in criteria.items():
if (not isinstance(values, collections.Iterable) or
isinstance(values, compat.string_types)):
# Fail hard if anyone is using the <0.17 calling style
raise ValueError('Filter values must be iterable: %r' % values)
if key == 'tlid':
matches = filter(lambda ct: ct.tlid in values, matches)
matches = [ct for ct in matches if ct.tlid in values]
else:
matches = filter(
lambda ct: getattr(ct.track, key) in values, matches)
matches = [
ct for ct in matches if getattr(ct.track, key) in values]
return matches
def move(self, start, end, to_position):
@ -435,27 +514,27 @@ 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:
def _mark_playing(self, tl_track):
"""Internal method for :class:`mopidy.core.PlaybackController`."""
if self.get_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`."""
def _mark_unplayable(self, tl_track):
"""Internal method for :class:`mopidy.core.PlaybackController`."""
logger.warning('Track is not playable: %s', tl_track.track.uri)
if self.random and tl_track in self._shuffled:
if self.get_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`."""
def _mark_played(self, tl_track):
"""Internal method for :class:`mopidy.core.PlaybackController`."""
if self.consume and tl_track is not None:
self.remove(tlid=[tl_track.tlid])
return True
return False
def _trigger_tracklist_changed(self):
if self.random:
self._shuffled = self.tl_tracks
if self.get_random():
self._shuffled = self.get_tl_tracks()
random.shuffle(self._shuffled)
else:
self._shuffled = []

View File

@ -1,4 +1,4 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
class MopidyException(Exception):
@ -24,6 +24,12 @@ class ExtensionError(MopidyException):
pass
class FindError(MopidyException):
def __init__(self, message, errno=None):
super(FindError, self).__init__(message, errno)
self.errno = errno
class FrontendError(MopidyException):
pass
@ -34,3 +40,7 @@ class MixerError(MopidyException):
class ScannerError(MopidyException):
pass
class AudioException(MopidyException):
pass

View File

@ -1,4 +1,4 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import collections
import logging

View File

@ -1,4 +1,4 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import logging
import os

View File

@ -1,4 +1,4 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import json
import logging

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import logging
import os
@ -10,7 +10,7 @@ import tornado.websocket
import mopidy
from mopidy import core, models
from mopidy.utils import jsonrpc
from mopidy.utils import encoding, jsonrpc
logger = logging.getLogger(__name__)
@ -41,7 +41,9 @@ def make_jsonrpc_wrapper(core_actor):
objects={
'core.get_uri_schemes': core.Core.get_uri_schemes,
'core.get_version': core.Core.get_version,
'core.history': core.HistoryController,
'core.library': core.LibraryController,
'core.mixer': core.MixerController,
'core.playback': core.PlaybackController,
'core.playlists': core.PlaylistsController,
'core.tracklist': core.TracklistController,
@ -51,7 +53,9 @@ def make_jsonrpc_wrapper(core_actor):
'core.describe': inspector.describe,
'core.get_uri_schemes': core_actor.get_uri_schemes,
'core.get_version': core_actor.get_version,
'core.history': core_actor.history,
'core.library': core_actor.library,
'core.mixer': core_actor.mixer,
'core.playback': core_actor.playback,
'core.playlists': core_actor.playlists,
'core.tracklist': core_actor.tracklist,
@ -71,7 +75,16 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
@classmethod
def broadcast(cls, msg):
for client in cls.clients:
client.write_message(msg)
# We could check for client.ws_connection, but we don't really
# care why the broadcast failed, we just want the rest of them
# to succeed, so catch everything.
try:
client.write_message(msg)
except Exception as e:
error_msg = encoding.locale_decode(e)
logger.debug('Broadcast of WebSocket message to %s failed: %s',
client.request.remote_ip, error_msg)
# TODO: should this do the same cleanup as the on_message code?
def initialize(self, core):
self.jsonrpc = make_jsonrpc_wrapper(core)
@ -109,7 +122,8 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
'Sent WebSocket message to %s: %r',
self.request.remote_ip, response)
except Exception as e:
logger.error('WebSocket request error: %s', e)
error_msg = encoding.locale_decode(e)
logger.error('WebSocket request error: %s', error_msg)
if self.ws_connection:
# Tornado 3.2+ checks if self.ws_connection is None before
# using it, but not older versions.

View File

@ -1,4 +1,4 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import logging
@ -17,7 +17,21 @@ def send(cls, event, **kwargs):
listeners = pykka.ActorRegistry.get_by_class(cls)
logger.debug('Sending %s to %s: %s', event, cls.__name__, kwargs)
for listener in listeners:
listener.proxy().on_event(event, **kwargs)
# Save time by calling methods on Pykka actor without creating a
# throwaway actor proxy.
#
# Because we use `.tell()` there is no return channel for any errors,
# so Pykka logs them immediately. The alternative would be to use
# `.ask()` and `.get()` the returned futures to block for the listeners
# to react and return their exceptions to us. Since emitting events in
# practise is making calls upwards in the stack, blocking here would
# quickly deadlock.
listener.tell({
'command': 'pykka_call',
'attr_path': ('on_event',),
'args': (event,),
'kwargs': kwargs,
})
class Listener(object):

View File

@ -1,10 +1,10 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import logging
import os
import mopidy
from mopidy import config, ext
from mopidy import config, ext, models
logger = logging.getLogger(__name__)
@ -24,11 +24,12 @@ class Extension(ext.Extension):
schema['library'] = config.String()
schema['media_dir'] = config.Path()
schema['data_dir'] = config.Path()
schema['playlists_dir'] = config.Path()
schema['playlists_dir'] = config.Deprecated()
schema['tag_cache_file'] = config.Deprecated()
schema['scan_timeout'] = config.Integer(
minimum=1000, maximum=1000 * 60 * 60)
schema['scan_flush_threshold'] = config.Integer(minimum=0)
schema['scan_follow_symlinks'] = config.Boolean()
schema['excluded_file_extensions'] = config.List(optional=True)
return schema
@ -69,6 +70,10 @@ class Library(object):
#: Name of the local library implementation, must be overriden.
name = None
#: Feature marker to indicate that you want :meth:`add()` calls to be
#: called with optional arguments tags and duration.
add_supports_tags_and_duration = False
def __init__(self, config):
self._config = config
@ -84,6 +89,43 @@ class Library(object):
"""
raise NotImplementedError
def get_distinct(self, field, query=None):
"""
List distinct values for a given field from the library.
:param string field: One of ``artist``, ``albumartist``, ``album``,
``composer``, ``performer``, ``date``or ``genre``.
:param dict query: Query to use for limiting results, see
:meth:`search` for details about the query format.
:rtype: set of values corresponding to the requested field type.
"""
return set()
def get_images(self, uris):
"""
Lookup the images for the given URIs.
The default implementation will simply call :meth:`lookup` and
try and use the album art for any tracks returned. Most local
libraries should replace this with something smarter or simply
return an empty dictionary.
:param list uris: list of URIs to find images for
:rtype: {uri: tuple of :class:`mopidy.models.Image`}
"""
result = {}
for uri in uris:
image_uris = set()
tracks = self.lookup(uri)
# local libraries may return single track
if isinstance(tracks, models.Track):
tracks = [tracks]
for track in tracks:
if track.album and track.album.images:
image_uris.update(track.album.images)
result[uri] = [models.Image(uri=u) for u in image_uris]
return result
def load(self):
"""
(Re)load any tracks stored in memory, if any, otherwise just return
@ -99,11 +141,9 @@ class Library(object):
"""
Lookup the given URI.
Unlike the core APIs, local tracks uris can only be resolved to a
single track.
:param string uri: track URI
:rtype: :class:`~mopidy.models.Track`
:rtype: list of :class:`~mopidy.models.Track` (or single
:class:`~mopidy.models.Track` for backward compatibility)
"""
raise NotImplementedError
@ -136,12 +176,19 @@ class Library(object):
"""
raise NotImplementedError
def add(self, track):
def add(self, track, tags=None, duration=None):
"""
Add the given track to library.
Add the given track to library. Optional args will only be added if
:attr:`add_supports_tags_and_duration` has been set.
:param track: Track to add to the library
:type track: :class:`~mopidy.models.Track`
:param tags: All the tags the scanner found for the media. See
:mod:`mopidy.audio.utils` for details about the tags.
:type tags: dictionary of tag keys with a list of values.
:param duration: Duration of media in milliseconds or :class:`None` if
unknown
:type duration: :class:`int` or :class:`None`
"""
raise NotImplementedError

View File

@ -1,4 +1,4 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import logging
@ -8,7 +8,6 @@ from mopidy import backend
from mopidy.local import storage
from mopidy.local.library import LocalLibraryProvider
from mopidy.local.playback import LocalPlaybackProvider
from mopidy.local.playlists import LocalPlaylistsProvider
logger = logging.getLogger(__name__)
@ -36,5 +35,4 @@ class LocalBackend(pykka.ThreadingActor, backend.Backend):
logger.warning('Local library %s not found', library_name)
self.playback = LocalPlaybackProvider(audio=audio, backend=self)
self.playlists = LocalPlaylistsProvider(backend=self)
self.library = LocalLibraryProvider(backend=self, library=library)

View File

@ -1,17 +1,20 @@
from __future__ import print_function, unicode_literals
from __future__ import (
absolute_import, division, print_function, unicode_literals)
import logging
import os
import time
from mopidy import commands, exceptions
from mopidy.audio import scan
from mopidy import commands, compat, exceptions
from mopidy.audio import scan, utils
from mopidy.local import translator
from mopidy.utils import path
logger = logging.getLogger(__name__)
MIN_DURATION_MS = 100 # Shortest length of track to include.
def _get_library(args, config):
libraries = dict((l.name, l) for l in args.registry['local:library'])
@ -39,7 +42,7 @@ class ClearCommand(commands.Command):
library = _get_library(args, config)
prompt = '\nAre you sure you want to clear the library? [y/N] '
if raw_input(prompt).lower() != 'y':
if compat.input(prompt).lower() != 'y':
print('Clearing library aborted.')
return 0
@ -58,7 +61,10 @@ class ScanCommand(commands.Command):
super(ScanCommand, self).__init__()
self.add_argument('--limit',
action='store', type=int, dest='limit', default=None,
help='Maxmimum number of tracks to scan')
help='Maximum number of tracks to scan')
self.add_argument('--force',
action='store_true', dest='force', default=False,
help='Force rescan of all media files')
def run(self, args, config):
media_dir = config['local']['media_dir']
@ -70,23 +76,33 @@ class ScanCommand(commands.Command):
library = _get_library(args, config)
uris_to_update = set()
uris_to_remove = set()
file_mtimes, file_errors = path.find_mtimes(
media_dir, follow=config['local']['scan_follow_symlinks'])
file_mtimes = path.find_mtimes(media_dir)
logger.info('Found %d files in media_dir.', len(file_mtimes))
if file_errors:
logger.warning('Encountered %d errors while scanning media_dir.',
len(file_errors))
for name in file_errors:
logger.debug('Scan error %r for %r', file_errors[name], name)
num_tracks = library.load()
logger.info('Checking %d tracks from library.', num_tracks)
uris_to_update = set()
uris_to_remove = set()
uris_in_library = set()
for track in library.begin():
abspath = translator.local_track_uri_to_path(track.uri, media_dir)
mtime = file_mtimes.pop(abspath, None)
mtime = file_mtimes.get(abspath)
if mtime is None:
logger.debug('Missing file %s', track.uri)
uris_to_remove.add(track.uri)
elif mtime > track.last_modified:
elif mtime > track.last_modified or args.force:
uris_to_update.add(track.uri)
uris_in_library.add(track.uri)
logger.info('Removing %d missing tracks.', len(uris_to_remove))
for uri in uris_to_remove:
@ -96,11 +112,12 @@ class ScanCommand(commands.Command):
relpath = os.path.relpath(abspath, media_dir)
uri = translator.path_to_local_track_uri(relpath)
if relpath.lower().endswith(excluded_file_extensions):
if b'/.' in relpath:
logger.debug('Skipped %s: Hidden directory/file.', uri)
elif relpath.lower().endswith(excluded_file_extensions):
logger.debug('Skipped %s: File extension excluded.', uri)
continue
uris_to_update.add(uri)
elif uri not in uris_in_library:
uris_to_update.add(uri)
logger.info(
'Found %d tracks which need to be updated.', len(uris_to_update))
@ -116,10 +133,20 @@ class ScanCommand(commands.Command):
try:
relpath = translator.local_track_uri_to_path(uri, media_dir)
file_uri = path.path_to_uri(os.path.join(media_dir, relpath))
data = scanner.scan(file_uri)
track = scan.audio_data_to_track(data).copy(uri=uri)
library.add(track)
logger.debug('Added %s', track.uri)
result = scanner.scan(file_uri)
tags, duration = result.tags, result.duration
if duration < MIN_DURATION_MS:
logger.warning('Failed %s: Track shorter than %dms',
uri, MIN_DURATION_MS)
else:
mtime = file_mtimes.get(os.path.join(media_dir, relpath))
track = utils.convert_tags_to_track(tags).copy(
uri=uri, length=duration, last_modified=mtime)
if library.add_supports_tags_and_duration:
library.add(track, tags=tags, duration=duration)
else:
library.add(track)
logger.debug('Added %s', track.uri)
except exceptions.ScannerError as error:
logger.warning('Failed %s: %s', uri, error)

View File

@ -3,9 +3,9 @@ enabled = true
library = json
media_dir = $XDG_MUSIC_DIR
data_dir = $XDG_DATA_DIR/mopidy/local
playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists
scan_timeout = 1000
scan_flush_threshold = 1000
scan_follow_symlinks = false
excluded_file_extensions =
.directory
.html

View File

@ -1,4 +1,4 @@
from __future__ import absolute_import, unicode_literals
from __future__ import absolute_import, absolute_import, unicode_literals
import collections
import gzip
@ -8,12 +8,11 @@ import os
import re
import sys
import tempfile
import time
import mopidy
from mopidy import local, models
from mopidy import compat, local, models
from mopidy.local import search, storage, translator
from mopidy.utils import encoding
from mopidy.utils import encoding, timer
logger = logging.getLogger(__name__)
@ -109,20 +108,6 @@ class _BrowseCache(object):
return self._cache.get(uri, {}).values()
# TODO: make this available to other code?
class DebugTimer(object):
def __init__(self, msg):
self.msg = msg
self.start = None
def __enter__(self):
self.start = time.time()
def __exit__(self, exc_type, exc_value, traceback):
duration = (time.time() - self.start) * 1000
logger.debug('%s: %dms', self.msg, duration)
class JsonLibrary(local.Library):
name = 'json'
@ -142,29 +127,63 @@ class JsonLibrary(local.Library):
def load(self):
logger.debug('Loading library: %s', self._json_file)
with DebugTimer('Loading tracks'):
with timer.time_logger('Loading tracks'):
library = load_library(self._json_file)
self._tracks = dict((t.uri, t) for t in library.get('tracks', []))
with DebugTimer('Building browse cache'):
with timer.time_logger('Building browse cache'):
self._browse_cache = _BrowseCache(sorted(self._tracks.keys()))
return len(self._tracks)
def lookup(self, uri):
try:
return self._tracks[uri]
return [self._tracks[uri]]
except KeyError:
return None
return []
def get_distinct(self, field, query=None):
if field == 'artist':
def distinct(track):
return {a.name for a in track.artists}
elif field == 'albumartist':
def distinct(track):
album = track.album or models.Album()
return {a.name for a in album.artists}
elif field == 'album':
def distinct(track):
album = track.album or models.Album()
return {album.name}
elif field == 'composer':
def distinct(track):
return {a.name for a in track.composers}
elif field == 'performer':
def distinct(track):
return {a.name for a in track.performers}
elif field == 'date':
def distinct(track):
return {track.date}
elif field == 'genre':
def distinct(track):
return {track.genre}
else:
return set()
distinct_result = set()
search_result = search.search(self._tracks.values(), query, limit=None)
for track in search_result.tracks:
distinct_result.update(distinct(track))
return distinct_result
def search(self, query=None, limit=100, offset=0, uris=None, exact=False):
tracks = self._tracks.values()
# TODO: pass limit and offset into search helpers
if exact:
return search.find_exact(tracks, query=query, uris=uris)
return search.find_exact(
tracks, query=query, limit=limit, offset=offset, uris=uris)
else:
return search.search(tracks, query=query, uris=uris)
return search.search(
tracks, query=query, limit=limit, offset=offset, uris=uris)
def begin(self):
return self._tracks.itervalues()
return compat.itervalues(self._tracks)
def add(self, track):
self._tracks[track.uri] = track

View File

@ -1,4 +1,4 @@
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals
import logging
@ -23,6 +23,16 @@ class LocalLibraryProvider(backend.LibraryProvider):
return []
return self._library.browse(uri)
def get_distinct(self, field, query=None):
if not self._library:
return set()
return self._library.get_distinct(field, query)
def get_images(self, uris):
if not self._library:
return {}
return self._library.get_images(uris)
def refresh(self, uri=None):
if not self._library:
return 0
@ -33,18 +43,15 @@ class LocalLibraryProvider(backend.LibraryProvider):
def lookup(self, uri):
if not self._library:
return []
track = self._library.lookup(uri)
if track is None:
tracks = self._library.lookup(uri)
if tracks is None:
logger.debug('Failed to lookup %r', uri)
return []
return [track]
if isinstance(tracks, models.Track):
tracks = [tracks]
return tracks
def find_exact(self, query=None, uris=None):
def search(self, query=None, uris=None, exact=False):
if not self._library:
return None
return self._library.search(query=query, uris=uris, exact=True)
def search(self, query=None, uris=None):
if not self._library:
return None
return self._library.search(query=query, uris=uris, exact=False)
return self._library.search(query=query, uris=uris, exact=exact)

View File

@ -1,16 +1,10 @@
from __future__ import unicode_literals
import logging
from __future__ import absolute_import, unicode_literals
from mopidy import backend
from mopidy.local import translator
logger = logging.getLogger(__name__)
class LocalPlaybackProvider(backend.PlaybackProvider):
def change_track(self, track):
track = track.copy(uri=translator.local_track_uri_to_file_uri(
track.uri, self.backend.config['local']['media_dir']))
return super(LocalPlaybackProvider, self).change_track(track)
def translate_uri(self, uri):
return translator.local_track_uri_to_file_uri(
uri, self.backend.config['local']['media_dir'])

Some files were not shown because too many files have changed in this diff Show More