Release v1.0.0
This commit is contained in:
commit
85f11baa41
3
.gitignore
vendored
3
.gitignore
vendored
@ -13,8 +13,7 @@ cover/
|
||||
coverage.xml
|
||||
dist/
|
||||
docs/_build/
|
||||
js/test/lib/
|
||||
mopidy.log*
|
||||
node_modules/
|
||||
nosetests.xml
|
||||
xunit-*.xml
|
||||
tmp/
|
||||
|
||||
7
.mailmap
7
.mailmap
@ -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>
|
||||
|
||||
@ -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
17
AUTHORS
@ -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>
|
||||
|
||||
@ -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 *
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -35,3 +35,9 @@ Audio scanner
|
||||
|
||||
.. autoclass:: mopidy.audio.scan.Scanner
|
||||
:members:
|
||||
|
||||
Audio utils
|
||||
===========
|
||||
|
||||
.. automodule:: mopidy.audio.utils
|
||||
:members:
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
=============
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
@ -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::
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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**
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
-----
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
15
docs/conf.py
15
docs/conf.py
@ -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/'),
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -4,147 +4,125 @@
|
||||
Contributing
|
||||
************
|
||||
|
||||
If you are thinking about making Mopidy better, or you just want to hack on it,
|
||||
that’s 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.
|
||||
|
||||
@ -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
593
docs/devenv.rst
Normal 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
|
||||
@ -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
|
||||
===================
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
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
55
docs/ext/m3u.rst
Normal 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
BIN
docs/ext/mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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::
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
24
extra/mopidyctl/mopidyctl
Executable 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
|
||||
17
extra/mopidyctl/mopidyctl.8
Normal file
17
extra/mopidyctl/mopidyctl.8
Normal 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
|
||||
16
extra/systemd/mopidy.service
Normal file
16
extra/systemd/mopidy.service
Normal 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
63
fabfile.py
vendored
@ -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")
|
||||
101
js/Gruntfile.js
101
js/Gruntfile.js
@ -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");
|
||||
};
|
||||
121
js/README.md
121
js/README.md
@ -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/).
|
||||
15
js/buster.js
15
js/buster.js
@ -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"]
|
||||
};
|
||||
@ -1 +0,0 @@
|
||||
module.exports = { Client: window.WebSocket };
|
||||
@ -1,4 +0,0 @@
|
||||
{
|
||||
"browser": "browser.js",
|
||||
"main": "server.js"
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
module.exports = require('faye-websocket');
|
||||
@ -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": "*"
|
||||
}
|
||||
}
|
||||
331
js/src/mopidy.js
331
js/src/mopidy.js
@ -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;
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
@ -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.");
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -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'
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from __future__ import print_function, unicode_literals
|
||||
from __future__ import absolute_import, print_function, unicode_literals
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
|
||||
class PlaybackState(object):
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
30
mopidy/compat.py
Normal 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))
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -9,11 +9,10 @@ config_file =
|
||||
mixer = software
|
||||
mixer_volume =
|
||||
output = autoaudiosink
|
||||
visualizer =
|
||||
|
||||
[proxy]
|
||||
scheme =
|
||||
hostname =
|
||||
port =
|
||||
port =
|
||||
username =
|
||||
password =
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
# TODO: add validate regexp?
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
58
mopidy/core/history.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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
58
mopidy/core/mixer.py
Normal 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()
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = []
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import collections
|
||||
import logging
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
@ -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
6
mopidy/http/data/mopidy.min.js
vendored
6
mopidy/http/data/mopidy.min.js
vendored
File diff suppressed because one or more lines are too long
@ -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.
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user