Merge branch 'develop' into feature/implement-gapless
Conflicts: mopidy/backend.py mopidy/commands.py mopidy/core/actor.py mopidy/core/playback.py tests/audio/test_actor.py tests/core/test_playback.py tests/local/test_playback.py
This commit is contained in:
commit
11c9aa4ad0
1
.gitignore
vendored
1
.gitignore
vendored
@ -16,3 +16,4 @@ docs/_build/
|
|||||||
mopidy.log*
|
mopidy.log*
|
||||||
nosetests.xml
|
nosetests.xml
|
||||||
xunit-*.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@iterate.no>
|
||||||
Johannes Knutsen <johannes@knutseninfo.no> <johannes@barbarmaclin.(none)>
|
Johannes Knutsen <johannes@knutseninfo.no> <johannes@barbarmaclin.(none)>
|
||||||
John Bäckstrand <sopues@gmail.com> <sandos@XBMCLive.(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>
|
Alli Witheford <alzeih@gmail.com>
|
||||||
Alexandre Petitjean <alpetitjean@gmail.com>
|
Alexandre Petitjean <alpetitjean@gmail.com>
|
||||||
Alexandre Petitjean <alpetitjean@gmail.com> <alexandre.petitjean@lne.fr>
|
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>
|
Janez Troha <janez.troha@gmail.com> <dz0ny@ubuntu.si>
|
||||||
Luke Giuliani <luke@giuliani.com.au>
|
Luke Giuliani <luke@giuliani.com.au>
|
||||||
Colin Montgomerie <kiteflyingmonkey@gmail.com>
|
Colin Montgomerie <kiteflyingmonkey@gmail.com>
|
||||||
|
Nathan Harper <nathan.sam.harper@gmail.com>
|
||||||
Ignasi Fosch <natx@y10k.ws> <ifosch@serenity-2.local>
|
Ignasi Fosch <natx@y10k.ws> <ifosch@serenity-2.local>
|
||||||
Christopher Schirner <christopher@hackerspace-bamberg.de> <schinken@hackerspace-bamberg.de>
|
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>
|
||||||
|
|||||||
18
.travis.yml
18
.travis.yml
@ -1,8 +1,18 @@
|
|||||||
|
sudo: false
|
||||||
|
|
||||||
language: python
|
language: python
|
||||||
|
|
||||||
python:
|
python:
|
||||||
- "2.7_with_system_site_packages"
|
- "2.7_with_system_site_packages"
|
||||||
|
|
||||||
|
addons:
|
||||||
|
apt:
|
||||||
|
sources:
|
||||||
|
- mopidy-stable
|
||||||
|
packages:
|
||||||
|
- graphviz-dev
|
||||||
|
- mopidy
|
||||||
|
|
||||||
env:
|
env:
|
||||||
- TOX_ENV=py27
|
- TOX_ENV=py27
|
||||||
- TOX_ENV=py27-tornado23
|
- TOX_ENV=py27-tornado23
|
||||||
@ -11,10 +21,6 @@ env:
|
|||||||
- TOX_ENV=flake8
|
- TOX_ENV=flake8
|
||||||
|
|
||||||
install:
|
install:
|
||||||
- "wget -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add -"
|
|
||||||
- "sudo wget -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list"
|
|
||||||
- "sudo apt-get update || true"
|
|
||||||
- "sudo apt-get install mopidy graphviz-dev"
|
|
||||||
- "pip install tox"
|
- "pip install tox"
|
||||||
|
|
||||||
script:
|
script:
|
||||||
@ -23,6 +29,10 @@ script:
|
|||||||
after_success:
|
after_success:
|
||||||
- "if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi"
|
- "if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi"
|
||||||
|
|
||||||
|
branches:
|
||||||
|
except:
|
||||||
|
- debian
|
||||||
|
|
||||||
notifications:
|
notifications:
|
||||||
irc:
|
irc:
|
||||||
channels:
|
channels:
|
||||||
|
|||||||
14
AUTHORS
14
AUTHORS
@ -8,14 +8,14 @@
|
|||||||
- John Bäckstrand <sopues@gmail.com>
|
- John Bäckstrand <sopues@gmail.com>
|
||||||
- Fred Hatfull <fred.hatfull@gmail.com>
|
- Fred Hatfull <fred.hatfull@gmail.com>
|
||||||
- Erling Børresen <erling@fenicore.net>
|
- Erling Børresen <erling@fenicore.net>
|
||||||
- David C <dav@dav.com>
|
- David Caruso <deibido.caruso@gmail.com>
|
||||||
- Christian Johansen <christian@cjohansen.no>
|
- Christian Johansen <christian@cjohansen.no>
|
||||||
- Matt Bray <mattjbray@gmail.com>
|
- Matt Bray <mattjbray@gmail.com>
|
||||||
- Trygve Aaberge <trygveaa@gmail.com>
|
- Trygve Aaberge <trygveaa@gmail.com>
|
||||||
- Wouter van Wijk <woutervanwijk@gmail.com>
|
- Wouter van Wijk <woutervanwijk@gmail.com>
|
||||||
- Jeremy B. Merrill <jeremybmerrill@gmail.com>
|
- Jeremy B. Merrill <jeremybmerrill@gmail.com>
|
||||||
- 0xadam <radx@live.com.au>
|
- Adam Rigg <adam@adamrigg.id.au>
|
||||||
- herrernst <herr.ernst@gmail.com>
|
- Ernst Bammer <herr.ernst@gmail.com>
|
||||||
- Nick Steel <kingosticks@gmail.com>
|
- Nick Steel <kingosticks@gmail.com>
|
||||||
- Zan Dobersek <zandobersek@gmail.com>
|
- Zan Dobersek <zandobersek@gmail.com>
|
||||||
- Thomas Refis <refis.thomas@gmail.com>
|
- Thomas Refis <refis.thomas@gmail.com>
|
||||||
@ -36,7 +36,7 @@
|
|||||||
- Colin Montgomerie <kiteflyingmonkey@gmail.com>
|
- Colin Montgomerie <kiteflyingmonkey@gmail.com>
|
||||||
- Simon de Bakker <simon@simbits.nl>
|
- Simon de Bakker <simon@simbits.nl>
|
||||||
- Arnaud Barisain-Monrose <abarisain@gmail.com>
|
- 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>
|
- Pierpaolo Frasa <pfrasa@smail.uni-koeln.de>
|
||||||
- Thomas Scholtes <thomas-scholtes@gmx.de>
|
- Thomas Scholtes <thomas-scholtes@gmx.de>
|
||||||
- Sam Willcocks <sam@wlcx.cc>
|
- Sam Willcocks <sam@wlcx.cc>
|
||||||
@ -47,3 +47,9 @@
|
|||||||
- Lukas Vogel <lukas@vogelnest.org>
|
- Lukas Vogel <lukas@vogelnest.org>
|
||||||
- Thomas Amland <thomas.amland@gmail.com>
|
- Thomas Amland <thomas.amland@gmail.com>
|
||||||
- Deni Bertovic <deni@kset.org>
|
- 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>
|
||||||
|
|||||||
@ -12,12 +12,11 @@ flake8-import-order
|
|||||||
mock
|
mock
|
||||||
|
|
||||||
# Test runners
|
# Test runners
|
||||||
nose
|
pytest
|
||||||
|
pytest-cov
|
||||||
|
pytest-xdist
|
||||||
tox
|
tox
|
||||||
|
|
||||||
# Measure test's code coverage
|
|
||||||
coverage
|
|
||||||
|
|
||||||
# Check that MANIFEST.in matches Git repo contents before making a release
|
# Check that MANIFEST.in matches Git repo contents before making a release
|
||||||
check-manifest
|
check-manifest
|
||||||
|
|
||||||
|
|||||||
@ -22,15 +22,16 @@ Frontends
|
|||||||
=========
|
=========
|
||||||
|
|
||||||
Frontends expose Mopidy to the external world. They can implement servers for
|
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
|
protocols like HTTP, MPD and MPRIS, and they can be used to update other
|
||||||
when something happens in Mopidy, like the Last.fm scrobbler frontend does. See
|
services when something happens in Mopidy, like the Last.fm scrobbler frontend
|
||||||
:ref:`frontend-api` for more details.
|
does. See :ref:`frontend-api` for more details.
|
||||||
|
|
||||||
.. digraph:: frontend_architecture
|
.. digraph:: frontend_architecture
|
||||||
|
|
||||||
|
"HTTP\nfrontend" -> Core
|
||||||
"MPD\nfrontend" -> Core
|
"MPD\nfrontend" -> Core
|
||||||
"MPRIS\nfrontend" -> Core
|
"MPRIS\nfrontend" -> Core
|
||||||
"Last.fm\nfrontend" -> Core
|
"Scrobbler\nfrontend" -> Core
|
||||||
|
|
||||||
|
|
||||||
Core
|
Core
|
||||||
@ -55,6 +56,7 @@ See :ref:`core-api` for more details.
|
|||||||
Core -> "Library\ncontroller"
|
Core -> "Library\ncontroller"
|
||||||
Core -> "Playback\ncontroller"
|
Core -> "Playback\ncontroller"
|
||||||
Core -> "Playlists\ncontroller"
|
Core -> "Playlists\ncontroller"
|
||||||
|
Core -> "History\ncontroller"
|
||||||
|
|
||||||
"Library\ncontroller" -> "Local backend"
|
"Library\ncontroller" -> "Local backend"
|
||||||
"Library\ncontroller" -> "Spotify backend"
|
"Library\ncontroller" -> "Spotify backend"
|
||||||
@ -95,7 +97,8 @@ Audio
|
|||||||
|
|
||||||
The audio actor is a thin wrapper around the parts of the GStreamer library we
|
The audio actor is a thin wrapper around the parts of the GStreamer library we
|
||||||
use. If you implement an advanced backend, you may need to implement your own
|
use. If you implement an advanced backend, you may need to implement your own
|
||||||
playback provider using the :ref:`audio-api`.
|
playback provider using the :ref:`audio-api`, but most backends can use the
|
||||||
|
default playback provider without any changes.
|
||||||
|
|
||||||
|
|
||||||
Mixer
|
Mixer
|
||||||
|
|||||||
@ -64,6 +64,14 @@ Manages the music library, e.g. searching for tracks to be added to a playlist.
|
|||||||
:members:
|
:members:
|
||||||
|
|
||||||
|
|
||||||
|
Mixer controller
|
||||||
|
================
|
||||||
|
|
||||||
|
Manages volume and muting.
|
||||||
|
|
||||||
|
.. autoclass:: mopidy.core.MixerController
|
||||||
|
:members:
|
||||||
|
|
||||||
Core listener
|
Core listener
|
||||||
=============
|
=============
|
||||||
|
|
||||||
|
|||||||
@ -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
|
:ref:`http-explore-extension` extension, can also be used to get you
|
||||||
familiarized with HTTP based APIs.
|
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:
|
.. _http-post-api:
|
||||||
|
|
||||||
|
|||||||
@ -4,13 +4,10 @@
|
|||||||
API reference
|
API reference
|
||||||
*************
|
*************
|
||||||
|
|
||||||
.. warning:: API stability
|
.. note:: What is public?
|
||||||
|
|
||||||
Only APIs documented here are public and open for use by Mopidy
|
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
|
extensions.
|
||||||
date with all breaking changes.
|
|
||||||
|
|
||||||
From Mopidy 1.0 and onwards, we intend to keep these APIs far more stable.
|
|
||||||
|
|
||||||
|
|
||||||
.. toctree::
|
.. 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
|
:ref:`websocket-api` and gets you quickly started with working on your client
|
||||||
instead of figuring out how to communicate with Mopidy.
|
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
|
Getting the library for browser use
|
||||||
===================================
|
===================================
|
||||||
@ -289,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
|
The promise objects returned by Mopidy.js adheres to the `CommonJS Promises/A
|
||||||
<http://wiki.commonjs.org/wiki/Promises/A>`_ standard. We use the
|
<http://wiki.commonjs.org/wiki/Promises/A>`_ standard. We use the
|
||||||
implementation known as `when.js <https://github.com/cujojs/when>`_. Please
|
implementation known as `when.js <https://github.com/cujojs/when>`_, and
|
||||||
refer to when.js' documentation or the standard for further details on how to
|
reexport it as ``Mopidy.when`` so you don't have to duplicate the dependency.
|
||||||
work with promise objects.
|
Please refer to when.js' documentation or the standard for further details on
|
||||||
|
how to work with promise objects.
|
||||||
|
|
||||||
|
|
||||||
Cleaning up
|
Cleaning up
|
||||||
|
|||||||
@ -28,19 +28,54 @@ Data model relations
|
|||||||
|
|
||||||
.. digraph:: model_relations
|
.. digraph:: model_relations
|
||||||
|
|
||||||
Playlist -> Track [ label="has 0..n" ]
|
Ref -> Album [ style="dotted", weight=1 ]
|
||||||
Track -> Album [ label="has 0..1" ]
|
Ref -> Artist [ style="dotted", weight=1 ]
|
||||||
Track -> Artist [ label="has 0..n" ]
|
Ref -> Directory [ style="dotted", weight=1 ]
|
||||||
Album -> Artist [ label="has 0..n" ]
|
Ref -> Playlist [ style="dotted", weight=1 ]
|
||||||
|
Ref -> Track [ style="dotted", weight=1 ]
|
||||||
|
|
||||||
SearchResult -> Artist [ label="has 0..n" ]
|
Playlist -> Track [ label="has 0..n", weight=2 ]
|
||||||
SearchResult -> Album [ label="has 0..n" ]
|
Track -> Album [ label="has 0..1", weight=10 ]
|
||||||
SearchResult -> Track [ label="has 0..n" ]
|
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
|
Data model API
|
||||||
==============
|
==============
|
||||||
|
|
||||||
.. automodule:: mopidy.models
|
.. module:: mopidy.models
|
||||||
:synopsis: Data model API
|
: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
|
.. include:: ../AUTHORS
|
||||||
|
|
||||||
If you already enjoy Mopidy, or don't enjoy it and want to help us making
|
If want to help us making Mopidy better, the best way to do so is to contribute
|
||||||
Mopidy better, the best way to do so is to contribute back to the community.
|
back to the community, either through code, documentation, tests, bug reports,
|
||||||
You can contribute code, documentation, tests, bug reports, or help other
|
or by helping other users, spreading the word, etc. See :ref:`contributing` for
|
||||||
users, spreading the word, etc. See :ref:`contributing` for a head start.
|
a head start.
|
||||||
|
|||||||
@ -5,53 +5,424 @@ Changelog
|
|||||||
This changelog is used to track all major changes to Mopidy.
|
This changelog is used to track all major changes to Mopidy.
|
||||||
|
|
||||||
|
|
||||||
v0.20.0 (UNRELEASED)
|
v1.1.0 (UNRELEASED)
|
||||||
====================
|
===================
|
||||||
|
|
||||||
**Core API**
|
Core API
|
||||||
|
--------
|
||||||
|
|
||||||
- Added :class:`mopidy.core.HistoryController` which keeps track of what
|
- Calling :meth:`mopidy.core.library.LibraryController.search`` with ``kwargs``
|
||||||
tracks have been played. (Fixes: :issue:`423`, PR: :issue:`803`)
|
as the query is no longer supported (PR: :issue:`1090`)
|
||||||
|
|
||||||
- Removed ``clear_current_track`` keyword argument to
|
Internal changes
|
||||||
:meth:`mopidy.core.Playback.stop`. It was a leaky internal abstraction,
|
----------------
|
||||||
which was never intended to be used externally.
|
|
||||||
|
|
||||||
**Commands**
|
- Tests have been cleaned up to stop using deprecated APIs where feasible.
|
||||||
|
(Partial fix: :issue:`1083`, PR: :issue:`1090`)
|
||||||
|
|
||||||
|
|
||||||
|
v1.0.1 (UNRELEASED)
|
||||||
|
===================
|
||||||
|
|
||||||
|
- Audio: Software volume control has been reworked to greatly reduce the delay
|
||||||
|
between changing the volume and the change taking effect. (Fixes:
|
||||||
|
:issue:`1097`)
|
||||||
|
|
||||||
|
- Audio: As a side effect of the previous bug fix, software volume is no longer
|
||||||
|
tied to the PulseAudio application volume when using ``pulsesink``. This
|
||||||
|
behavior was confusing for many users and doesn't work well with the plans
|
||||||
|
for multiple outputs.
|
||||||
|
|
||||||
|
|
||||||
|
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
|
- Make the ``mopidy`` command print a friendly error message if the
|
||||||
:mod:`gobject` Python module cannot be imported. (Fixes: :issue:`836`)
|
:mod:`gobject` Python module cannot be imported. (Fixes: :issue:`836`)
|
||||||
|
|
||||||
**Local backend**
|
- 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 cover URL to all scanned files with MusicBrainz album IDs. (Fixes:
|
- Add path to the current ``mopidy`` executable to the output of ``mopidy
|
||||||
:issue:`697`, PR: :issue:`802`)
|
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.
|
||||||
|
|
||||||
- Local library API: Implementors of :meth:`mopidy.local.Library.lookup` should
|
Configuration
|
||||||
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`)
|
|
||||||
|
|
||||||
**File scanner**
|
- 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``.
|
||||||
|
|
||||||
- Improve error logging for scan code (Fixes: :issue:`856`, PR: :issue:`874`)
|
- Add debug logging of unknown sections. (Fixes: :issue:`694`, PR: :issue:`1002`)
|
||||||
|
|
||||||
- Add symlink support with loop protection to file finder (Fixes: :issue:`858`,
|
Logging
|
||||||
PR: :isusue:`874`)
|
-------
|
||||||
|
|
||||||
**MPD frontend**
|
- 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``.
|
||||||
|
|
||||||
- In stored playlist names, replace "/", which are illegal, with "|" instead of
|
- Add support for per logger color overrides. (Fixes: :issue:`808`, PR:
|
||||||
a whitespace. Pipes are more similar to forward slash.
|
: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.
|
- Enable browsing of artist references, in addition to albums and playlists.
|
||||||
(PR: :issue:`884`)
|
(PR: :issue:`884`)
|
||||||
|
|
||||||
**Audio**
|
- Switch the ``list`` command over to using the new method
|
||||||
|
:meth:`mopidy.core.LibraryController.get_distinct` for increased performance.
|
||||||
|
(Fixes: :issue:`913`)
|
||||||
|
|
||||||
- Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. Pass a
|
- In stored playlist names, replace "/", which are illegal, with "|" instead of
|
||||||
:class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` to end the
|
a whitespace. Pipes are more similar to forward slash.
|
||||||
stream.
|
|
||||||
|
- 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:
|
- Internal code cleanup within audio subsystem:
|
||||||
|
|
||||||
@ -65,40 +436,59 @@ v0.20.0 (UNRELEASED)
|
|||||||
|
|
||||||
- Add internal helper for converting GStreamer data types to Python.
|
- Add internal helper for converting GStreamer data types to Python.
|
||||||
|
|
||||||
- Move MusicBrainz coverart code out of audio and into local.
|
- Reduce scope of audio scanner to just find tags and duration. Modification
|
||||||
|
time, URI and minimum length handling are now outside of this class.
|
||||||
- Reduce scope of audio scanner to just tags + duration. Mtime, uri and min
|
|
||||||
length handling are now outside of this class.
|
|
||||||
|
|
||||||
- Update scanner to operate with milliseconds for duration.
|
- Update scanner to operate with milliseconds for duration.
|
||||||
|
|
||||||
- Add :meth:`mopidy.audio.AudioListener.tags_changed`. Notifies core when new tags
|
- Update scanner to use a custom source, typefind and decodebin. This allows
|
||||||
are found.
|
us to detect playlists before we try to decode them.
|
||||||
|
|
||||||
- Add :meth:`mopidy.audio.Audio.get_current_tags` for looking up the current
|
- Refactored scanner to create a new pipeline per track, this is needed as
|
||||||
tags of the playing media.
|
reseting decodebin is much slower than tearing it down and making a fresh
|
||||||
|
one.
|
||||||
|
|
||||||
- Move and rename helper for converting tags to tracks.
|
- Move and rename helper for converting tags to tracks.
|
||||||
|
|
||||||
- Helper now ignores albums without a name.
|
- Ignore albums without a name when converting tags to tracks.
|
||||||
|
|
||||||
- Kill support for visualizers. Feature was originally added as a workaround for
|
- Support UTF-8 in M3U playlists. (Fixes: :issue:`853`)
|
||||||
all the people asking for ncmpcpp visualizer support. And since we could get
|
|
||||||
it almost for free thanks to GStreamer. But this feature didn't really ever
|
|
||||||
make sense for a server such as Mopidy. Currently the only way to find out if
|
|
||||||
it is in use and will be missed is to go ahead and remove it.
|
|
||||||
|
|
||||||
**Stream backend**
|
- Add workaround for volume not persisting across tracks on OS X.
|
||||||
|
(Issue: :issue:`886`, PR: :issue:`958`)
|
||||||
|
|
||||||
- Add basic tests for the stream library provider.
|
- 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`)
|
||||||
|
|
||||||
v0.19.6 (UNRELEASED)
|
- Added support for checking if the media is seekable, and getting the initial
|
||||||
====================
|
MIME type guess. (PR: :issue:`1033`)
|
||||||
|
|
||||||
Bug fix release.
|
Mopidy.js client library
|
||||||
|
------------------------
|
||||||
|
|
||||||
- Audio: Support UTF-8 in M3U playlists. (Fixes: :issue:`853`)
|
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)
|
v0.19.5 (2014-12-23)
|
||||||
@ -571,6 +961,7 @@ guys. Thanks to everyone that has contributed!
|
|||||||
|
|
||||||
- The dummy backend used for testing many frontends have moved from
|
- The dummy backend used for testing many frontends have moved from
|
||||||
:mod:`mopidy.backends.dummy` to :mod:`mopidy.backend.dummy`.
|
:mod:`mopidy.backends.dummy` to :mod:`mopidy.backend.dummy`.
|
||||||
|
(PR: :issue:`984`)
|
||||||
|
|
||||||
**Commands**
|
**Commands**
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
Using Mopidy-MPRIS and Rygel, or using Mopidy-MPD and upmpdcli.
|
||||||
|
|
||||||
|
|
||||||
.. _upmpdcli:
|
|
||||||
|
|
||||||
upmpdcli
|
upmpdcli
|
||||||
--------
|
--------
|
||||||
|
|
||||||
`upmpdcli <http://www.lesbonscomptes.com/upmpdcli/>`_ is recommended, since it
|
`upmpdcli <http://www.lesbonscomptes.com/upmpdcli/>`_ is recommended, since it
|
||||||
is easier to setup, and offers `OpenHome <http://www.openhome.org> ohMedia`_
|
is easier to setup, and offers `OpenHome
|
||||||
compatibility. upmpdcli exposes a UPnP MediaRenderer to the network, while
|
<http://www.openhome.org/wiki/OhMedia>`_ compatibility. upmpdcli exposes a UPnP
|
||||||
using the MPD protocol to control Mopidy.
|
MediaRenderer to the network, while using the MPD protocol to control Mopidy.
|
||||||
|
|
||||||
1. Install upmpdcli. On Debian/Ubuntu::
|
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.
|
4. A UPnP renderer should be available now.
|
||||||
|
|
||||||
|
|
||||||
.. _rygel:
|
|
||||||
|
|
||||||
Rygel
|
Rygel
|
||||||
-----
|
-----
|
||||||
|
|
||||||
|
|||||||
@ -43,7 +43,7 @@ Options
|
|||||||
|
|
||||||
.. cmdoption:: --verbose, -v
|
.. 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
|
.. cmdoption:: --save-debug-log
|
||||||
|
|
||||||
|
|||||||
13
docs/conf.py
13
docs/conf.py
@ -15,6 +15,7 @@ sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + '/../'))
|
|||||||
|
|
||||||
|
|
||||||
class Mock(object):
|
class Mock(object):
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -34,11 +35,13 @@ class Mock(object):
|
|||||||
elif name == 'get_user_config_dir':
|
elif name == 'get_user_config_dir':
|
||||||
# glib.get_user_config_dir()
|
# glib.get_user_config_dir()
|
||||||
return str
|
return str
|
||||||
elif (name[0] == name[0].upper()
|
elif (name[0] == name[0].upper() and
|
||||||
|
# gst.Caps
|
||||||
|
not name.startswith('Caps') and
|
||||||
# gst.PadTemplate
|
# gst.PadTemplate
|
||||||
and not name.startswith('PadTemplate')
|
not name.startswith('PadTemplate') and
|
||||||
# dbus.String()
|
# dbus.String()
|
||||||
and not name == 'String'):
|
not name == 'String'):
|
||||||
return type(name, (), {})
|
return type(name, (), {})
|
||||||
else:
|
else:
|
||||||
return Mock()
|
return Mock()
|
||||||
@ -112,6 +115,9 @@ modindex_common_prefix = ['mopidy.']
|
|||||||
|
|
||||||
# -- Options for HTML output --------------------------------------------------
|
# -- 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 = 'default'
|
||||||
html_theme_path = ['_themes']
|
html_theme_path = ['_themes']
|
||||||
html_static_path = ['_static']
|
html_static_path = ['_static']
|
||||||
@ -155,6 +161,7 @@ man_pages = [
|
|||||||
extlinks = {
|
extlinks = {
|
||||||
'issue': ('https://github.com/mopidy/mopidy/issues/%s', '#'),
|
'issue': ('https://github.com/mopidy/mopidy/issues/%s', '#'),
|
||||||
'commit': ('https://github.com/mopidy/mopidy/commit/%s', 'commit '),
|
'commit': ('https://github.com/mopidy/mopidy/commit/%s', 'commit '),
|
||||||
|
'js': ('https://github.com/mopidy/mopidy.js/issues/%s', 'mopidy.js#'),
|
||||||
'mpris': (
|
'mpris': (
|
||||||
'https://github.com/mopidy/mopidy-mpris/issues/%s', 'mopidy-mpris#'),
|
'https://github.com/mopidy/mopidy-mpris/issues/%s', 'mopidy-mpris#'),
|
||||||
'discuss': ('https://discuss.mopidy.com/t/%s', 'discuss.mopidy.com/t/'),
|
'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
|
will affect the audio volume if you're streaming the audio from Mopidy
|
||||||
through Shoutcast.
|
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
|
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
|
which integrates with your sound subsystem. E.g. for ALSA, install
|
||||||
`Mopidy-ALSAMixer <https://github.com/mopidy/mopidy-alsamixer>`_.
|
`Mopidy-ALSAMixer <https://github.com/mopidy/mopidy-alsamixer>`_.
|
||||||
@ -131,6 +133,14 @@ Logging configuration
|
|||||||
level to use for that logger, one of ``debug``, ``info``, ``warning``,
|
level to use for that logger, one of ``debug``, ``info``, ``warning``,
|
||||||
``error``, or ``critical``.
|
``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
|
.. _the Python logging docs: http://docs.python.org/2/library/logging.config.html
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -4,149 +4,125 @@
|
|||||||
Contributing
|
Contributing
|
||||||
************
|
************
|
||||||
|
|
||||||
If you are thinking about making Mopidy better, or you just want to hack on it,
|
If you want to contribute to Mopidy, here are some tips to get you started.
|
||||||
that’s great. 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
|
Please get in touch with us in one of these ways when requesting help with
|
||||||
issue, assuming one does not already exist. Clearly describe the issue
|
Mopidy and its extensions:
|
||||||
including steps to reproduce when it is a bug.
|
|
||||||
|
|
||||||
#. 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.
|
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
|
||||||
#. Consider making a Python `virtualenv <http://www.virtualenv.org/>`_ for
|
highly. As more people help with user support, new users get faster and better
|
||||||
Mopidy development to wall of Mopidy and it's dependencies from the rest of
|
help. For your own benefit, you'll quickly learn what users find confusing,
|
||||||
your system. If you do so, create the virtualenv with the
|
difficult or lacking, giving you some ideas for where you may contribute
|
||||||
``--system-site-packages`` flag so that Mopidy can use globally installed
|
improvements, either to code or documentation. Lastly, this may also free up
|
||||||
dependencies like GStreamer. If you don't use a virtualenv, you may need to
|
time for other contributors to spend more time on fixing bugs or implementing
|
||||||
run the following ``pip`` and ``python setup.py`` commands with ``sudo`` to
|
new features.
|
||||||
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/``
|
|
||||||
|
|
||||||
|
|
||||||
.. _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
|
#. Before spending any time on making a pull request:
|
||||||
repo.
|
|
||||||
|
|
||||||
#. 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
|
#. Create a new branch, based on the ``develop`` branch, for every feature or
|
||||||
setuptools, run::
|
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
|
- Bug fixes get the prefix ``fix/``, e.g. ``fix/902-consume-track-on-next``.
|
||||||
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.
|
|
||||||
|
|
||||||
#. Now you can run the Mopidy command, and it will run using the code
|
- Improvements to the documentation get the prefix ``docs/``, e.g.
|
||||||
in the Git repo::
|
``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``
|
#. Include tests for any new feature or substantial bug fix. See
|
||||||
to see the changes take effect.
|
: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
|
#. Write good commit messages.
|
||||||
Mopidy to come with tests.
|
|
||||||
|
|
||||||
#. 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
|
- `Writing Git commit messages
|
||||||
`coveralls.io <https://coveralls.io/r/mopidy/mopidy>`_.
|
<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. Ignore the rare
|
#. Send a pull request to the ``develop`` branch. See the `GitHub pull request
|
||||||
cases you need to ignore a check use `# noqa: <code>` so we can lookup what
|
docs <https://help.github.com/articles/using-pull-requests>`_ for help.
|
||||||
you are ignoring.
|
|
||||||
|
|
||||||
#. 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/>`_
|
|
||||||
|
|||||||
@ -1,13 +1,20 @@
|
|||||||
.. _debian:
|
.. _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
|
<http://apt.mopidy.com/>`__ as well as from Debian, Ubuntu and other
|
||||||
Debian-based Linux distributions.
|
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
|
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`.
|
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
|
Mopidy-Local-SQLite
|
||||||
===================
|
===================
|
||||||
|
|
||||||
|
|||||||
@ -73,17 +73,21 @@ See :ref:`config` for general help on configuring Mopidy.
|
|||||||
|
|
||||||
.. confval:: http/static_dir
|
.. 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 "/"
|
Which directory the HTTP server should serve at "/"
|
||||||
|
|
||||||
Change this to have Mopidy serve e.g. files for your JavaScript client.
|
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
|
You're strongly encouraged to make Mopidy extensions which use the the
|
||||||
make Mopidy extensions which use the the :ref:`http-server-api` to host
|
:ref:`http-server-api` to host static files on Mopidy's web server instead
|
||||||
static files on Mopidy's web server instead of using
|
of using :confval:`http/static_dir`. That way, installation of your web
|
||||||
:confval:`http/static_dir`. That way, installation of your web client will
|
client will be a lot easier for your end users, and multiple web clients
|
||||||
be a lot easier for your end users, and multiple web clients can easily
|
can easily share the same web server.
|
||||||
share the same web server.
|
|
||||||
|
|
||||||
.. confval:: http/zeroconf
|
.. confval:: http/zeroconf
|
||||||
|
|
||||||
|
|||||||
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 |
BIN
docs/ext/mopify.jpg
Normal file
BIN
docs/ext/mopify.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 189 KiB |
@ -99,3 +99,10 @@ See :ref:`config` for general help on configuring Mopidy.
|
|||||||
``$hostname`` and ``$port`` can be used in the name.
|
``$hostname`` and ``$port`` can be used in the name.
|
||||||
|
|
||||||
Set to an empty string to disable Zeroconf for MPD.
|
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
|
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
|
Not a full-featured Web client, but rather a local library and Web
|
||||||
Mobile by Thomas Kemmer.
|
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::
|
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
|
Mopidy-Moped
|
||||||
@ -64,12 +86,13 @@ Mopidy-Mopify
|
|||||||
|
|
||||||
https://github.com/dirkgroenen/mopidy-mopify
|
https://github.com/dirkgroenen/mopidy-mopify
|
||||||
|
|
||||||
An web client that mainly targets using Spotify through Mopidy. Made by Dirk
|
A web client that uses external web services to provide additional features and
|
||||||
Groenen.
|
a more "complete" Spotify music experience. It's currently targeted at people
|
||||||
|
using Spotify through Mopidy. Made by Dirk Groenen.
|
||||||
|
|
||||||
.. image:: /ext/mopify.png
|
.. image:: /ext/mopify.jpg
|
||||||
:width: 720
|
:width: 800
|
||||||
:height: 424
|
:height: 416
|
||||||
|
|
||||||
To install, run::
|
To install, run::
|
||||||
|
|
||||||
|
|||||||
@ -189,11 +189,6 @@ class that will connect the rest of the dots.
|
|||||||
'Pykka >= 1.1',
|
'Pykka >= 1.1',
|
||||||
'pysoundspot',
|
'pysoundspot',
|
||||||
],
|
],
|
||||||
test_suite='nose.collector',
|
|
||||||
tests_require=[
|
|
||||||
'nose',
|
|
||||||
'mock >= 1.0',
|
|
||||||
],
|
|
||||||
entry_points={
|
entry_points={
|
||||||
'mopidy.ext': [
|
'mopidy.ext': [
|
||||||
'soundspot = mopidy_soundspot:Extension',
|
'soundspot = mopidy_soundspot:Extension',
|
||||||
@ -312,12 +307,6 @@ This is ``mopidy_soundspot/__init__.py``::
|
|||||||
from .backend import SoundspotBackend
|
from .backend import SoundspotBackend
|
||||||
registry.add('backend', 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
|
# Or nothing to register e.g. command extension
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -421,17 +410,6 @@ examples, see the :ref:`http-server-api` docs or explore with
|
|||||||
:ref:`http-explore-extension` extension.
|
: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
|
Running an extension
|
||||||
====================
|
====================
|
||||||
|
|
||||||
|
|||||||
@ -94,6 +94,7 @@ Extensions
|
|||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
|
|
||||||
ext/local
|
ext/local
|
||||||
|
ext/m3u
|
||||||
ext/stream
|
ext/stream
|
||||||
ext/http
|
ext/http
|
||||||
ext/mpd
|
ext/mpd
|
||||||
@ -132,10 +133,11 @@ Development
|
|||||||
===========
|
===========
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 1
|
:maxdepth: 2
|
||||||
|
|
||||||
contributing
|
contributing
|
||||||
devtools
|
devenv
|
||||||
|
releasing
|
||||||
codestyle
|
codestyle
|
||||||
extensiondev
|
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::
|
To upgrade Mopidy to future releases, just upgrade your system using::
|
||||||
|
|
||||||
yaourt -Syu
|
yaourt -Syua
|
||||||
|
|
||||||
#. 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`.
|
|
||||||
|
|
||||||
#. Finally, you need to set a couple of :doc:`config values </config>`, and
|
#. Finally, you need to set a couple of :doc:`config values </config>`, and
|
||||||
then you're ready to :doc:`run Mopidy </running>`.
|
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 update
|
||||||
sudo apt-get install mopidy
|
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
|
#. Before continuing, make sure you've read the :ref:`debian` section to learn
|
||||||
about the differences between running Mopidy as a system service and
|
about the differences between running Mopidy as a system service and
|
||||||
manually as your own system user.
|
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 update
|
||||||
sudo apt-get dist-upgrade
|
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
|
There are several ways to install Mopidy. What way is best depends upon your OS
|
||||||
and/or distribution.
|
and/or distribution.
|
||||||
|
|
||||||
If you want to contribute to the development of Mopidy, you should first read
|
If you want to contribute to the development of Mopidy, you should first follow
|
||||||
the general installation instructions, then have a look at :ref:`run-from-git`.
|
the instructions here to install a regular install of Mopidy, then continue
|
||||||
|
with reading :ref:`contributing` and :ref:`devenv`.
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
|
|
||||||
|
|||||||
@ -57,16 +57,77 @@ If you are running OS X, you can install everything needed with Homebrew.
|
|||||||
|
|
||||||
brew install mopidy
|
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
|
#. Finally, you need to set a couple of :doc:`config values </config>`, and
|
||||||
then you're ready to :doc:`run Mopidy </running>`.
|
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
|
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
|
<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
|
#. First of all, you need Python 2.7. Check if you have Python and what
|
||||||
version by running::
|
version by running::
|
||||||
@ -69,46 +72,32 @@ from source by hand.
|
|||||||
|
|
||||||
sudo pip install -U mopidy
|
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
|
Alternatively, if you want to track Mopidy development closer, you may
|
||||||
install a snapshot of Mopidy's ``develop`` Git branch using pip::
|
install a snapshot of Mopidy's ``develop`` Git branch using pip::
|
||||||
|
|
||||||
sudo pip install --allow-unverified=mopidy mopidy==dev
|
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
|
#. Finally, you need to set a couple of :doc:`config values </config>`, and
|
||||||
then you're ready to :doc:`run Mopidy </running>`.
|
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.
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
|
|
||||||
Creating releases
|
Creating releases
|
||||||
@ -4,9 +4,9 @@
|
|||||||
Troubleshooting
|
Troubleshooting
|
||||||
***************
|
***************
|
||||||
|
|
||||||
If you run into problems with Mopidy, we usually hang around at ``#mopidy`` at
|
If you run into problems with Mopidy, we usually hang around at ``#mopidy`` on
|
||||||
`irc.freenode.net <http://freenode.net/>`_ and also have a `mailing list at
|
`irc.freenode.net <http://freenode.net/>`_ and also have a `discussion forum
|
||||||
Google Groups <https://groups.google.com/forum/?fromgroups=#!forum/mopidy>`_.
|
<https://discuss.mopidy.com/c/mopidy>`_.
|
||||||
If you stumble into a bug or have a feature request, please create an issue in
|
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>`_.
|
the `issue tracker <https://github.com/mopidy/mopidy/issues>`_.
|
||||||
|
|
||||||
|
|||||||
@ -1,23 +1,39 @@
|
|||||||
|
.. _versioning:
|
||||||
|
|
||||||
**********
|
**********
|
||||||
Versioning
|
Versioning
|
||||||
**********
|
**********
|
||||||
|
|
||||||
Mopidy uses `Semantic Versioning <http://semver.org/>`_, but since we're still
|
Mopidy follows `Semantic Versioning <http://semver.org/>`_. In summary this
|
||||||
pre-1.0 that doesn't mean much yet.
|
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
|
Release schedule
|
||||||
================
|
================
|
||||||
|
|
||||||
We intend to have about one feature release every month in periods of active
|
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
|
development. The features added is a mix of what we feel is most
|
||||||
mix of what we feel is most important/requested of the missing features, and
|
important/requested of the missing features, and features we develop just
|
||||||
features we develop just because we find them fun to make, even though they may
|
because we find them fun to make, even though they may be useful for very few
|
||||||
be useful for very few users or for a limited use case.
|
users or for a limited use case.
|
||||||
|
|
||||||
Bugfix releases, numbered 0.x.y, will be released whenever we discover bugs
|
Bugfix releases will be released whenever we discover bugs that are too serious
|
||||||
that are too serious to wait for the next feature release. We will only release
|
to wait for the next feature release. We will only release bugfix releases for
|
||||||
bugfix releases for the last feature release. E.g. when 0.14.0 is released, we
|
the last feature release. E.g. when 1.2.0 is released, we will no longer
|
||||||
will no longer provide bugfix releases for the 0.13 series. In other words,
|
provide bugfix releases for the 1.1.x series. In other words, there will be just
|
||||||
there will be just a single supported release at any point in time. This is to
|
a single supported release at any point in time. This is to not spread our
|
||||||
not spread our limited resources too thin.
|
limited resources too thin.
|
||||||
|
|||||||
@ -30,4 +30,4 @@ except ImportError:
|
|||||||
warnings.filterwarnings('ignore', 'could not open display')
|
warnings.filterwarnings('ignore', 'could not open display')
|
||||||
|
|
||||||
|
|
||||||
__version__ = '0.19.5'
|
__version__ = '1.0.0'
|
||||||
|
|||||||
@ -2,7 +2,6 @@ from __future__ import absolute_import, unicode_literals
|
|||||||
|
|
||||||
# flake8: noqa
|
# flake8: noqa
|
||||||
from .actor import Audio
|
from .actor import Audio
|
||||||
from .dummy import DummyAudio
|
|
||||||
from .listener import AudioListener
|
from .listener import AudioListener
|
||||||
from .constants import PlaybackState
|
from .constants import PlaybackState
|
||||||
from .utils import (
|
from .utils import (
|
||||||
|
|||||||
@ -16,7 +16,7 @@ from mopidy import exceptions
|
|||||||
from mopidy.audio import playlists, utils
|
from mopidy.audio import playlists, utils
|
||||||
from mopidy.audio.constants import PlaybackState
|
from mopidy.audio.constants import PlaybackState
|
||||||
from mopidy.audio.listener import AudioListener
|
from mopidy.audio.listener import AudioListener
|
||||||
from mopidy.utils import process
|
from mopidy.utils import deprecation, process
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -34,26 +34,11 @@ _GST_STATE_MAPPING = {
|
|||||||
gst.STATE_PAUSED: PlaybackState.PAUSED,
|
gst.STATE_PAUSED: PlaybackState.PAUSED,
|
||||||
gst.STATE_NULL: PlaybackState.STOPPED}
|
gst.STATE_NULL: PlaybackState.STOPPED}
|
||||||
|
|
||||||
MB = 1 << 20
|
|
||||||
|
|
||||||
# GST_PLAY_FLAG_VIDEO (1<<0)
|
|
||||||
# GST_PLAY_FLAG_AUDIO (1<<1)
|
|
||||||
# GST_PLAY_FLAG_TEXT (1<<2)
|
|
||||||
# GST_PLAY_FLAG_VIS (1<<3)
|
|
||||||
# GST_PLAY_FLAG_SOFT_VOLUME (1<<4)
|
|
||||||
# GST_PLAY_FLAG_NATIVE_AUDIO (1<<5)
|
|
||||||
# GST_PLAY_FLAG_NATIVE_VIDEO (1<<6)
|
|
||||||
# GST_PLAY_FLAG_DOWNLOAD (1<<7)
|
|
||||||
# GST_PLAY_FLAG_BUFFERING (1<<8)
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
|
|
||||||
class _Signals(object):
|
class _Signals(object):
|
||||||
|
|
||||||
"""Helper for tracking gobject signal registrations"""
|
"""Helper for tracking gobject signal registrations"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._ids = {}
|
self._ids = {}
|
||||||
|
|
||||||
@ -82,7 +67,9 @@ class _Signals(object):
|
|||||||
|
|
||||||
# TODO: expose this as a property on audio?
|
# TODO: expose this as a property on audio?
|
||||||
class _Appsrc(object):
|
class _Appsrc(object):
|
||||||
|
|
||||||
"""Helper class for dealing with appsrc based playback."""
|
"""Helper class for dealing with appsrc based playback."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._signals = _Signals()
|
self._signals = _Signals()
|
||||||
self.reset()
|
self.reset()
|
||||||
@ -112,7 +99,7 @@ class _Appsrc(object):
|
|||||||
source.set_property('caps', self._caps)
|
source.set_property('caps', self._caps)
|
||||||
source.set_property('format', b'time')
|
source.set_property('format', b'time')
|
||||||
source.set_property('stream-type', b'seekable')
|
source.set_property('stream-type', b'seekable')
|
||||||
source.set_property('max-bytes', 1 * MB)
|
source.set_property('max-bytes', 1 << 20) # 1MB
|
||||||
source.set_property('min-percent', 50)
|
source.set_property('min-percent', 50)
|
||||||
|
|
||||||
if self._need_data_callback:
|
if self._need_data_callback:
|
||||||
@ -128,6 +115,9 @@ class _Appsrc(object):
|
|||||||
self._source = source
|
self._source = source
|
||||||
|
|
||||||
def push(self, buffer_):
|
def push(self, buffer_):
|
||||||
|
if self._source is None:
|
||||||
|
return False
|
||||||
|
|
||||||
if buffer_ is None:
|
if buffer_ is None:
|
||||||
gst_logger.debug('Sending appsrc end-of-stream event.')
|
gst_logger.debug('Sending appsrc end-of-stream event.')
|
||||||
return self._source.emit('end-of-stream') == gst.FLOW_OK
|
return self._source.emit('end-of-stream') == gst.FLOW_OK
|
||||||
@ -146,27 +136,14 @@ class _Appsrc(object):
|
|||||||
|
|
||||||
# TODO: expose this as a property on audio when #790 gets further along.
|
# TODO: expose this as a property on audio when #790 gets further along.
|
||||||
class _Outputs(gst.Bin):
|
class _Outputs(gst.Bin):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
gst.Bin.__init__(self)
|
gst.Bin.__init__(self, 'outputs')
|
||||||
|
|
||||||
self._tee = gst.element_factory_make('tee')
|
self._tee = gst.element_factory_make('tee')
|
||||||
self.add(self._tee)
|
self.add(self._tee)
|
||||||
|
|
||||||
# Queue element to buy us time between the about to finish event and
|
ghost_pad = gst.GhostPad('sink', self._tee.get_pad('sink'))
|
||||||
# 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)
|
self.add_pad(ghost_pad)
|
||||||
|
|
||||||
# Add an always connected fakesink which respects the clock so the tee
|
# Add an always connected fakesink which respects the clock so the tee
|
||||||
@ -190,7 +167,9 @@ class _Outputs(gst.Bin):
|
|||||||
|
|
||||||
def _add(self, element):
|
def _add(self, element):
|
||||||
# All tee branches need a queue in front of them.
|
# All tee branches need a queue in front of them.
|
||||||
|
# But keep the queue short so the volume change isn't to slow:
|
||||||
queue = gst.element_factory_make('queue')
|
queue = gst.element_factory_make('queue')
|
||||||
|
queue.set_property('max-size-buffers', 5)
|
||||||
self.add(element)
|
self.add(element)
|
||||||
self.add(queue)
|
self.add(queue)
|
||||||
queue.link(element)
|
queue.link(element)
|
||||||
@ -209,10 +188,6 @@ class SoftwareMixer(object):
|
|||||||
|
|
||||||
def setup(self, element, mixer_ref):
|
def setup(self, element, mixer_ref):
|
||||||
self._element = element
|
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)
|
self._mixer.setup(mixer_ref)
|
||||||
|
|
||||||
def teardown(self):
|
def teardown(self):
|
||||||
@ -224,41 +199,20 @@ class SoftwareMixer(object):
|
|||||||
|
|
||||||
def set_volume(self, volume):
|
def set_volume(self, volume):
|
||||||
self._element.set_property('volume', volume / 100.0)
|
self._element.set_property('volume', volume / 100.0)
|
||||||
|
self._mixer.trigger_volume_changed(volume)
|
||||||
|
|
||||||
def get_mute(self):
|
def get_mute(self):
|
||||||
return self._element.get_property('mute')
|
return self._element.get_property('mute')
|
||||||
|
|
||||||
def set_mute(self, mute):
|
def set_mute(self, mute):
|
||||||
return self._element.set_property('mute', bool(mute))
|
result = self._element.set_property('mute', bool(mute))
|
||||||
|
if result:
|
||||||
def _volume_changed(self, element, property_):
|
self._mixer.trigger_mute_changed(bool(mute))
|
||||||
old_volume, self._last_volume = self._last_volume, self.get_volume()
|
return result
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_proxy(element, config):
|
|
||||||
# TODO: reuse in scanner code
|
|
||||||
if 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'))
|
|
||||||
|
|
||||||
|
|
||||||
class _Handler(object):
|
class _Handler(object):
|
||||||
|
|
||||||
def __init__(self, audio):
|
def __init__(self, audio):
|
||||||
self._audio = audio
|
self._audio = audio
|
||||||
self._element = None
|
self._element = None
|
||||||
@ -290,7 +244,7 @@ class _Handler(object):
|
|||||||
if msg.type == gst.MESSAGE_STATE_CHANGED and msg.src == self._element:
|
if msg.type == gst.MESSAGE_STATE_CHANGED and msg.src == self._element:
|
||||||
self.on_playbin_state_changed(*msg.parse_state_changed())
|
self.on_playbin_state_changed(*msg.parse_state_changed())
|
||||||
elif msg.type == gst.MESSAGE_BUFFERING:
|
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:
|
elif msg.type == gst.MESSAGE_EOS:
|
||||||
self.on_end_of_stream()
|
self.on_end_of_stream()
|
||||||
elif msg.type == gst.MESSAGE_ERROR:
|
elif msg.type == gst.MESSAGE_ERROR:
|
||||||
@ -353,16 +307,23 @@ class _Handler(object):
|
|||||||
gst.DEBUG_BIN_TO_DOT_FILE(
|
gst.DEBUG_BIN_TO_DOT_FILE(
|
||||||
self._audio._playbin, gst.DEBUG_GRAPH_SHOW_ALL, 'mopidy')
|
self._audio._playbin, gst.DEBUG_GRAPH_SHOW_ALL, 'mopidy')
|
||||||
|
|
||||||
def on_buffering(self, percent):
|
def on_buffering(self, percent, structure=None):
|
||||||
gst_logger.debug('Got buffering message: percent=%d%%', percent)
|
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:
|
if percent < 10 and not self._audio._buffering:
|
||||||
self._audio._playbin.set_state(gst.STATE_PAUSED)
|
self._audio._playbin.set_state(gst.STATE_PAUSED)
|
||||||
self._audio._buffering = True
|
self._audio._buffering = True
|
||||||
|
level = logging.DEBUG
|
||||||
if percent == 100:
|
if percent == 100:
|
||||||
self._audio._buffering = False
|
self._audio._buffering = False
|
||||||
if self._audio._target_state == gst.STATE_PLAYING:
|
if self._audio._target_state == gst.STATE_PLAYING:
|
||||||
self._audio._playbin.set_state(gst.STATE_PLAYING)
|
self._audio._playbin.set_state(gst.STATE_PLAYING)
|
||||||
|
level = logging.DEBUG
|
||||||
|
|
||||||
|
gst_logger.log(level, 'Got buffering message: percent=%d%%', percent)
|
||||||
|
|
||||||
def on_end_of_stream(self):
|
def on_end_of_stream(self):
|
||||||
gst_logger.debug('Got end-of-stream message.')
|
gst_logger.debug('Got end-of-stream message.')
|
||||||
@ -420,6 +381,7 @@ class _Handler(object):
|
|||||||
|
|
||||||
# TODO: create a player class which replaces the actors internals
|
# TODO: create a player class which replaces the actors internals
|
||||||
class Audio(pykka.ThreadingActor):
|
class Audio(pykka.ThreadingActor):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Audio output through `GStreamer <http://gstreamer.freedesktop.org/>`_.
|
Audio output through `GStreamer <http://gstreamer.freedesktop.org/>`_.
|
||||||
"""
|
"""
|
||||||
@ -453,8 +415,8 @@ class Audio(pykka.ThreadingActor):
|
|||||||
try:
|
try:
|
||||||
self._setup_preferences()
|
self._setup_preferences()
|
||||||
self._setup_playbin()
|
self._setup_playbin()
|
||||||
self._setup_output()
|
self._setup_outputs()
|
||||||
self._setup_mixer()
|
self._setup_audio_sink()
|
||||||
except gobject.GError as ex:
|
except gobject.GError as ex:
|
||||||
logger.exception(ex)
|
logger.exception(ex)
|
||||||
process.exit_process()
|
process.exit_process()
|
||||||
@ -474,11 +436,11 @@ class Audio(pykka.ThreadingActor):
|
|||||||
|
|
||||||
def _setup_playbin(self):
|
def _setup_playbin(self):
|
||||||
playbin = gst.element_factory_make('playbin2')
|
playbin = gst.element_factory_make('playbin2')
|
||||||
playbin.set_property('flags', PLAYBIN_FLAGS)
|
playbin.set_property('flags', 2) # GST_PLAY_FLAG_AUDIO
|
||||||
|
|
||||||
# TODO: turn into config values...
|
# TODO: turn into config values...
|
||||||
playbin.set_property('buffer-size', 2*1024*1024)
|
playbin.set_property('buffer-size', 5 << 20) # 5MB
|
||||||
playbin.set_property('buffer-duration', 2*gst.SECOND)
|
playbin.set_property('buffer-duration', 5 * gst.SECOND)
|
||||||
|
|
||||||
self._signals.connect(playbin, 'source-setup', self._on_source_setup)
|
self._signals.connect(playbin, 'source-setup', self._on_source_setup)
|
||||||
self._signals.connect(playbin, 'about-to-finish',
|
self._signals.connect(playbin, 'about-to-finish',
|
||||||
@ -494,7 +456,7 @@ class Audio(pykka.ThreadingActor):
|
|||||||
self._signals.disconnect(self._playbin, 'source-setup')
|
self._signals.disconnect(self._playbin, 'source-setup')
|
||||||
self._playbin.set_state(gst.STATE_NULL)
|
self._playbin.set_state(gst.STATE_NULL)
|
||||||
|
|
||||||
def _setup_output(self):
|
def _setup_outputs(self):
|
||||||
# We don't want to use outputs for regular testing, so just install
|
# We don't want to use outputs for regular testing, so just install
|
||||||
# an unsynced fakesink when someone asks for a 'testoutput'.
|
# an unsynced fakesink when someone asks for a 'testoutput'.
|
||||||
if self._config['audio']['output'] == 'testoutput':
|
if self._config['audio']['output'] == 'testoutput':
|
||||||
@ -507,11 +469,36 @@ class Audio(pykka.ThreadingActor):
|
|||||||
process.exit_process() # TODO: move this up the chain
|
process.exit_process() # TODO: move this up the chain
|
||||||
|
|
||||||
self._handler.setup_event_handling(self._outputs.get_pad('sink'))
|
self._handler.setup_event_handling(self._outputs.get_pad('sink'))
|
||||||
self._playbin.set_property('audio-sink', self._outputs)
|
|
||||||
|
|
||||||
def _setup_mixer(self):
|
def _setup_audio_sink(self):
|
||||||
|
audio_sink = gst.Bin('audio-sink')
|
||||||
|
|
||||||
|
# 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?
|
||||||
|
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', 3 * gst.SECOND)
|
||||||
|
queue.set_property('min-threshold-time', 1 * gst.SECOND)
|
||||||
|
|
||||||
|
audio_sink.add(queue)
|
||||||
|
audio_sink.add(self._outputs)
|
||||||
|
|
||||||
if self.mixer:
|
if self.mixer:
|
||||||
self.mixer.setup(self._playbin, self.actor_ref.proxy().mixer)
|
volume = gst.element_factory_make('volume')
|
||||||
|
audio_sink.add(volume)
|
||||||
|
queue.link(volume)
|
||||||
|
volume.link(self._outputs)
|
||||||
|
self.mixer.setup(volume, self.actor_ref.proxy().mixer)
|
||||||
|
else:
|
||||||
|
queue.link(self._outputs)
|
||||||
|
|
||||||
|
ghost_pad = gst.GhostPad('sink', queue.get_pad('sink'))
|
||||||
|
audio_sink.add_pad(ghost_pad)
|
||||||
|
|
||||||
|
self._playbin.set_property('audio-sink', audio_sink)
|
||||||
|
|
||||||
def _teardown_mixer(self):
|
def _teardown_mixer(self):
|
||||||
if self.mixer:
|
if self.mixer:
|
||||||
@ -531,8 +518,7 @@ class Audio(pykka.ThreadingActor):
|
|||||||
else:
|
else:
|
||||||
self._appsrc.reset()
|
self._appsrc.reset()
|
||||||
|
|
||||||
if hasattr(source.props, 'proxy'):
|
utils.setup_proxy(source, self._config['proxy'])
|
||||||
setup_proxy(source, self._config['proxy'])
|
|
||||||
|
|
||||||
def set_uri(self, uri):
|
def set_uri(self, uri):
|
||||||
"""
|
"""
|
||||||
@ -543,9 +529,20 @@ class Audio(pykka.ThreadingActor):
|
|||||||
:param uri: the URI to play
|
:param uri: the URI to play
|
||||||
:type uri: string
|
: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._tags = {} # TODO: add test for this somehow
|
||||||
self._playbin.set_property('uri', uri)
|
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(
|
def set_appsrc(
|
||||||
self, caps, need_data=None, enough_data=None, seek_data=None):
|
self, caps, need_data=None, enough_data=None, seek_data=None):
|
||||||
"""
|
"""
|
||||||
@ -594,9 +591,10 @@ class Audio(pykka.ThreadingActor):
|
|||||||
We will get a GStreamer message when the stream playback reaches the
|
We will get a GStreamer message when the stream playback reaches the
|
||||||
token, and can then do any end-of-stream related tasks.
|
token, and can then do any end-of-stream related tasks.
|
||||||
|
|
||||||
.. deprecated:: 0.20
|
.. deprecated:: 1.0
|
||||||
Use :meth:`emit_data` with a :class:`None` buffer instead.
|
Use :meth:`emit_data` with a :class:`None` buffer instead.
|
||||||
"""
|
"""
|
||||||
|
deprecation.warn('audio.emit_end_of_stream')
|
||||||
self._appsrc.push(None)
|
self._appsrc.push(None)
|
||||||
|
|
||||||
def set_about_to_finish_callback(self, callback):
|
def set_about_to_finish_callback(self, callback):
|
||||||
|
|||||||
@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals
|
|||||||
|
|
||||||
|
|
||||||
class PlaybackState(object):
|
class PlaybackState(object):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Enum of playback states.
|
Enum of playback states.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from mopidy import listener
|
|||||||
|
|
||||||
|
|
||||||
class AudioListener(listener.Listener):
|
class AudioListener(listener.Listener):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Marker interface for recipients of events sent by the audio actor.
|
Marker interface for recipients of events sent by the audio actor.
|
||||||
|
|
||||||
|
|||||||
@ -78,7 +78,7 @@ def parse_pls(data):
|
|||||||
if section.lower() != 'playlist':
|
if section.lower() != 'playlist':
|
||||||
continue
|
continue
|
||||||
for i in range(cp.getint(section, 'numberofentries')):
|
for i in range(cp.getint(section, 'numberofentries')):
|
||||||
yield cp.get(section, 'file%d' % (i+1))
|
yield cp.get(section, 'file%d' % (i + 1))
|
||||||
|
|
||||||
|
|
||||||
def parse_xspf(data):
|
def parse_xspf(data):
|
||||||
@ -136,6 +136,7 @@ def register_typefinders():
|
|||||||
|
|
||||||
|
|
||||||
class BasePlaylistElement(gst.Bin):
|
class BasePlaylistElement(gst.Bin):
|
||||||
|
|
||||||
"""Base class for creating GStreamer elements for playlist support.
|
"""Base class for creating GStreamer elements for playlist support.
|
||||||
|
|
||||||
This element performs the following steps:
|
This element performs the following steps:
|
||||||
|
|||||||
@ -1,42 +1,38 @@
|
|||||||
from __future__ import absolute_import, division, unicode_literals
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
|
|
||||||
import time
|
import collections
|
||||||
|
|
||||||
import pygst
|
import pygst
|
||||||
pygst.require('0.10')
|
pygst.require('0.10')
|
||||||
import gst # noqa
|
import gst # noqa
|
||||||
|
import gst.pbutils
|
||||||
|
|
||||||
from mopidy import exceptions
|
from mopidy import exceptions
|
||||||
from mopidy.audio import utils
|
from mopidy.audio import utils
|
||||||
from mopidy.utils import encoding
|
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):
|
class Scanner(object):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Helper to get tags and other relevant info from URIs.
|
Helper to get tags and other relevant info from URIs.
|
||||||
|
|
||||||
:param timeout: timeout for scanning a URI in ms
|
:param timeout: timeout for scanning a URI in ms
|
||||||
|
:param proxy_config: dictionary containing proxy config strings.
|
||||||
:type event: int
|
:type event: int
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, timeout=1000):
|
def __init__(self, timeout=1000, proxy_config=None):
|
||||||
self._timeout_ms = timeout
|
self._timeout_ms = int(timeout)
|
||||||
|
self._proxy_config = proxy_config or {}
|
||||||
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 scan(self, uri):
|
def scan(self, uri):
|
||||||
"""
|
"""
|
||||||
@ -44,68 +40,125 @@ class Scanner(object):
|
|||||||
|
|
||||||
:param uri: URI of the resource to scan.
|
:param uri: URI of the resource to scan.
|
||||||
:type event: string
|
:type event: string
|
||||||
:return: (tags, duration) pair. tags is a dictionary of lists for all
|
:return: A named tuple containing
|
||||||
the tags we found and duration is the length of the URI in
|
``(uri, tags, duration, seekable, mime)``.
|
||||||
milliseconds, or :class:`None` if the URI has no duration.
|
``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 = None, None
|
tags, duration, seekable, mime = None, None, None, None
|
||||||
|
pipeline = _setup_pipeline(uri, self._proxy_config)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._setup(uri)
|
_start_pipeline(pipeline)
|
||||||
tags = self._collect()
|
tags, mime = _process(pipeline, self._timeout_ms)
|
||||||
duration = self._query_duration()
|
duration = _query_duration(pipeline)
|
||||||
|
seekable = _query_seekable(pipeline)
|
||||||
finally:
|
finally:
|
||||||
self._reset()
|
pipeline.set_state(gst.STATE_NULL)
|
||||||
|
del pipeline
|
||||||
|
|
||||||
return tags, duration
|
return _Result(uri, tags, duration, seekable, mime)
|
||||||
|
|
||||||
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):
|
# Turns out it's _much_ faster to just create a new pipeline for every as
|
||||||
"""Polls for messages to collect data."""
|
# decodebins and other elements don't seem to take well to being reused.
|
||||||
start = time.time()
|
def _setup_pipeline(uri, proxy_config=None):
|
||||||
timeout_s = self._timeout_ms / 1000.0
|
src = gst.element_make_from_uri(gst.URI_SRC, uri)
|
||||||
tags = {}
|
if not src:
|
||||||
|
raise exceptions.ScannerError('GStreamer can not open: %s' % uri)
|
||||||
|
|
||||||
while time.time() - start < timeout_s:
|
typefind = gst.element_factory_make('typefind')
|
||||||
if not self._bus.have_pending():
|
decodebin = gst.element_factory_make('decodebin2')
|
||||||
continue
|
sink = gst.element_factory_make('fakesink')
|
||||||
message = self._bus.pop()
|
|
||||||
|
|
||||||
if message.type == gst.MESSAGE_ERROR:
|
pipeline = gst.element_factory_make('pipeline')
|
||||||
raise exceptions.ScannerError(
|
for e in (src, typefind, decodebin, sink):
|
||||||
encoding.locale_decode(message.parse_error()[0]))
|
pipeline.add(e)
|
||||||
elif message.type == gst.MESSAGE_EOS:
|
gst.element_link_many(src, typefind, decodebin)
|
||||||
return tags
|
|
||||||
elif message.type == gst.MESSAGE_ASYNC_DONE:
|
|
||||||
if message.src == self._pipe:
|
|
||||||
return tags
|
|
||||||
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))
|
|
||||||
|
|
||||||
raise exceptions.ScannerError('Timeout after %dms' % self._timeout_ms)
|
if proxy_config:
|
||||||
|
utils.setup_proxy(src, proxy_config)
|
||||||
|
|
||||||
def _reset(self):
|
decodebin.set_property('caps', _RAW_AUDIO)
|
||||||
"""Ensures we cleanup child elements and flush the bus."""
|
decodebin.connect('pad-added', _pad_added, sink)
|
||||||
self._bus.set_flushing(True)
|
typefind.connect('have-type', _have_type, decodebin)
|
||||||
self._pipe.set_state(gst.STATE_NULL)
|
|
||||||
|
|
||||||
def _query_duration(self):
|
return pipeline
|
||||||
try:
|
|
||||||
duration = self._pipe.query_duration(gst.FORMAT_TIME, None)[0]
|
|
||||||
except gst.QueryError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if duration < 0:
|
|
||||||
return None
|
def _have_type(element, probability, caps, decodebin):
|
||||||
else:
|
decodebin.set_property('sink-caps', caps)
|
||||||
return duration // gst.MSECOND
|
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:
|
||||||
|
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 _query_seekable(pipeline):
|
||||||
|
query = gst.query_new_seeking(gst.FORMAT_TIME)
|
||||||
|
pipeline.query(query)
|
||||||
|
return query.parse_seeking()[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _process(pipeline, timeout_ms):
|
||||||
|
clock = pipeline.get_clock()
|
||||||
|
bus = pipeline.get_bus()
|
||||||
|
timeout = timeout_ms * gst.MSECOND
|
||||||
|
tags, mime, missing_description = {}, None, None
|
||||||
|
|
||||||
|
types = (gst.MESSAGE_ELEMENT | gst.MESSAGE_APPLICATION | gst.MESSAGE_ERROR
|
||||||
|
| gst.MESSAGE_EOS | gst.MESSAGE_ASYNC_DONE | gst.MESSAGE_TAG)
|
||||||
|
|
||||||
|
start = clock.get_time()
|
||||||
|
while timeout > 0:
|
||||||
|
message = bus.timed_pop_filtered(timeout, types)
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
timeout -= clock.get_time() - start
|
||||||
|
|
||||||
|
raise exceptions.ScannerError('Timeout after %dms' % timeout_ms)
|
||||||
|
|||||||
@ -82,7 +82,8 @@ def _artists(tags, artist_name, artist_id=None):
|
|||||||
def convert_tags_to_track(tags):
|
def convert_tags_to_track(tags):
|
||||||
"""Convert our normalized tags to a track.
|
"""Convert our normalized tags to a track.
|
||||||
|
|
||||||
:param :class:`dict` tags: dictionary of tag keys with a list of values
|
:param tags: dictionary of tag keys with a list of values
|
||||||
|
:type tags: :class:`dict`
|
||||||
:rtype: :class:`mopidy.models.Track`
|
:rtype: :class:`mopidy.models.Track`
|
||||||
"""
|
"""
|
||||||
album_kwargs = {}
|
album_kwargs = {}
|
||||||
@ -130,6 +131,26 @@ def convert_tags_to_track(tags):
|
|||||||
return Track(**track_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):
|
def convert_taglist(taglist):
|
||||||
"""Convert a :class:`gst.Taglist` to plain Python types.
|
"""Convert a :class:`gst.Taglist` to plain Python types.
|
||||||
|
|
||||||
@ -147,7 +168,8 @@ def convert_taglist(taglist):
|
|||||||
.. _GstTagList: http://gstreamer.freedesktop.org/data/doc/gstreamer/\
|
.. _GstTagList: http://gstreamer.freedesktop.org/data/doc/gstreamer/\
|
||||||
0.10.36/gstreamer/html/gstreamer-GstTagList.html
|
0.10.36/gstreamer/html/gstreamer-GstTagList.html
|
||||||
|
|
||||||
:param gst.Taglist taglist: A GStreamer taglist to be converted.
|
:param taglist: A GStreamer taglist to be converted.
|
||||||
|
:type taglist: :class:`gst.Taglist`
|
||||||
:rtype: dictionary of tag keys with a list of values.
|
:rtype: dictionary of tag keys with a list of values.
|
||||||
"""
|
"""
|
||||||
result = {}
|
result = {}
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
from __future__ import absolute_import, unicode_literals
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
import copy
|
from mopidy import listener, models
|
||||||
|
|
||||||
from mopidy import listener
|
|
||||||
|
|
||||||
|
|
||||||
class Backend(object):
|
class Backend(object):
|
||||||
|
|
||||||
"""Backend API
|
"""Backend API
|
||||||
|
|
||||||
If the backend has problems during initialization it should raise
|
If the backend has problems during initialization it should raise
|
||||||
@ -61,6 +60,7 @@ class Backend(object):
|
|||||||
|
|
||||||
|
|
||||||
class LibraryProvider(object):
|
class LibraryProvider(object):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
:param backend: backend the controller is a part of
|
:param backend: backend the controller is a part of
|
||||||
:type backend: :class:`mopidy.backend.Backend`
|
:type backend: :class:`mopidy.backend.Backend`
|
||||||
@ -92,14 +92,34 @@ class LibraryProvider(object):
|
|||||||
"""
|
"""
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# TODO: replace with search(query, exact=True, ...)
|
def get_distinct(self, field, query=None):
|
||||||
def find_exact(self, query=None, uris=None):
|
|
||||||
"""
|
"""
|
||||||
See :meth:`mopidy.core.LibraryController.find_exact`.
|
See :meth:`mopidy.core.LibraryController.get_distinct`.
|
||||||
|
|
||||||
*MAY be implemented by subclass.*
|
*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):
|
def lookup(self, uri):
|
||||||
"""
|
"""
|
||||||
@ -117,16 +137,20 @@ class LibraryProvider(object):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def search(self, query=None, uris=None):
|
def search(self, query=None, uris=None, exact=False):
|
||||||
"""
|
"""
|
||||||
See :meth:`mopidy.core.LibraryController.search`.
|
See :meth:`mopidy.core.LibraryController.search`.
|
||||||
|
|
||||||
*MAY be implemented by subclass.*
|
*MAY be implemented by subclass.*
|
||||||
|
|
||||||
|
.. versionadded:: 1.0
|
||||||
|
The ``exact`` param which replaces the old ``find_exact``.
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PlaybackProvider(object):
|
class PlaybackProvider(object):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
:param audio: the audio actor
|
:param audio: the audio actor
|
||||||
:type audio: actor proxy to an instance of :class:`mopidy.audio.Audio`
|
:type audio: actor proxy to an instance of :class:`mopidy.audio.Audio`
|
||||||
@ -172,23 +196,44 @@ class PlaybackProvider(object):
|
|||||||
"""
|
"""
|
||||||
self.audio.prepare_change().get()
|
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):
|
def change_track(self, track):
|
||||||
"""
|
"""
|
||||||
Swith to provided track.
|
Swith to provided track.
|
||||||
|
|
||||||
*MAY be reimplemented by subclass.*
|
*MAY be reimplemented by subclass.*
|
||||||
|
|
||||||
This is very likely the *only* thing you need to override as a backend
|
It is unlikely it makes sense for any backends to override
|
||||||
author. Typically this is where you convert any mopidy specific URIs
|
this. For most practical purposes it should be considered an internal
|
||||||
to real URIs and then return::
|
call between backends and core that backend authors should not touch.
|
||||||
|
|
||||||
return super(MyBackend, self).change_track(track.copy(uri=new_uri))
|
The default implementation will call :meth:`translate_uri` which
|
||||||
|
is what you want to implement.
|
||||||
|
|
||||||
:param track: the track to play
|
:param track: the track to play
|
||||||
:type track: :class:`mopidy.models.Track`
|
:type track: :class:`mopidy.models.Track`
|
||||||
:rtype: :class:`True` if successful, else :class:`False`
|
: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
|
return True
|
||||||
|
|
||||||
def resume(self):
|
def resume(self):
|
||||||
@ -219,7 +264,7 @@ class PlaybackProvider(object):
|
|||||||
|
|
||||||
*MAY be reimplemented by subclass.*
|
*MAY be reimplemented by subclass.*
|
||||||
|
|
||||||
Should not be used for tracking if tracks have been played / when we
|
Should not be used for tracking if tracks have been played or when we
|
||||||
are done playing them.
|
are done playing them.
|
||||||
|
|
||||||
:rtype: :class:`True` if successful, else :class:`False`
|
:rtype: :class:`True` if successful, else :class:`False`
|
||||||
@ -238,6 +283,7 @@ class PlaybackProvider(object):
|
|||||||
|
|
||||||
|
|
||||||
class PlaylistsProvider(object):
|
class PlaylistsProvider(object):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
A playlist provider exposes a collection of playlists, methods to
|
A playlist provider exposes a collection of playlists, methods to
|
||||||
create/change/delete playlists in this collection, and lookup of any
|
create/change/delete playlists in this collection, and lookup of any
|
||||||
@ -251,25 +297,36 @@ class PlaylistsProvider(object):
|
|||||||
|
|
||||||
def __init__(self, backend):
|
def __init__(self, backend):
|
||||||
self.backend = backend
|
self.backend = backend
|
||||||
self._playlists = []
|
|
||||||
|
|
||||||
# TODO Replace playlists property with a get_playlists() method which
|
def as_list(self):
|
||||||
# returns playlist Ref's instead of the gigantic data structures we
|
|
||||||
# currently make available. lookup() should be used for getting full
|
|
||||||
# playlists with all details.
|
|
||||||
|
|
||||||
@property
|
|
||||||
def playlists(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 get_items(self, uri):
|
||||||
def playlists(self, playlists):
|
"""
|
||||||
self._playlists = playlists
|
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):
|
def create(self, name):
|
||||||
"""
|
"""
|
||||||
@ -338,6 +395,7 @@ class PlaylistsProvider(object):
|
|||||||
|
|
||||||
|
|
||||||
class BackendListener(listener.Listener):
|
class BackendListener(listener.Listener):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Marker interface for recipients of events sent by the backend actors.
|
Marker interface for recipients of events sent by the backend actors.
|
||||||
|
|
||||||
@ -2,11 +2,9 @@ from __future__ import absolute_import, print_function, unicode_literals
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import collections
|
import collections
|
||||||
import contextlib
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
|
|
||||||
import glib
|
import glib
|
||||||
|
|
||||||
@ -15,7 +13,7 @@ import gobject
|
|||||||
from mopidy import config as config_lib, exceptions
|
from mopidy import config as config_lib, exceptions
|
||||||
from mopidy.audio import Audio
|
from mopidy.audio import Audio
|
||||||
from mopidy.core import Core
|
from mopidy.core import Core
|
||||||
from mopidy.utils import deps, process, versioning
|
from mopidy.utils import deps, process, timer, versioning
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -40,7 +38,9 @@ def config_override_type(value):
|
|||||||
|
|
||||||
|
|
||||||
class _ParserError(Exception):
|
class _ParserError(Exception):
|
||||||
pass
|
|
||||||
|
def __init__(self, message):
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
class _HelpError(Exception):
|
class _HelpError(Exception):
|
||||||
@ -48,11 +48,13 @@ class _HelpError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class _ArgumentParser(argparse.ArgumentParser):
|
class _ArgumentParser(argparse.ArgumentParser):
|
||||||
|
|
||||||
def error(self, message):
|
def error(self, message):
|
||||||
raise _ParserError(message)
|
raise _ParserError(message)
|
||||||
|
|
||||||
|
|
||||||
class _HelpAction(argparse.Action):
|
class _HelpAction(argparse.Action):
|
||||||
|
|
||||||
def __init__(self, option_strings, dest=None, help=None):
|
def __init__(self, option_strings, dest=None, help=None):
|
||||||
super(_HelpAction, self).__init__(
|
super(_HelpAction, self).__init__(
|
||||||
option_strings=option_strings,
|
option_strings=option_strings,
|
||||||
@ -65,14 +67,8 @@ class _HelpAction(argparse.Action):
|
|||||||
raise _HelpError()
|
raise _HelpError()
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
|
||||||
def _startup_timer(name):
|
|
||||||
start = time.time()
|
|
||||||
yield
|
|
||||||
logger.debug('%s startup took %dms', name, (time.time() - start) * 1000)
|
|
||||||
|
|
||||||
|
|
||||||
class Command(object):
|
class Command(object):
|
||||||
|
|
||||||
"""Command parser and runner for building trees of commands.
|
"""Command parser and runner for building trees of commands.
|
||||||
|
|
||||||
This class provides a wraper around :class:`argparse.ArgumentParser`
|
This class provides a wraper around :class:`argparse.ArgumentParser`
|
||||||
@ -236,6 +232,7 @@ class Command(object):
|
|||||||
|
|
||||||
# TODO: move out of this file
|
# TODO: move out of this file
|
||||||
class RootCommand(Command):
|
class RootCommand(Command):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super(RootCommand, self).__init__()
|
super(RootCommand, self).__init__()
|
||||||
self.set(base_verbosity_level=0)
|
self.set(base_verbosity_level=0)
|
||||||
@ -277,10 +274,12 @@ class RootCommand(Command):
|
|||||||
|
|
||||||
exit_status_code = 0
|
exit_status_code = 0
|
||||||
try:
|
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)
|
audio = self.start_audio(config, mixer)
|
||||||
backends = self.start_backends(config, backend_classes, audio)
|
backends = self.start_backends(config, backend_classes, audio)
|
||||||
core = self.start_core(audio, mixer, backends)
|
core = self.start_core(mixer, backends, audio)
|
||||||
self.start_frontends(config, frontend_classes, core)
|
self.start_frontends(config, frontend_classes, core)
|
||||||
loop.run()
|
loop.run()
|
||||||
except (exceptions.BackendError,
|
except (exceptions.BackendError,
|
||||||
@ -298,7 +297,8 @@ class RootCommand(Command):
|
|||||||
self.stop_core()
|
self.stop_core()
|
||||||
self.stop_backends(backend_classes)
|
self.stop_backends(backend_classes)
|
||||||
self.stop_audio()
|
self.stop_audio()
|
||||||
self.stop_mixer(mixer_class)
|
if mixer_class is not None:
|
||||||
|
self.stop_mixer(mixer_class)
|
||||||
process.stop_remaining_actors()
|
process.stop_remaining_actors()
|
||||||
return exit_status_code
|
return exit_status_code
|
||||||
|
|
||||||
@ -307,13 +307,18 @@ class RootCommand(Command):
|
|||||||
'Available Mopidy mixers: %s',
|
'Available Mopidy mixers: %s',
|
||||||
', '.join(m.__name__ for m in mixer_classes) or 'none')
|
', '.join(m.__name__ for m in mixer_classes) or 'none')
|
||||||
|
|
||||||
|
if config['audio']['mixer'] == 'none':
|
||||||
|
logger.debug('Mixer disabled')
|
||||||
|
return None
|
||||||
|
|
||||||
selected_mixers = [
|
selected_mixers = [
|
||||||
m for m in mixer_classes if m.name == config['audio']['mixer']]
|
m for m in mixer_classes if m.name == config['audio']['mixer']]
|
||||||
if len(selected_mixers) != 1:
|
if len(selected_mixers) != 1:
|
||||||
logger.error(
|
logger.error(
|
||||||
'Did not find unique mixer "%s". Alternatives are: %s',
|
'Did not find unique mixer "%s". Alternatives are: %s',
|
||||||
config['audio']['mixer'],
|
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()
|
process.exit_process()
|
||||||
return selected_mixers[0]
|
return selected_mixers[0]
|
||||||
|
|
||||||
@ -349,7 +354,7 @@ class RootCommand(Command):
|
|||||||
backends = []
|
backends = []
|
||||||
for backend_class in backend_classes:
|
for backend_class in backend_classes:
|
||||||
try:
|
try:
|
||||||
with _startup_timer(backend_class.__name__):
|
with timer.time_logger(backend_class.__name__):
|
||||||
backend = backend_class.start(
|
backend = backend_class.start(
|
||||||
config=config, audio=audio).proxy()
|
config=config, audio=audio).proxy()
|
||||||
backends.append(backend)
|
backends.append(backend)
|
||||||
@ -361,9 +366,9 @@ class RootCommand(Command):
|
|||||||
|
|
||||||
return backends
|
return backends
|
||||||
|
|
||||||
def start_core(self, audio, mixer, backends):
|
def start_core(self, mixer, backends, audio):
|
||||||
logger.info('Starting Mopidy core')
|
logger.info('Starting Mopidy core')
|
||||||
return Core.start(audio=audio, mixer=mixer, backends=backends).proxy()
|
return Core.start(mixer=mixer, backends=backends, audio=audio).proxy()
|
||||||
|
|
||||||
def start_frontends(self, config, frontend_classes, core):
|
def start_frontends(self, config, frontend_classes, core):
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -372,7 +377,7 @@ class RootCommand(Command):
|
|||||||
|
|
||||||
for frontend_class in frontend_classes:
|
for frontend_class in frontend_classes:
|
||||||
try:
|
try:
|
||||||
with _startup_timer(frontend_class.__name__):
|
with timer.time_logger(frontend_class.__name__):
|
||||||
frontend_class.start(config=config, core=core)
|
frontend_class.start(config=config, core=core)
|
||||||
except exceptions.FrontendError as exc:
|
except exceptions.FrontendError as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
@ -22,7 +22,8 @@ _logging_schema['debug_format'] = String()
|
|||||||
_logging_schema['debug_file'] = Path()
|
_logging_schema['debug_file'] = Path()
|
||||||
_logging_schema['config_file'] = Path(optional=True)
|
_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 = ConfigSchema('audio')
|
||||||
_audio_schema['mixer'] = String()
|
_audio_schema['mixer'] = String()
|
||||||
@ -42,7 +43,8 @@ _proxy_schema['password'] = Secret(optional=True)
|
|||||||
# NOTE: if multiple outputs ever comes something like LogLevelConfigSchema
|
# NOTE: if multiple outputs ever comes something like LogLevelConfigSchema
|
||||||
# _outputs_schema = config.AudioOutputConfigSchema()
|
# _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 = """
|
_INITIAL_HELP = """
|
||||||
# For further information about options in this file see:
|
# For further information about options in this file see:
|
||||||
@ -148,6 +150,11 @@ def _load_file(parser, filename):
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
'Loading config from %s failed; it does not exist', filename)
|
'Loading config from %s failed; it does not exist', filename)
|
||||||
return
|
return
|
||||||
|
if not os.access(filename, os.R_OK):
|
||||||
|
logger.warning(
|
||||||
|
'Loading config from %s failed; read permission missing',
|
||||||
|
filename)
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info('Loading config from %s', filename)
|
logger.info('Loading config from %s', filename)
|
||||||
@ -170,13 +177,19 @@ def _validate(raw_config, schemas):
|
|||||||
# Get validated config
|
# Get validated config
|
||||||
config = {}
|
config = {}
|
||||||
errors = {}
|
errors = {}
|
||||||
|
sections = set(raw_config)
|
||||||
for schema in schemas:
|
for schema in schemas:
|
||||||
|
sections.discard(schema.name)
|
||||||
values = raw_config.get(schema.name, {})
|
values = raw_config.get(schema.name, {})
|
||||||
result, error = schema.deserialize(values)
|
result, error = schema.deserialize(values)
|
||||||
if error:
|
if error:
|
||||||
errors[schema.name] = error
|
errors[schema.name] = error
|
||||||
if result:
|
if result:
|
||||||
config[schema.name] = result
|
config[schema.name] = result
|
||||||
|
|
||||||
|
for section in sections:
|
||||||
|
logger.debug('Ignoring unknown config section: %s', section)
|
||||||
|
|
||||||
return config, errors
|
return config, errors
|
||||||
|
|
||||||
|
|
||||||
@ -251,6 +264,7 @@ def _postprocess(config_string):
|
|||||||
|
|
||||||
|
|
||||||
class Proxy(collections.Mapping):
|
class Proxy(collections.Mapping):
|
||||||
|
|
||||||
def __init__(self, data):
|
def __init__(self, data):
|
||||||
self._data = data
|
self._data = data
|
||||||
|
|
||||||
|
|||||||
@ -38,6 +38,7 @@ def _levenshtein(a, b):
|
|||||||
|
|
||||||
|
|
||||||
class ConfigSchema(collections.OrderedDict):
|
class ConfigSchema(collections.OrderedDict):
|
||||||
|
|
||||||
"""Logical group of config values that correspond to a config section.
|
"""Logical group of config values that correspond to a config section.
|
||||||
|
|
||||||
Schemas are set up by assigning config keys with config values to
|
Schemas are set up by assigning config keys with config values to
|
||||||
@ -47,6 +48,7 @@ class ConfigSchema(collections.OrderedDict):
|
|||||||
:meth:`serialize` for converting the values to a form suitable for
|
:meth:`serialize` for converting the values to a form suitable for
|
||||||
persistence.
|
persistence.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, name):
|
def __init__(self, name):
|
||||||
super(ConfigSchema, self).__init__()
|
super(ConfigSchema, self).__init__()
|
||||||
self.name = name
|
self.name = name
|
||||||
@ -94,17 +96,17 @@ class ConfigSchema(collections.OrderedDict):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
class LogLevelConfigSchema(object):
|
class MapConfigSchema(object):
|
||||||
"""Special cased schema for handling a config section with loglevels.
|
|
||||||
|
|
||||||
Expects the config keys to be logger names and the values to be log levels
|
"""Schema for handling multiple unknown keys with the same type.
|
||||||
as understood by the :class:`LogLevel` config value. Does not sub-class
|
|
||||||
:class:`ConfigSchema`, but implements the same serialize/deserialize
|
Does not sub-class :class:`ConfigSchema`, but implements the same
|
||||||
interface.
|
serialize/deserialize interface.
|
||||||
"""
|
"""
|
||||||
def __init__(self, name):
|
|
||||||
|
def __init__(self, name, value_type):
|
||||||
self.name = name
|
self.name = name
|
||||||
self._config_value = types.LogLevel()
|
self._value_type = value_type
|
||||||
|
|
||||||
def deserialize(self, values):
|
def deserialize(self, values):
|
||||||
errors = {}
|
errors = {}
|
||||||
@ -112,7 +114,7 @@ class LogLevelConfigSchema(object):
|
|||||||
|
|
||||||
for key, value in values.items():
|
for key, value in values.items():
|
||||||
try:
|
try:
|
||||||
result[key] = self._config_value.deserialize(value)
|
result[key] = self._value_type.deserialize(value)
|
||||||
except ValueError as e: # deserialization failed
|
except ValueError as e: # deserialization failed
|
||||||
result[key] = None
|
result[key] = None
|
||||||
errors[key] = str(e)
|
errors[key] = str(e)
|
||||||
@ -121,5 +123,5 @@ class LogLevelConfigSchema(object):
|
|||||||
def serialize(self, values, display=False):
|
def serialize(self, values, display=False):
|
||||||
result = collections.OrderedDict()
|
result = collections.OrderedDict()
|
||||||
for key in sorted(values.keys()):
|
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
|
return result
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import socket
|
|||||||
|
|
||||||
from mopidy import compat
|
from mopidy import compat
|
||||||
from mopidy.config import validators
|
from mopidy.config import validators
|
||||||
from mopidy.utils import path
|
from mopidy.utils import log, path
|
||||||
|
|
||||||
|
|
||||||
def decode(value):
|
def decode(value):
|
||||||
@ -25,6 +25,7 @@ def encode(value):
|
|||||||
|
|
||||||
|
|
||||||
class ExpandedPath(bytes):
|
class ExpandedPath(bytes):
|
||||||
|
|
||||||
def __new__(cls, original, expanded):
|
def __new__(cls, original, expanded):
|
||||||
return super(ExpandedPath, cls).__new__(cls, expanded)
|
return super(ExpandedPath, cls).__new__(cls, expanded)
|
||||||
|
|
||||||
@ -37,6 +38,7 @@ class DeprecatedValue(object):
|
|||||||
|
|
||||||
|
|
||||||
class ConfigValue(object):
|
class ConfigValue(object):
|
||||||
|
|
||||||
"""Represents a config key's value and how to handle it.
|
"""Represents a config key's value and how to handle it.
|
||||||
|
|
||||||
Normally you will only be interacting with sub-classes for config values
|
Normally you will only be interacting with sub-classes for config values
|
||||||
@ -65,6 +67,7 @@ class ConfigValue(object):
|
|||||||
|
|
||||||
|
|
||||||
class Deprecated(ConfigValue):
|
class Deprecated(ConfigValue):
|
||||||
|
|
||||||
"""Deprecated value
|
"""Deprecated value
|
||||||
|
|
||||||
Used for ignoring old config values that are no longer in use, but should
|
Used for ignoring old config values that are no longer in use, but should
|
||||||
@ -79,10 +82,12 @@ class Deprecated(ConfigValue):
|
|||||||
|
|
||||||
|
|
||||||
class String(ConfigValue):
|
class String(ConfigValue):
|
||||||
|
|
||||||
"""String value.
|
"""String value.
|
||||||
|
|
||||||
Is decoded as utf-8 and \\n \\t escapes should work and be preserved.
|
Is decoded as utf-8 and \\n \\t escapes should work and be preserved.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, optional=False, choices=None):
|
def __init__(self, optional=False, choices=None):
|
||||||
self._required = not optional
|
self._required = not optional
|
||||||
self._choices = choices
|
self._choices = choices
|
||||||
@ -102,6 +107,7 @@ class String(ConfigValue):
|
|||||||
|
|
||||||
|
|
||||||
class Secret(String):
|
class Secret(String):
|
||||||
|
|
||||||
"""Secret string value.
|
"""Secret string value.
|
||||||
|
|
||||||
Is decoded as utf-8 and \\n \\t escapes should work and be preserved.
|
Is decoded as utf-8 and \\n \\t escapes should work and be preserved.
|
||||||
@ -109,6 +115,7 @@ class Secret(String):
|
|||||||
Should be used for passwords, auth tokens etc. Will mask value when being
|
Should be used for passwords, auth tokens etc. Will mask value when being
|
||||||
displayed.
|
displayed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, optional=False, choices=None):
|
def __init__(self, optional=False, choices=None):
|
||||||
self._required = not optional
|
self._required = not optional
|
||||||
self._choices = None # Choices doesn't make sense for secrets
|
self._choices = None # Choices doesn't make sense for secrets
|
||||||
@ -120,6 +127,7 @@ class Secret(String):
|
|||||||
|
|
||||||
|
|
||||||
class Integer(ConfigValue):
|
class Integer(ConfigValue):
|
||||||
|
|
||||||
"""Integer value."""
|
"""Integer value."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -141,6 +149,7 @@ class Integer(ConfigValue):
|
|||||||
|
|
||||||
|
|
||||||
class Boolean(ConfigValue):
|
class Boolean(ConfigValue):
|
||||||
|
|
||||||
"""Boolean value.
|
"""Boolean value.
|
||||||
|
|
||||||
Accepts ``1``, ``yes``, ``true``, and ``on`` with any casing as
|
Accepts ``1``, ``yes``, ``true``, and ``on`` with any casing as
|
||||||
@ -173,11 +182,13 @@ class Boolean(ConfigValue):
|
|||||||
|
|
||||||
|
|
||||||
class List(ConfigValue):
|
class List(ConfigValue):
|
||||||
|
|
||||||
"""List value.
|
"""List value.
|
||||||
|
|
||||||
Supports elements split by commas or newlines. Newlines take presedence and
|
Supports elements split by commas or newlines. Newlines take presedence and
|
||||||
empty list items will be filtered out.
|
empty list items will be filtered out.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, optional=False):
|
def __init__(self, optional=False):
|
||||||
self._required = not optional
|
self._required = not optional
|
||||||
|
|
||||||
@ -197,11 +208,24 @@ class List(ConfigValue):
|
|||||||
return b'\n ' + b'\n '.join(encode(v) for v in value if v)
|
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):
|
class LogLevel(ConfigValue):
|
||||||
|
|
||||||
"""Log level value.
|
"""Log level value.
|
||||||
|
|
||||||
Expects one of ``critical``, ``error``, ``warning``, ``info``, ``debug``
|
Expects one of ``critical``, ``error``, ``warning``, ``info``, ``debug``,
|
||||||
with any casing.
|
or ``all``, with any casing.
|
||||||
"""
|
"""
|
||||||
levels = {
|
levels = {
|
||||||
b'critical': logging.CRITICAL,
|
b'critical': logging.CRITICAL,
|
||||||
@ -209,6 +233,7 @@ class LogLevel(ConfigValue):
|
|||||||
b'warning': logging.WARNING,
|
b'warning': logging.WARNING,
|
||||||
b'info': logging.INFO,
|
b'info': logging.INFO,
|
||||||
b'debug': logging.DEBUG,
|
b'debug': logging.DEBUG,
|
||||||
|
b'all': logging.NOTSET,
|
||||||
}
|
}
|
||||||
|
|
||||||
def deserialize(self, value):
|
def deserialize(self, value):
|
||||||
@ -223,6 +248,7 @@ class LogLevel(ConfigValue):
|
|||||||
|
|
||||||
|
|
||||||
class Hostname(ConfigValue):
|
class Hostname(ConfigValue):
|
||||||
|
|
||||||
"""Network hostname value."""
|
"""Network hostname value."""
|
||||||
|
|
||||||
def __init__(self, optional=False):
|
def __init__(self, optional=False):
|
||||||
@ -240,18 +266,21 @@ class Hostname(ConfigValue):
|
|||||||
|
|
||||||
|
|
||||||
class Port(Integer):
|
class Port(Integer):
|
||||||
|
|
||||||
"""Network port value.
|
"""Network port value.
|
||||||
|
|
||||||
Expects integer in the range 0-65535, zero tells the kernel to simply
|
Expects integer in the range 0-65535, zero tells the kernel to simply
|
||||||
allocate a port for us.
|
allocate a port for us.
|
||||||
"""
|
"""
|
||||||
# TODO: consider probing if port is free or not?
|
# TODO: consider probing if port is free or not?
|
||||||
|
|
||||||
def __init__(self, choices=None, optional=False):
|
def __init__(self, choices=None, optional=False):
|
||||||
super(Port, self).__init__(
|
super(Port, self).__init__(
|
||||||
minimum=0, maximum=2 ** 16 - 1, choices=choices, optional=optional)
|
minimum=0, maximum=2 ** 16 - 1, choices=choices, optional=optional)
|
||||||
|
|
||||||
|
|
||||||
class Path(ConfigValue):
|
class Path(ConfigValue):
|
||||||
|
|
||||||
"""File system path
|
"""File system path
|
||||||
|
|
||||||
The following expansions of the path will be done:
|
The following expansions of the path will be done:
|
||||||
@ -266,6 +295,7 @@ class Path(ConfigValue):
|
|||||||
|
|
||||||
- ``$XDG_MUSIC_DIR`` according to the XDG spec
|
- ``$XDG_MUSIC_DIR`` according to the XDG spec
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, optional=False):
|
def __init__(self, optional=False):
|
||||||
self._required = not optional
|
self._required = not optional
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from .actor import Core
|
|||||||
from .history import HistoryController
|
from .history import HistoryController
|
||||||
from .library import LibraryController
|
from .library import LibraryController
|
||||||
from .listener import CoreListener
|
from .listener import CoreListener
|
||||||
|
from .mixer import MixerController
|
||||||
from .playback import PlaybackController, PlaybackState
|
from .playback import PlaybackController, PlaybackState
|
||||||
from .playlists import PlaylistsController
|
from .playlists import PlaylistsController
|
||||||
from .tracklist import TracklistController
|
from .tracklist import TracklistController
|
||||||
|
|||||||
@ -10,10 +10,12 @@ from mopidy.audio import PlaybackState
|
|||||||
from mopidy.core.history import HistoryController
|
from mopidy.core.history import HistoryController
|
||||||
from mopidy.core.library import LibraryController
|
from mopidy.core.library import LibraryController
|
||||||
from mopidy.core.listener import CoreListener
|
from mopidy.core.listener import CoreListener
|
||||||
|
from mopidy.core.mixer import MixerController
|
||||||
from mopidy.core.playback import PlaybackController
|
from mopidy.core.playback import PlaybackController
|
||||||
from mopidy.core.playlists import PlaylistsController
|
from mopidy.core.playlists import PlaylistsController
|
||||||
from mopidy.core.tracklist import TracklistController
|
from mopidy.core.tracklist import TracklistController
|
||||||
from mopidy.utils import versioning
|
from mopidy.utils import versioning
|
||||||
|
from mopidy.utils.deprecation import deprecated_property
|
||||||
|
|
||||||
|
|
||||||
class Core(
|
class Core(
|
||||||
@ -28,6 +30,10 @@ class Core(
|
|||||||
"""The playback history controller. An instance of
|
"""The playback history controller. An instance of
|
||||||
:class:`mopidy.core.HistoryController`."""
|
:class:`mopidy.core.HistoryController`."""
|
||||||
|
|
||||||
|
mixer = None
|
||||||
|
"""The mixer controller. An instance of
|
||||||
|
:class:`mopidy.core.MixerController`."""
|
||||||
|
|
||||||
playback = None
|
playback = None
|
||||||
"""The playback controller. An instance of
|
"""The playback controller. An instance of
|
||||||
:class:`mopidy.core.PlaybackController`."""
|
:class:`mopidy.core.PlaybackController`."""
|
||||||
@ -40,43 +46,49 @@ class Core(
|
|||||||
"""The tracklist controller. An instance of
|
"""The tracklist controller. An instance of
|
||||||
:class:`mopidy.core.TracklistController`."""
|
:class:`mopidy.core.TracklistController`."""
|
||||||
|
|
||||||
def __init__(self, audio=None, mixer=None, backends=None):
|
def __init__(self, mixer=None, backends=None, audio=None):
|
||||||
super(Core, self).__init__()
|
super(Core, self).__init__()
|
||||||
|
|
||||||
self.backends = Backends(backends)
|
self.backends = Backends(backends)
|
||||||
|
|
||||||
self.library = LibraryController(backends=self.backends, core=self)
|
self.library = LibraryController(backends=self.backends, core=self)
|
||||||
|
|
||||||
self.history = HistoryController()
|
self.history = HistoryController()
|
||||||
|
self.mixer = MixerController(mixer=mixer)
|
||||||
self.playback = PlaybackController(
|
self.playback = PlaybackController(
|
||||||
audio=audio, mixer=mixer, backends=self.backends, core=self)
|
audio=audio, backends=self.backends, core=self)
|
||||||
|
self.playlists = PlaylistsController(backends=self.backends, core=self)
|
||||||
self.playlists = PlaylistsController(
|
|
||||||
backends=self.backends, core=self)
|
|
||||||
|
|
||||||
self.tracklist = TracklistController(core=self)
|
self.tracklist = TracklistController(core=self)
|
||||||
|
|
||||||
|
self.audio = audio
|
||||||
|
|
||||||
def get_uri_schemes(self):
|
def get_uri_schemes(self):
|
||||||
|
"""Get list of URI schemes we can handle"""
|
||||||
futures = [b.uri_schemes for b in self.backends]
|
futures = [b.uri_schemes for b in self.backends]
|
||||||
results = pykka.get_all(futures)
|
results = pykka.get_all(futures)
|
||||||
uri_schemes = itertools.chain(*results)
|
uri_schemes = itertools.chain(*results)
|
||||||
return sorted(uri_schemes)
|
return sorted(uri_schemes)
|
||||||
|
|
||||||
uri_schemes = property(get_uri_schemes)
|
uri_schemes = deprecated_property(get_uri_schemes)
|
||||||
"""List of URI schemes we can handle"""
|
"""
|
||||||
|
.. deprecated:: 1.0
|
||||||
|
Use :meth:`get_uri_schemes` instead.
|
||||||
|
"""
|
||||||
|
|
||||||
def get_version(self):
|
def get_version(self):
|
||||||
|
"""Get version of the Mopidy core API"""
|
||||||
return versioning.get_version()
|
return versioning.get_version()
|
||||||
|
|
||||||
version = property(get_version)
|
version = deprecated_property(get_version)
|
||||||
"""Version of the Mopidy core API"""
|
"""
|
||||||
|
.. deprecated:: 1.0
|
||||||
|
Use :meth:`get_version` instead.
|
||||||
|
"""
|
||||||
|
|
||||||
def reached_end_of_stream(self):
|
def reached_end_of_stream(self):
|
||||||
self.playback.on_end_of_stream()
|
self.playback._on_end_of_stream()
|
||||||
|
|
||||||
def stream_changed(self, uri):
|
def stream_changed(self, uri):
|
||||||
self.playback.on_stream_changed(uri)
|
self.playback._on_stream_changed(uri)
|
||||||
|
|
||||||
def state_changed(self, old_state, new_state, target_state):
|
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
|
# XXX: This is a temporary fix for issue #232 while we wait for a more
|
||||||
@ -88,8 +100,8 @@ class Core(
|
|||||||
|
|
||||||
# We ignore cases when target state is set as this is buffering
|
# We ignore cases when target state is set as this is buffering
|
||||||
# updates (at least for now) and we need to get #234 fixed...
|
# updates (at least for now) and we need to get #234 fixed...
|
||||||
if (new_state == PlaybackState.PAUSED and not target_state
|
if (new_state == PlaybackState.PAUSED and not target_state and
|
||||||
and self.playback.state != PlaybackState.PAUSED):
|
self.playback.state != PlaybackState.PAUSED):
|
||||||
self.playback.state = new_state
|
self.playback.state = new_state
|
||||||
self.playback._trigger_track_playback_paused()
|
self.playback._trigger_track_playback_paused()
|
||||||
|
|
||||||
@ -105,8 +117,25 @@ class Core(
|
|||||||
# Forward event from mixer to frontends
|
# Forward event from mixer to frontends
|
||||||
CoreListener.send('mute_changed', mute=mute)
|
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):
|
class Backends(list):
|
||||||
|
|
||||||
def __init__(self, backends):
|
def __init__(self, backends):
|
||||||
super(Backends, self).__init__(backends)
|
super(Backends, self).__init__(backends)
|
||||||
|
|
||||||
@ -116,7 +145,9 @@ class Backends(list):
|
|||||||
self.with_playlists = collections.OrderedDict()
|
self.with_playlists = collections.OrderedDict()
|
||||||
|
|
||||||
backends_by_scheme = {}
|
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:
|
for b in backends:
|
||||||
has_library = b.has_library().get()
|
has_library = b.has_library().get()
|
||||||
|
|||||||
@ -15,9 +15,11 @@ class HistoryController(object):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._history = []
|
self._history = []
|
||||||
|
|
||||||
def add(self, track):
|
def _add_track(self, track):
|
||||||
"""Add track to the playback history.
|
"""Add track to the playback history.
|
||||||
|
|
||||||
|
Internal method for :class:`mopidy.core.PlaybackController`.
|
||||||
|
|
||||||
:param track: track to add
|
:param track: track to add
|
||||||
:type track: :class:`mopidy.models.Track`
|
:type track: :class:`mopidy.models.Track`
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1,11 +1,17 @@
|
|||||||
from __future__ import absolute_import, unicode_literals
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
import collections
|
import collections
|
||||||
|
import logging
|
||||||
import operator
|
import operator
|
||||||
import urlparse
|
import urlparse
|
||||||
|
|
||||||
import pykka
|
import pykka
|
||||||
|
|
||||||
|
from mopidy.utils import deprecation
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class LibraryController(object):
|
class LibraryController(object):
|
||||||
pykka_traversable = True
|
pykka_traversable = True
|
||||||
@ -60,6 +66,8 @@ class LibraryController(object):
|
|||||||
|
|
||||||
:param string uri: URI to browse
|
:param string uri: URI to browse
|
||||||
:rtype: list of :class:`mopidy.models.Ref`
|
:rtype: list of :class:`mopidy.models.Ref`
|
||||||
|
|
||||||
|
.. versionadded:: 0.18
|
||||||
"""
|
"""
|
||||||
if uri is None:
|
if uri is None:
|
||||||
backends = self.backends.with_library_browse.values()
|
backends = self.backends.with_library_browse.values()
|
||||||
@ -72,52 +80,65 @@ class LibraryController(object):
|
|||||||
return []
|
return []
|
||||||
return backend.library.browse(uri).get()
|
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
|
This has mainly been added to support the list commands the MPD
|
||||||
tracks are returned.
|
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
|
:param string field: One of ``artist``, ``albumartist``, ``album``,
|
||||||
URI roots. For example passing ``uris=['file:']`` will limit the search
|
``composer``, ``performer``, ``date``or ``genre``.
|
||||||
to the local backend.
|
: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::
|
.. versionadded:: 1.0
|
||||||
|
"""
|
||||||
# Returns results matching 'a' from any backend
|
futures = [b.library.get_distinct(field, query)
|
||||||
find_exact({'any': ['a']})
|
for b in self.backends.with_library.values()]
|
||||||
find_exact(any=['a'])
|
result = set()
|
||||||
|
for r in pykka.get_all(futures):
|
||||||
# Returns results matching artist 'xyz' from any backend
|
result.update(r)
|
||||||
find_exact({'artist': ['xyz']})
|
return result
|
||||||
find_exact(artist=['xyz'])
|
|
||||||
|
def get_images(self, uris):
|
||||||
# Returns results matching 'a' and 'b' and artist 'xyz' from any
|
"""Lookup the images for the given URIs
|
||||||
# backend
|
|
||||||
find_exact({'any': ['a', 'b'], 'artist': ['xyz']})
|
Backends can use this to return image URIs for any URI they know about
|
||||||
find_exact(any=['a', 'b'], artist=['xyz'])
|
be it tracks, albums, playlists... The lookup result is a dictionary
|
||||||
|
mapping the provided URIs to lists of images.
|
||||||
# Returns results matching 'a' if within the given URI roots
|
|
||||||
# "file:///media/music" and "spotify:"
|
Unknown URIs or URIs the corresponding backend couldn't find anything
|
||||||
find_exact(
|
for will simply return an empty list for that URI.
|
||||||
{'any': ['a']}, uris=['file:///media/music', 'spotify:'])
|
|
||||||
find_exact(any=['a'], uris=['file:///media/music', 'spotify:'])
|
:param list uris: list of URIs to find images for
|
||||||
|
:rtype: {uri: tuple of :class:`mopidy.models.Image`}
|
||||||
:param query: one or more queries to search for
|
|
||||||
:type query: dict
|
.. versionadded:: 1.0
|
||||||
: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`
|
|
||||||
"""
|
"""
|
||||||
query = query or kwargs
|
|
||||||
futures = [
|
futures = [
|
||||||
backend.library.find_exact(query=query, uris=backend_uris)
|
backend.library.get_images(backend_uris)
|
||||||
for (backend, backend_uris)
|
for (backend, backend_uris)
|
||||||
in self._get_backends_to_uris(uris).items()]
|
in self._get_backends_to_uris(uris).items() if backend_uris]
|
||||||
return [result for result in pykka.get_all(futures) if result]
|
|
||||||
|
|
||||||
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.
|
||||||
|
"""
|
||||||
|
deprecation.warn('core.library.find_exact')
|
||||||
|
return self.search(query=query, uris=uris, exact=True, **kwargs)
|
||||||
|
|
||||||
|
def lookup(self, uri=None, uris=None):
|
||||||
"""
|
"""
|
||||||
Lookup the given URI.
|
Lookup the given URI.
|
||||||
|
|
||||||
@ -125,14 +146,48 @@ class LibraryController(object):
|
|||||||
them all.
|
them all.
|
||||||
|
|
||||||
:param uri: track URI
|
:param uri: track URI
|
||||||
:type uri: string
|
:type uri: string or :class:`None`
|
||||||
:rtype: list of :class:`mopidy.models.Track`
|
: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)
|
none_set = uri is None and uris is None
|
||||||
if backend:
|
both_set = uri is not None and uris is not None
|
||||||
return backend.library.lookup(uri).get()
|
|
||||||
else:
|
if none_set or both_set:
|
||||||
return []
|
raise ValueError("One of 'uri' or 'uris' must be set")
|
||||||
|
|
||||||
|
if uri:
|
||||||
|
deprecation.warn('core.library.lookup:uri_arg')
|
||||||
|
|
||||||
|
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):
|
def refresh(self, uri=None):
|
||||||
"""
|
"""
|
||||||
@ -150,13 +205,10 @@ class LibraryController(object):
|
|||||||
for b in self.backends.with_library.values()]
|
for b in self.backends.with_library.values()]
|
||||||
pykka.get_all(futures)
|
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``.
|
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.
|
|
||||||
|
|
||||||
If ``uris`` is given, the search is limited to results from within the
|
If ``uris`` is given, the search is limited to results from within the
|
||||||
URI roots. For example passing ``uris=['file:']`` will limit the search
|
URI roots. For example passing ``uris=['file:']`` will limit the search
|
||||||
to the local backend.
|
to the local backend.
|
||||||
@ -186,10 +238,58 @@ class LibraryController(object):
|
|||||||
:param uris: zero or more URI roots to limit the search to
|
:param uris: zero or more URI roots to limit the search to
|
||||||
:type uris: list of strings or :class:`None`
|
:type uris: list of strings or :class:`None`
|
||||||
:rtype: list of :class:`mopidy.models.SearchResult`
|
:rtype: list of :class:`mopidy.models.SearchResult`
|
||||||
|
|
||||||
|
.. versionadded:: 1.0
|
||||||
|
The ``exact`` keyword argument, which replaces :meth:`find_exact`.
|
||||||
|
|
||||||
|
.. 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.
|
||||||
|
|
||||||
|
.. deprecated:: 1.1
|
||||||
|
Providing the search query via ``kwargs`` is no longer supported.
|
||||||
"""
|
"""
|
||||||
query = query or kwargs
|
query = _normalize_query(query or kwargs)
|
||||||
futures = [
|
|
||||||
backend.library.search(query=query, uris=backend_uris)
|
if kwargs:
|
||||||
for (backend, backend_uris)
|
deprecation.warn('core.library.search:kwargs_query')
|
||||||
in self._get_backends_to_uris(uris).items()]
|
|
||||||
return [result for result in pykka.get_all(futures) if result]
|
if not query:
|
||||||
|
deprecation.warn('core.library.search:empty_query')
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from mopidy import listener
|
|||||||
|
|
||||||
|
|
||||||
class CoreListener(listener.Listener):
|
class CoreListener(listener.Listener):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Marker interface for recipients of events sent by the core actor.
|
Marker interface for recipients of events sent by the core actor.
|
||||||
|
|
||||||
@ -163,3 +164,11 @@ class CoreListener(listener.Listener):
|
|||||||
:type time_position: int
|
:type time_position: int
|
||||||
"""
|
"""
|
||||||
pass
|
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()
|
||||||
@ -5,29 +5,28 @@ import urlparse
|
|||||||
|
|
||||||
from mopidy.audio import PlaybackState
|
from mopidy.audio import PlaybackState
|
||||||
from mopidy.core import listener
|
from mopidy.core import listener
|
||||||
|
from mopidy.utils import deprecation
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# TODO: split mixing out from playback?
|
|
||||||
class PlaybackController(object):
|
class PlaybackController(object):
|
||||||
pykka_traversable = True
|
pykka_traversable = True
|
||||||
|
|
||||||
def __init__(self, audio, mixer, backends, core):
|
def __init__(self, audio, backends, core):
|
||||||
# TODO: these should be internal
|
# TODO: these should be internal
|
||||||
self.mixer = mixer
|
|
||||||
self.backends = backends
|
self.backends = backends
|
||||||
self.core = core
|
self.core = core
|
||||||
|
|
||||||
self._audio = audio
|
self._audio = audio
|
||||||
|
|
||||||
|
self._stream_title = None
|
||||||
self._state = PlaybackState.STOPPED
|
self._state = PlaybackState.STOPPED
|
||||||
self._volume = None
|
|
||||||
self._mute = False
|
self._current_tl_track = None
|
||||||
self._pending_tl_track = None
|
self._pending_tl_track = None
|
||||||
|
|
||||||
if self._audio:
|
if self._audio:
|
||||||
self._audio.set_about_to_finish_callback(self.on_about_to_finish)
|
self._audio.set_about_to_finish_callback(self._on_about_to_finish)
|
||||||
|
|
||||||
def _get_backend(self, tl_track):
|
def _get_backend(self, tl_track):
|
||||||
if tl_track is None:
|
if tl_track is None:
|
||||||
@ -38,137 +37,188 @@ class PlaybackController(object):
|
|||||||
# Properties
|
# Properties
|
||||||
|
|
||||||
def get_current_tl_track(self):
|
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 = deprecation.deprecated_property(get_current_tl_track)
|
||||||
"""
|
"""
|
||||||
The currently playing or selected :class:`mopidy.models.TlTrack`, or
|
.. deprecated:: 1.0
|
||||||
:class:`None`.
|
Use :meth:`get_current_tl_track` instead.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_current_track(self):
|
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)
|
Extracted from :meth:`get_current_tl_track` for convenience.
|
||||||
"""
|
|
||||||
The currently playing or selected :class:`mopidy.models.Track`.
|
|
||||||
|
|
||||||
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 = deprecation.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):
|
def get_state(self):
|
||||||
|
"""Get The playback state."""
|
||||||
|
|
||||||
return self._state
|
return self._state
|
||||||
|
|
||||||
def set_state(self, new_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)
|
logger.debug('Changing state: %s -> %s', old_state, new_state)
|
||||||
|
|
||||||
self._trigger_playback_state_changed(old_state, new_state)
|
self._trigger_playback_state_changed(old_state, new_state)
|
||||||
|
|
||||||
state = property(get_state, set_state)
|
state = deprecation.deprecated_property(get_state, set_state)
|
||||||
"""
|
"""
|
||||||
The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or
|
.. deprecated:: 1.0
|
||||||
:attr:`STOPPED`.
|
Use :meth:`get_state` and :meth:`set_state` instead.
|
||||||
|
|
||||||
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" ]
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_time_position(self):
|
def get_time_position(self):
|
||||||
backend = self._get_backend(self.current_tl_track)
|
"""Get time position in milliseconds."""
|
||||||
|
backend = self._get_backend(self.get_current_tl_track())
|
||||||
if backend:
|
if backend:
|
||||||
return backend.playback.get_time_position().get()
|
return backend.playback.get_time_position().get()
|
||||||
else:
|
else:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
time_position = property(get_time_position)
|
time_position = deprecation.deprecated_property(get_time_position)
|
||||||
"""Time position in milliseconds."""
|
"""
|
||||||
|
.. deprecated:: 1.0
|
||||||
|
Use :meth:`get_time_position` instead.
|
||||||
|
"""
|
||||||
|
|
||||||
def get_volume(self):
|
def get_volume(self):
|
||||||
if self.mixer:
|
"""
|
||||||
return self.mixer.get_volume().get()
|
.. deprecated:: 1.0
|
||||||
else:
|
Use :meth:`core.mixer.get_volume()
|
||||||
# For testing
|
<mopidy.core.MixerController.get_volume>` instead.
|
||||||
return self._volume
|
"""
|
||||||
|
deprecation.warn('core.playback.get_volume')
|
||||||
|
return self.core.mixer.get_volume()
|
||||||
|
|
||||||
def set_volume(self, volume):
|
def set_volume(self, volume):
|
||||||
if self.mixer:
|
"""
|
||||||
self.mixer.set_volume(volume)
|
.. deprecated:: 1.0
|
||||||
else:
|
Use :meth:`core.mixer.set_volume()
|
||||||
# For testing
|
<mopidy.core.MixerController.set_volume>` instead.
|
||||||
self._volume = volume
|
"""
|
||||||
|
deprecation.warn('core.playback.set_volume')
|
||||||
|
return self.core.mixer.set_volume(volume)
|
||||||
|
|
||||||
volume = property(get_volume, set_volume)
|
volume = deprecation.deprecated_property(get_volume, set_volume)
|
||||||
"""Volume as int in range [0..100] or :class:`None` if unknown. The volume
|
"""
|
||||||
scale is linear.
|
.. 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):
|
def get_mute(self):
|
||||||
if self.mixer:
|
"""
|
||||||
return self.mixer.get_mute().get()
|
.. deprecated:: 1.0
|
||||||
else:
|
Use :meth:`core.mixer.get_mute()
|
||||||
# For testing
|
<mopidy.core.MixerController.get_mute>` instead.
|
||||||
return self._mute
|
"""
|
||||||
|
deprecation.warn('core.playback.get_mute')
|
||||||
|
return self.core.mixer.get_mute()
|
||||||
|
|
||||||
def set_mute(self, value):
|
def set_mute(self, mute):
|
||||||
value = bool(value)
|
"""
|
||||||
if self.mixer:
|
.. deprecated:: 1.0
|
||||||
self.mixer.set_mute(value)
|
Use :meth:`core.mixer.set_mute()
|
||||||
else:
|
<mopidy.core.MixerController.set_mute>` instead.
|
||||||
# For testing
|
"""
|
||||||
self._mute = value
|
deprecation.warn('core.playback.set_mute')
|
||||||
|
return self.core.mixer.set_mute(mute)
|
||||||
|
|
||||||
mute = property(get_mute, set_mute)
|
mute = deprecation.deprecated_property(get_mute, set_mute)
|
||||||
"""Mute state as a :class:`True` if muted, :class:`False` otherwise"""
|
"""
|
||||||
|
.. 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
|
# Methods
|
||||||
|
|
||||||
def on_end_of_stream(self):
|
def _on_end_of_stream(self):
|
||||||
self.state = PlaybackState.STOPPED
|
self.set_state(PlaybackState.STOPPED)
|
||||||
self.current_tl_track = None
|
self._set_current_tl_track(None)
|
||||||
# TODO: self._trigger_track_playback_ended?
|
# TODO: self._trigger_track_playback_ended?
|
||||||
|
|
||||||
def on_stream_changed(self, uri):
|
def _on_stream_changed(self, uri):
|
||||||
|
self._stream_title = None
|
||||||
if self._pending_tl_track:
|
if self._pending_tl_track:
|
||||||
self.current_tl_track = self._pending_tl_track
|
self._set_current_tl_track(self._pending_tl_track)
|
||||||
self._pending_tl_track = None
|
self._pending_tl_track = None
|
||||||
self._trigger_track_playback_started()
|
self._trigger_track_playback_started()
|
||||||
|
|
||||||
def on_about_to_finish(self):
|
def _on_about_to_finish(self):
|
||||||
# TODO: check that we always have a current track
|
# TODO: check that we always have a current track
|
||||||
|
original_tl_track = self.get_current_tl_track()
|
||||||
original_tl_track = self.current_tl_track
|
|
||||||
next_tl_track = self.core.tracklist.eot_track(original_tl_track)
|
next_tl_track = self.core.tracklist.eot_track(original_tl_track)
|
||||||
|
|
||||||
|
# TODO: only set pending if we have a backend that can play it?
|
||||||
|
# TODO: skip tracks that don't have a backend?
|
||||||
self._pending_tl_track = next_tl_track
|
self._pending_tl_track = next_tl_track
|
||||||
backend = self._get_backend(next_tl_track)
|
backend = self._get_backend(next_tl_track)
|
||||||
|
|
||||||
if backend:
|
if backend:
|
||||||
backend.playback.change_track(next_tl_track.track).get()
|
backend.playback.change_track(next_tl_track.track).get()
|
||||||
|
|
||||||
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.
|
Tell the playback controller that the current playlist has changed.
|
||||||
|
|
||||||
Used by :class:`mopidy.core.TracklistController`.
|
Used by :class:`mopidy.core.TracklistController`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not self.core.tracklist.tl_tracks:
|
if not self.core.tracklist.tl_tracks:
|
||||||
self.stop()
|
self.stop()
|
||||||
self.current_tl_track = None
|
self._set_current_tl_track(None)
|
||||||
elif self.current_tl_track not in self.core.tracklist.tl_tracks:
|
elif self.get_current_tl_track() not in self.core.tracklist.tl_tracks:
|
||||||
self.current_tl_track = None
|
self._set_current_tl_track(None)
|
||||||
|
|
||||||
def next(self):
|
def next(self):
|
||||||
"""
|
"""
|
||||||
@ -177,64 +227,61 @@ class PlaybackController(object):
|
|||||||
The current playback state will be kept. If it was playing, playing
|
The current playback state will be kept. If it was playing, playing
|
||||||
will continue. If it was paused, it will still be paused, etc.
|
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)
|
next_tl_track = self.core.tracklist.next_track(original_tl_track)
|
||||||
|
|
||||||
backend = self._get_backend(next_tl_track)
|
backend = self._get_backend(next_tl_track)
|
||||||
self.current_tl_track = next_tl_track
|
self._set_current_tl_track(next_tl_track)
|
||||||
|
|
||||||
if backend:
|
if backend:
|
||||||
backend.playback.prepare_change()
|
backend.playback.prepare_change()
|
||||||
backend.playback.change_track(next_tl_track.track)
|
backend.playback.change_track(next_tl_track.track)
|
||||||
|
|
||||||
if self.state == PlaybackState.PLAYING:
|
if self.get_state() == PlaybackState.PLAYING:
|
||||||
result = backend.playback.play().get()
|
result = backend.playback.play().get()
|
||||||
elif self.state == PlaybackState.PAUSED:
|
elif self.get_state() == PlaybackState.PAUSED:
|
||||||
result = backend.playback.pause().get()
|
result = backend.playback.pause().get()
|
||||||
else:
|
else:
|
||||||
result = True
|
result = True
|
||||||
|
|
||||||
if result and self.state != PlaybackState.PAUSED:
|
if result and self.get_state() != PlaybackState.PAUSED:
|
||||||
self._trigger_track_playback_started()
|
self._trigger_track_playback_started()
|
||||||
elif not result:
|
elif not result:
|
||||||
self.core.tracklist.mark_unplayable(next_tl_track)
|
self.core.tracklist._mark_unplayable(next_tl_track)
|
||||||
# TODO: can cause an endless loop for single track repeat.
|
# TODO: can cause an endless loop for single track repeat.
|
||||||
self.next()
|
self.next()
|
||||||
else:
|
else:
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
self.core.tracklist.mark_played(original_tl_track)
|
self.core.tracklist._mark_played(original_tl_track)
|
||||||
|
|
||||||
def pause(self):
|
def pause(self):
|
||||||
"""Pause playback."""
|
"""Pause playback."""
|
||||||
backend = self._get_backend(self.current_tl_track)
|
backend = self._get_backend(self.get_current_tl_track())
|
||||||
if not backend or backend.playback.pause().get():
|
if not backend or backend.playback.pause().get():
|
||||||
# TODO: switch to:
|
# TODO: switch to:
|
||||||
# backend.track(pause)
|
# backend.track(pause)
|
||||||
# wait for state change?
|
# wait for state change?
|
||||||
self.state = PlaybackState.PAUSED
|
self.set_state(PlaybackState.PAUSED)
|
||||||
self._trigger_track_playback_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
|
Play the given track, or if the given track is :class:`None`, play the
|
||||||
currently active track.
|
currently active track.
|
||||||
|
|
||||||
:param tl_track: track to play
|
:param tl_track: track to play
|
||||||
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
|
: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. **INTERNAL**
|
|
||||||
: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 tl_track is None:
|
||||||
if self.state == PlaybackState.PAUSED:
|
if self.get_state() == PlaybackState.PAUSED:
|
||||||
return self.resume()
|
return self.resume()
|
||||||
|
|
||||||
if self.current_tl_track is not None:
|
if self.get_current_tl_track() is not None:
|
||||||
tl_track = self.current_tl_track
|
tl_track = self.get_current_tl_track()
|
||||||
else:
|
else:
|
||||||
if on_error_step == 1:
|
if on_error_step == 1:
|
||||||
tl_track = self.core.tracklist.next_track(tl_track)
|
tl_track = self.core.tracklist.next_track(tl_track)
|
||||||
@ -244,29 +291,37 @@ class PlaybackController(object):
|
|||||||
if tl_track is None:
|
if tl_track is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
assert tl_track in self.core.tracklist.tl_tracks
|
assert tl_track in self.core.tracklist.get_tl_tracks()
|
||||||
|
|
||||||
# TODO: switch to:
|
# TODO: switch to:
|
||||||
# backend.play(track)
|
# backend.play(track)
|
||||||
# wait for state change?
|
# wait for state change?
|
||||||
|
|
||||||
if self.state == PlaybackState.PLAYING:
|
if self.get_state() == PlaybackState.PLAYING:
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
self.current_tl_track = tl_track
|
self._set_current_tl_track(tl_track)
|
||||||
self.state = PlaybackState.PLAYING
|
self.set_state(PlaybackState.PLAYING)
|
||||||
backend = self._get_backend(self.current_tl_track)
|
backend = self._get_backend(tl_track)
|
||||||
success = False
|
success = False
|
||||||
|
|
||||||
if backend:
|
if backend:
|
||||||
backend.playback.prepare_change()
|
backend.playback.prepare_change()
|
||||||
backend.playback.change_track(tl_track.track)
|
try:
|
||||||
success = backend.playback.play().get()
|
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:
|
if success:
|
||||||
|
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()
|
self._trigger_track_playback_started()
|
||||||
else:
|
else:
|
||||||
self.core.tracklist.mark_unplayable(tl_track)
|
self.core.tracklist._mark_unplayable(tl_track)
|
||||||
if on_error_step == 1:
|
if on_error_step == 1:
|
||||||
# TODO: can cause an endless loop for single track repeat.
|
# TODO: can cause an endless loop for single track repeat.
|
||||||
self.next()
|
self.next()
|
||||||
@ -280,35 +335,38 @@ class PlaybackController(object):
|
|||||||
The current playback state will be kept. If it was playing, playing
|
The current playback state will be kept. If it was playing, playing
|
||||||
will continue. If it was paused, it will still be paused, etc.
|
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()
|
||||||
prev_tl_track = self.core.tracklist.previous_track(original_tl_track)
|
prev_tl_track = self.core.tracklist.previous_track(original_tl_track)
|
||||||
|
|
||||||
backend = self._get_backend(prev_tl_track)
|
backend = self._get_backend(prev_tl_track)
|
||||||
self.current_tl_track = prev_tl_track
|
self._set_current_tl_track(prev_tl_track)
|
||||||
|
|
||||||
if backend:
|
if backend:
|
||||||
backend.playback.prepare_change()
|
backend.playback.prepare_change()
|
||||||
|
# TODO: check return values of change track
|
||||||
backend.playback.change_track(prev_tl_track.track)
|
backend.playback.change_track(prev_tl_track.track)
|
||||||
if self.state == PlaybackState.PLAYING:
|
if self.get_state() == PlaybackState.PLAYING:
|
||||||
result = backend.playback.play().get()
|
result = backend.playback.play().get()
|
||||||
elif self.state == PlaybackState.PAUSED:
|
elif self.get_state() == PlaybackState.PAUSED:
|
||||||
result = backend.playback.pause().get()
|
result = backend.playback.pause().get()
|
||||||
else:
|
else:
|
||||||
result = True
|
result = True
|
||||||
|
|
||||||
if result and self.state != PlaybackState.PAUSED:
|
if result and self.get_state() != PlaybackState.PAUSED:
|
||||||
self._trigger_track_playback_started()
|
self._trigger_track_playback_started()
|
||||||
elif not result:
|
elif not result:
|
||||||
self.core.tracklist.mark_unplayable(prev_tl_track)
|
self.core.tracklist._mark_unplayable(prev_tl_track)
|
||||||
self.previous()
|
self.previous()
|
||||||
|
|
||||||
|
# TODO: no return value?
|
||||||
|
|
||||||
def resume(self):
|
def resume(self):
|
||||||
"""If paused, resume playing the current track."""
|
"""If paused, resume playing the current track."""
|
||||||
if self.state != PlaybackState.PAUSED:
|
if self.get_state() != PlaybackState.PAUSED:
|
||||||
return
|
return
|
||||||
backend = self._get_backend(self.current_tl_track)
|
backend = self._get_backend(self.get_current_tl_track())
|
||||||
if backend and backend.playback.resume().get():
|
if backend and backend.playback.resume().get():
|
||||||
self.state = PlaybackState.PLAYING
|
self.set_state(PlaybackState.PLAYING)
|
||||||
# TODO: trigger via gst messages
|
# TODO: trigger via gst messages
|
||||||
self._trigger_track_playback_resumed()
|
self._trigger_track_playback_resumed()
|
||||||
# TODO: switch to:
|
# TODO: switch to:
|
||||||
@ -327,18 +385,20 @@ class PlaybackController(object):
|
|||||||
if not self.core.tracklist.tracks:
|
if not self.core.tracklist.tracks:
|
||||||
return False
|
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()
|
self.play()
|
||||||
elif self.state == PlaybackState.PAUSED:
|
|
||||||
self.resume()
|
|
||||||
|
|
||||||
if time_position < 0:
|
if time_position < 0:
|
||||||
time_position = 0
|
time_position = 0
|
||||||
elif time_position > self.current_track.length:
|
elif time_position > self.current_track.length:
|
||||||
|
# TODO: gstreamer will trigger a about to finish for us, use that?
|
||||||
self.next()
|
self.next()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
backend = self._get_backend(self.current_tl_track)
|
backend = self._get_backend(self.get_current_tl_track())
|
||||||
if not backend:
|
if not backend:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -349,11 +409,11 @@ class PlaybackController(object):
|
|||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""Stop playing."""
|
"""Stop playing."""
|
||||||
if self.state != PlaybackState.STOPPED:
|
if self.get_state() != PlaybackState.STOPPED:
|
||||||
backend = self._get_backend(self.current_tl_track)
|
backend = self._get_backend(self.get_current_tl_track())
|
||||||
time_position_before_stop = self.time_position
|
time_position_before_stop = self.get_time_position()
|
||||||
if not backend or backend.playback.stop().get():
|
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)
|
self._trigger_track_playback_ended(time_position_before_stop)
|
||||||
|
|
||||||
def _trigger_track_playback_paused(self):
|
def _trigger_track_playback_paused(self):
|
||||||
@ -362,7 +422,8 @@ class PlaybackController(object):
|
|||||||
return
|
return
|
||||||
listener.CoreListener.send(
|
listener.CoreListener.send(
|
||||||
'track_playback_paused',
|
'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):
|
def _trigger_track_playback_resumed(self):
|
||||||
logger.debug('Triggering track playback resumed event')
|
logger.debug('Triggering track playback resumed event')
|
||||||
@ -370,27 +431,27 @@ class PlaybackController(object):
|
|||||||
return
|
return
|
||||||
listener.CoreListener.send(
|
listener.CoreListener.send(
|
||||||
'track_playback_resumed',
|
'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):
|
def _trigger_track_playback_started(self):
|
||||||
# TODO: replace with stream-changed
|
# TODO: replace with stream-changed
|
||||||
logger.debug('Triggering track playback started event')
|
logger.debug('Triggering track playback started event')
|
||||||
if self.current_tl_track is None:
|
if self.get_current_tl_track() is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.core.tracklist.mark_playing(self.current_tl_track)
|
tl_track = self.get_current_tl_track()
|
||||||
self.core.history.add(self.current_tl_track.track)
|
self.core.tracklist._mark_playing(tl_track)
|
||||||
listener.CoreListener.send(
|
self.core.history._add_track(tl_track.track)
|
||||||
'track_playback_started',
|
listener.CoreListener.send('track_playback_started', tl_track=tl_track)
|
||||||
tl_track=self.current_tl_track)
|
|
||||||
|
|
||||||
def _trigger_track_playback_ended(self, time_position_before_stop):
|
def _trigger_track_playback_ended(self, time_position_before_stop):
|
||||||
logger.debug('Triggering track playback ended event')
|
logger.debug('Triggering track playback ended event')
|
||||||
if self.current_tl_track is None:
|
if self.get_current_tl_track() is None:
|
||||||
return
|
return
|
||||||
listener.CoreListener.send(
|
listener.CoreListener.send(
|
||||||
'track_playback_ended',
|
'track_playback_ended',
|
||||||
tl_track=self.current_tl_track,
|
tl_track=self.get_current_tl_track(),
|
||||||
time_position=time_position_before_stop)
|
time_position=time_position_before_stop)
|
||||||
|
|
||||||
def _trigger_playback_state_changed(self, old_state, new_state):
|
def _trigger_playback_state_changed(self, old_state, new_state):
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
from __future__ import absolute_import, unicode_literals
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
import itertools
|
import logging
|
||||||
import urlparse
|
import urlparse
|
||||||
|
|
||||||
import pykka
|
import pykka
|
||||||
|
|
||||||
from . import listener
|
from mopidy.core import listener
|
||||||
|
from mopidy.models import Playlist
|
||||||
|
from mopidy.utils import deprecation
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PlaylistsController(object):
|
class PlaylistsController(object):
|
||||||
@ -15,20 +19,85 @@ class PlaylistsController(object):
|
|||||||
self.backends = backends
|
self.backends = backends
|
||||||
self.core = core
|
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):
|
def get_playlists(self, include_tracks=True):
|
||||||
futures = [b.playlists.playlists
|
"""
|
||||||
for b in self.backends.with_playlists.values()]
|
Get the available playlists.
|
||||||
results = pykka.get_all(futures)
|
|
||||||
playlists = list(itertools.chain(*results))
|
|
||||||
if not include_tracks:
|
|
||||||
playlists = [p.copy(tracks=[]) for p in playlists]
|
|
||||||
return 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.
|
||||||
|
"""
|
||||||
|
deprecation.warn('core.playlists.get_playlists')
|
||||||
|
|
||||||
|
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 = deprecation.deprecated_property(get_playlists)
|
||||||
"""
|
"""
|
||||||
The available playlists.
|
.. deprecated:: 1.0
|
||||||
|
Use :meth:`as_list` and :meth:`get_items` instead.
|
||||||
Read-only. List of :class:`mopidy.models.Playlist`.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def create(self, name, uri_scheme=None):
|
def create(self, name, uri_scheme=None):
|
||||||
@ -40,7 +109,7 @@ class PlaylistsController(object):
|
|||||||
:class:`None` or doesn't match a current backend, the first backend is
|
:class:`None` or doesn't match a current backend, the first backend is
|
||||||
asked to create the playlist.
|
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`.
|
by creating new instances of :class:`mopidy.models.Playlist`.
|
||||||
|
|
||||||
:param name: name of the new playlist
|
:param name: name of the new playlist
|
||||||
@ -94,7 +163,12 @@ class PlaylistsController(object):
|
|||||||
:param criteria: one or more criteria to match by
|
:param criteria: one or more criteria to match by
|
||||||
:type criteria: dict
|
:type criteria: dict
|
||||||
:rtype: list of :class:`mopidy.models.Playlist`
|
:rtype: list of :class:`mopidy.models.Playlist`
|
||||||
|
|
||||||
|
.. deprecated:: 1.0
|
||||||
|
Use :meth:`as_list` and filter yourself.
|
||||||
"""
|
"""
|
||||||
|
deprecation.warn('core.playlists.filter')
|
||||||
|
|
||||||
criteria = criteria or kwargs
|
criteria = criteria or kwargs
|
||||||
matches = self.playlists
|
matches = self.playlists
|
||||||
for (key, value) in criteria.iteritems():
|
for (key, value) in criteria.iteritems():
|
||||||
@ -145,14 +219,14 @@ class PlaylistsController(object):
|
|||||||
Save the playlist.
|
Save the playlist.
|
||||||
|
|
||||||
For a playlist to be saveable, it must have the ``uri`` attribute set.
|
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`,
|
objects returned by :meth:`create` or retrieved from :attr:`playlists`,
|
||||||
which will always give you saveable playlists.
|
which will always give you saveable playlists.
|
||||||
|
|
||||||
The method returns the saved playlist. The return playlist may differ
|
The method returns the saved playlist. The return playlist may differ
|
||||||
from the saved playlist. E.g. if the playlist name was changed, the
|
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
|
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.
|
returned playlist instead.
|
||||||
|
|
||||||
If the playlist's URI isn't set or doesn't match the URI scheme of a
|
If the playlist's URI isn't set or doesn't match the URI scheme of a
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import random
|
|||||||
from mopidy import compat
|
from mopidy import compat
|
||||||
from mopidy.core import listener
|
from mopidy.core import listener
|
||||||
from mopidy.models import TlTrack
|
from mopidy.models import TlTrack
|
||||||
|
from mopidy.utils import deprecation
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -26,114 +26,176 @@ class TracklistController(object):
|
|||||||
# Properties
|
# Properties
|
||||||
|
|
||||||
def get_tl_tracks(self):
|
def get_tl_tracks(self):
|
||||||
|
"""Get tracklist as list of :class:`mopidy.models.TlTrack`."""
|
||||||
return self._tl_tracks[:]
|
return self._tl_tracks[:]
|
||||||
|
|
||||||
tl_tracks = property(get_tl_tracks)
|
tl_tracks = deprecation.deprecated_property(get_tl_tracks)
|
||||||
"""
|
"""
|
||||||
List of :class:`mopidy.models.TlTrack`.
|
.. deprecated:: 1.0
|
||||||
|
Use :meth:`get_tl_tracks` instead.
|
||||||
Read-only.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_tracks(self):
|
def get_tracks(self):
|
||||||
|
"""Get tracklist as list of :class:`mopidy.models.Track`."""
|
||||||
return [tl_track.track for tl_track in self._tl_tracks]
|
return [tl_track.track for tl_track in self._tl_tracks]
|
||||||
|
|
||||||
tracks = property(get_tracks)
|
tracks = deprecation.deprecated_property(get_tracks)
|
||||||
"""
|
"""
|
||||||
List of :class:`mopidy.models.Track` in the tracklist.
|
.. deprecated:: 1.0
|
||||||
|
Use :meth:`get_tracks` instead.
|
||||||
Read-only.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_length(self):
|
def get_length(self):
|
||||||
|
"""Get length of the tracklist."""
|
||||||
return len(self._tl_tracks)
|
return len(self._tl_tracks)
|
||||||
|
|
||||||
length = property(get_length)
|
length = deprecation.deprecated_property(get_length)
|
||||||
"""Length of the tracklist."""
|
"""
|
||||||
|
.. deprecated:: 1.0
|
||||||
|
Use :meth:`get_length` instead.
|
||||||
|
"""
|
||||||
|
|
||||||
def get_version(self):
|
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
|
return self._version
|
||||||
|
|
||||||
def _increase_version(self):
|
def _increase_version(self):
|
||||||
self._version += 1
|
self._version += 1
|
||||||
self.core.playback.on_tracklist_change()
|
self.core.playback._on_tracklist_change()
|
||||||
self._trigger_tracklist_changed()
|
self._trigger_tracklist_changed()
|
||||||
|
|
||||||
version = property(get_version)
|
version = deprecation.deprecated_property(get_version)
|
||||||
"""
|
"""
|
||||||
The tracklist version.
|
.. deprecated:: 1.0
|
||||||
|
Use :meth:`get_version` instead.
|
||||||
Read-only. Integer which is increased every time the tracklist is changed.
|
|
||||||
Is not reset before Mopidy is restarted.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_consume(self):
|
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)
|
return getattr(self, '_consume', False)
|
||||||
|
|
||||||
def set_consume(self, value):
|
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:
|
if self.get_consume() != value:
|
||||||
self._trigger_options_changed()
|
self._trigger_options_changed()
|
||||||
return setattr(self, '_consume', value)
|
return setattr(self, '_consume', value)
|
||||||
|
|
||||||
consume = property(get_consume, set_consume)
|
consume = deprecation.deprecated_property(get_consume, set_consume)
|
||||||
"""
|
"""
|
||||||
:class:`True`
|
.. deprecated:: 1.0
|
||||||
Tracks are removed from the tracklist when they have been played.
|
Use :meth:`get_consume` and :meth:`set_consume` instead.
|
||||||
:class:`False`
|
|
||||||
Tracks are not removed from the tracklist.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_random(self):
|
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)
|
return getattr(self, '_random', False)
|
||||||
|
|
||||||
def set_random(self, value):
|
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:
|
if self.get_random() != value:
|
||||||
self._trigger_options_changed()
|
self._trigger_options_changed()
|
||||||
if value:
|
if value:
|
||||||
self._shuffled = self.tl_tracks
|
self._shuffled = self.get_tl_tracks()
|
||||||
random.shuffle(self._shuffled)
|
random.shuffle(self._shuffled)
|
||||||
return setattr(self, '_random', value)
|
return setattr(self, '_random', value)
|
||||||
|
|
||||||
random = property(get_random, set_random)
|
random = deprecation.deprecated_property(get_random, set_random)
|
||||||
"""
|
"""
|
||||||
:class:`True`
|
.. deprecated:: 1.0
|
||||||
Tracks are selected at random from the tracklist.
|
Use :meth:`get_random` and :meth:`set_random` instead.
|
||||||
:class:`False`
|
|
||||||
Tracks are played in the order of the tracklist.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_repeat(self):
|
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)
|
return getattr(self, '_repeat', False)
|
||||||
|
|
||||||
def set_repeat(self, value):
|
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:
|
if self.get_repeat() != value:
|
||||||
self._trigger_options_changed()
|
self._trigger_options_changed()
|
||||||
return setattr(self, '_repeat', value)
|
return setattr(self, '_repeat', value)
|
||||||
|
|
||||||
repeat = property(get_repeat, set_repeat)
|
repeat = deprecation.deprecated_property(get_repeat, set_repeat)
|
||||||
"""
|
"""
|
||||||
:class:`True`
|
.. deprecated:: 1.0
|
||||||
The tracklist is played repeatedly. To repeat a single track, select
|
Use :meth:`get_repeat` and :meth:`set_repeat` instead.
|
||||||
both :attr:`repeat` and :attr:`single`.
|
|
||||||
:class:`False`
|
|
||||||
The tracklist is played once.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_single(self):
|
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)
|
return getattr(self, '_single', False)
|
||||||
|
|
||||||
def set_single(self, value):
|
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:
|
if self.get_single() != value:
|
||||||
self._trigger_options_changed()
|
self._trigger_options_changed()
|
||||||
return setattr(self, '_single', value)
|
return setattr(self, '_single', value)
|
||||||
|
|
||||||
single = property(get_single, set_single)
|
single = deprecation.deprecated_property(get_single, set_single)
|
||||||
"""
|
"""
|
||||||
:class:`True`
|
.. deprecated:: 1.0
|
||||||
Playback is stopped after current song, unless in :attr:`repeat`
|
Use :meth:`get_single` and :meth:`set_single` instead.
|
||||||
mode.
|
|
||||||
:class:`False`
|
|
||||||
Playback continues after current song.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Methods
|
# Methods
|
||||||
@ -161,9 +223,9 @@ class TracklistController(object):
|
|||||||
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
|
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||||
:rtype: :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
|
return tl_track
|
||||||
elif self.single:
|
elif self.get_single():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Current difference between next and EOT handling is that EOT needs to
|
# Current difference between next and EOT handling is that EOT needs to
|
||||||
@ -186,30 +248,30 @@ class TracklistController(object):
|
|||||||
:rtype: :class:`mopidy.models.TlTrack` or :class:`None`
|
:rtype: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not self.tl_tracks:
|
if not self.get_tl_tracks():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if self.random and not self._shuffled:
|
if self.get_random() and not self._shuffled:
|
||||||
if self.repeat or not tl_track:
|
if self.get_repeat() or not tl_track:
|
||||||
logger.debug('Shuffling tracks')
|
logger.debug('Shuffling tracks')
|
||||||
self._shuffled = self.tl_tracks
|
self._shuffled = self.get_tl_tracks()
|
||||||
random.shuffle(self._shuffled)
|
random.shuffle(self._shuffled)
|
||||||
|
|
||||||
if self.random:
|
if self.get_random():
|
||||||
try:
|
try:
|
||||||
return self._shuffled[0]
|
return self._shuffled[0]
|
||||||
except IndexError:
|
except IndexError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if tl_track is None:
|
if tl_track is None:
|
||||||
return self.tl_tracks[0]
|
return self.get_tl_tracks()[0]
|
||||||
|
|
||||||
next_index = self.index(tl_track) + 1
|
next_index = self.index(tl_track) + 1
|
||||||
if self.repeat:
|
if self.get_repeat():
|
||||||
next_index %= len(self.tl_tracks)
|
next_index %= len(self.get_tl_tracks())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return self.tl_tracks[next_index]
|
return self.get_tl_tracks()[next_index]
|
||||||
except IndexError:
|
except IndexError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -226,7 +288,7 @@ class TracklistController(object):
|
|||||||
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
|
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||||
:rtype: :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
|
return tl_track
|
||||||
|
|
||||||
position = self.index(tl_track)
|
position = self.index(tl_track)
|
||||||
@ -234,15 +296,18 @@ class TracklistController(object):
|
|||||||
if position in (None, 0):
|
if position in (None, 0):
|
||||||
return None
|
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.
|
Add the track or list of tracks to the tracklist.
|
||||||
|
|
||||||
If ``uri`` is given instead of ``tracks``, the URI is looked up in the
|
If ``uri`` is given instead of ``tracks``, the URI is looked up in the
|
||||||
library and the resulting tracks are added to the tracklist.
|
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
|
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
|
the tracklist. If ``at_position`` is not given, the tracks are appended
|
||||||
to the end of the tracklist.
|
to the end of the tracklist.
|
||||||
@ -256,12 +321,32 @@ class TracklistController(object):
|
|||||||
:param uri: URI for tracks to add
|
:param uri: URI for tracks to add
|
||||||
:type uri: string
|
:type uri: string
|
||||||
:rtype: list of :class:`mopidy.models.TlTrack`
|
: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:
|
.. versionadded:: 1.0
|
||||||
tracks = self.core.library.lookup(uri)
|
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'
|
||||||
|
|
||||||
|
# TODO: assert that tracks are track instances
|
||||||
|
|
||||||
|
if tracks:
|
||||||
|
deprecation.warn('core.tracklist.add:tracks_arg')
|
||||||
|
|
||||||
|
if uri:
|
||||||
|
deprecation.warn('core.tracklist.add:uri_arg')
|
||||||
|
|
||||||
|
if tracks is None:
|
||||||
|
if uri is not None:
|
||||||
|
uris = [uri]
|
||||||
|
|
||||||
|
tracks = []
|
||||||
|
track_map = self.core.library.lookup(uris=uris)
|
||||||
|
for uri in uris:
|
||||||
|
tracks.extend(track_map[uri])
|
||||||
|
|
||||||
tl_tracks = []
|
tl_tracks = []
|
||||||
|
|
||||||
@ -329,8 +414,8 @@ class TracklistController(object):
|
|||||||
criteria = criteria or kwargs
|
criteria = criteria or kwargs
|
||||||
matches = self._tl_tracks
|
matches = self._tl_tracks
|
||||||
for (key, values) in criteria.items():
|
for (key, values) in criteria.items():
|
||||||
if (not isinstance(values, collections.Iterable)
|
if (not isinstance(values, collections.Iterable) or
|
||||||
or isinstance(values, compat.string_types)):
|
isinstance(values, compat.string_types)):
|
||||||
# Fail hard if anyone is using the <0.17 calling style
|
# Fail hard if anyone is using the <0.17 calling style
|
||||||
raise ValueError('Filter values must be iterable: %r' % values)
|
raise ValueError('Filter values must be iterable: %r' % values)
|
||||||
if key == 'tlid':
|
if key == 'tlid':
|
||||||
@ -436,27 +521,27 @@ class TracklistController(object):
|
|||||||
"""
|
"""
|
||||||
return self._tl_tracks[start:end]
|
return self._tl_tracks[start:end]
|
||||||
|
|
||||||
def mark_playing(self, tl_track):
|
def _mark_playing(self, tl_track):
|
||||||
"""Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**"""
|
"""Internal method for :class:`mopidy.core.PlaybackController`."""
|
||||||
if self.random and tl_track in self._shuffled:
|
if self.get_random() and tl_track in self._shuffled:
|
||||||
self._shuffled.remove(tl_track)
|
self._shuffled.remove(tl_track)
|
||||||
|
|
||||||
def mark_unplayable(self, tl_track):
|
def _mark_unplayable(self, tl_track):
|
||||||
"""Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**"""
|
"""Internal method for :class:`mopidy.core.PlaybackController`."""
|
||||||
logger.warning('Track is not playable: %s', tl_track.track.uri)
|
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)
|
self._shuffled.remove(tl_track)
|
||||||
|
|
||||||
def mark_played(self, tl_track):
|
def _mark_played(self, tl_track):
|
||||||
"""Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**"""
|
"""Internal method for :class:`mopidy.core.PlaybackController`."""
|
||||||
if self.consume and tl_track is not None:
|
if self.consume and tl_track is not None:
|
||||||
self.remove(tlid=[tl_track.tlid])
|
self.remove(tlid=[tl_track.tlid])
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _trigger_tracklist_changed(self):
|
def _trigger_tracklist_changed(self):
|
||||||
if self.random:
|
if self.get_random():
|
||||||
self._shuffled = self.tl_tracks
|
self._shuffled = self.get_tl_tracks()
|
||||||
random.shuffle(self._shuffled)
|
random.shuffle(self._shuffled)
|
||||||
else:
|
else:
|
||||||
self._shuffled = []
|
self._shuffled = []
|
||||||
|
|||||||
@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals
|
|||||||
|
|
||||||
|
|
||||||
class MopidyException(Exception):
|
class MopidyException(Exception):
|
||||||
|
|
||||||
def __init__(self, message, *args, **kwargs):
|
def __init__(self, message, *args, **kwargs):
|
||||||
super(MopidyException, self).__init__(message, *args, **kwargs)
|
super(MopidyException, self).__init__(message, *args, **kwargs)
|
||||||
self._message = message
|
self._message = message
|
||||||
@ -25,6 +26,7 @@ class ExtensionError(MopidyException):
|
|||||||
|
|
||||||
|
|
||||||
class FindError(MopidyException):
|
class FindError(MopidyException):
|
||||||
|
|
||||||
def __init__(self, message, errno=None):
|
def __init__(self, message, errno=None):
|
||||||
super(FindError, self).__init__(message, errno)
|
super(FindError, self).__init__(message, errno)
|
||||||
self.errno = errno
|
self.errno = errno
|
||||||
|
|||||||
@ -12,6 +12,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class Extension(object):
|
class Extension(object):
|
||||||
|
|
||||||
"""Base class for Mopidy extensions"""
|
"""Base class for Mopidy extensions"""
|
||||||
|
|
||||||
dist_name = None
|
dist_name = None
|
||||||
@ -104,6 +105,7 @@ class Extension(object):
|
|||||||
|
|
||||||
|
|
||||||
class Registry(collections.Mapping):
|
class Registry(collections.Mapping):
|
||||||
|
|
||||||
"""Registry of components provided by Mopidy extensions.
|
"""Registry of components provided by Mopidy extensions.
|
||||||
|
|
||||||
Passed to the :meth:`~Extension.setup` method of all extensions. The
|
Passed to the :meth:`~Extension.setup` method of all extensions. The
|
||||||
|
|||||||
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
@ -10,7 +10,7 @@ import tornado.websocket
|
|||||||
|
|
||||||
import mopidy
|
import mopidy
|
||||||
from mopidy import core, models
|
from mopidy import core, models
|
||||||
from mopidy.utils import jsonrpc
|
from mopidy.utils import encoding, jsonrpc
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -43,6 +43,7 @@ def make_jsonrpc_wrapper(core_actor):
|
|||||||
'core.get_version': core.Core.get_version,
|
'core.get_version': core.Core.get_version,
|
||||||
'core.history': core.HistoryController,
|
'core.history': core.HistoryController,
|
||||||
'core.library': core.LibraryController,
|
'core.library': core.LibraryController,
|
||||||
|
'core.mixer': core.MixerController,
|
||||||
'core.playback': core.PlaybackController,
|
'core.playback': core.PlaybackController,
|
||||||
'core.playlists': core.PlaylistsController,
|
'core.playlists': core.PlaylistsController,
|
||||||
'core.tracklist': core.TracklistController,
|
'core.tracklist': core.TracklistController,
|
||||||
@ -54,6 +55,7 @@ def make_jsonrpc_wrapper(core_actor):
|
|||||||
'core.get_version': core_actor.get_version,
|
'core.get_version': core_actor.get_version,
|
||||||
'core.history': core_actor.history,
|
'core.history': core_actor.history,
|
||||||
'core.library': core_actor.library,
|
'core.library': core_actor.library,
|
||||||
|
'core.mixer': core_actor.mixer,
|
||||||
'core.playback': core_actor.playback,
|
'core.playback': core_actor.playback,
|
||||||
'core.playlists': core_actor.playlists,
|
'core.playlists': core_actor.playlists,
|
||||||
'core.tracklist': core_actor.tracklist,
|
'core.tracklist': core_actor.tracklist,
|
||||||
@ -73,7 +75,16 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def broadcast(cls, msg):
|
def broadcast(cls, msg):
|
||||||
for client in cls.clients:
|
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):
|
def initialize(self, core):
|
||||||
self.jsonrpc = make_jsonrpc_wrapper(core)
|
self.jsonrpc = make_jsonrpc_wrapper(core)
|
||||||
@ -111,7 +122,8 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
|
|||||||
'Sent WebSocket message to %s: %r',
|
'Sent WebSocket message to %s: %r',
|
||||||
self.request.remote_ip, response)
|
self.request.remote_ip, response)
|
||||||
except Exception as e:
|
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:
|
if self.ws_connection:
|
||||||
# Tornado 3.2+ checks if self.ws_connection is None before
|
# Tornado 3.2+ checks if self.ws_connection is None before
|
||||||
# using it, but not older versions.
|
# using it, but not older versions.
|
||||||
@ -130,6 +142,7 @@ def set_mopidy_headers(request_handler):
|
|||||||
|
|
||||||
|
|
||||||
class JsonRpcHandler(tornado.web.RequestHandler):
|
class JsonRpcHandler(tornado.web.RequestHandler):
|
||||||
|
|
||||||
def initialize(self, core):
|
def initialize(self, core):
|
||||||
self.jsonrpc = make_jsonrpc_wrapper(core)
|
self.jsonrpc = make_jsonrpc_wrapper(core)
|
||||||
|
|
||||||
@ -164,6 +177,7 @@ class JsonRpcHandler(tornado.web.RequestHandler):
|
|||||||
|
|
||||||
|
|
||||||
class ClientListHandler(tornado.web.RequestHandler):
|
class ClientListHandler(tornado.web.RequestHandler):
|
||||||
|
|
||||||
def initialize(self, apps, statics):
|
def initialize(self, apps, statics):
|
||||||
self.apps = apps
|
self.apps = apps
|
||||||
self.statics = statics
|
self.statics = statics
|
||||||
@ -185,6 +199,7 @@ class ClientListHandler(tornado.web.RequestHandler):
|
|||||||
|
|
||||||
|
|
||||||
class StaticFileHandler(tornado.web.StaticFileHandler):
|
class StaticFileHandler(tornado.web.StaticFileHandler):
|
||||||
|
|
||||||
def set_extra_headers(self, path):
|
def set_extra_headers(self, path):
|
||||||
set_mopidy_headers(self)
|
set_mopidy_headers(self)
|
||||||
|
|
||||||
|
|||||||
@ -17,10 +17,25 @@ def send(cls, event, **kwargs):
|
|||||||
listeners = pykka.ActorRegistry.get_by_class(cls)
|
listeners = pykka.ActorRegistry.get_by_class(cls)
|
||||||
logger.debug('Sending %s to %s: %s', event, cls.__name__, kwargs)
|
logger.debug('Sending %s to %s: %s', event, cls.__name__, kwargs)
|
||||||
for listener in listeners:
|
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):
|
class Listener(object):
|
||||||
|
|
||||||
def on_event(self, event, **kwargs):
|
def on_event(self, event, **kwargs):
|
||||||
"""
|
"""
|
||||||
Called on all events.
|
Called on all events.
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
import mopidy
|
import mopidy
|
||||||
from mopidy import config, ext
|
from mopidy import config, ext, models
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -24,10 +24,10 @@ class Extension(ext.Extension):
|
|||||||
schema['library'] = config.String()
|
schema['library'] = config.String()
|
||||||
schema['media_dir'] = config.Path()
|
schema['media_dir'] = config.Path()
|
||||||
schema['data_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['tag_cache_file'] = config.Deprecated()
|
||||||
schema['scan_timeout'] = config.Integer(
|
schema['scan_timeout'] = config.Integer(
|
||||||
minimum=1000, maximum=1000*60*60)
|
minimum=1000, maximum=1000 * 60 * 60)
|
||||||
schema['scan_flush_threshold'] = config.Integer(minimum=0)
|
schema['scan_flush_threshold'] = config.Integer(minimum=0)
|
||||||
schema['scan_follow_symlinks'] = config.Boolean()
|
schema['scan_follow_symlinks'] = config.Boolean()
|
||||||
schema['excluded_file_extensions'] = config.List(optional=True)
|
schema['excluded_file_extensions'] = config.List(optional=True)
|
||||||
@ -48,6 +48,7 @@ class Extension(ext.Extension):
|
|||||||
|
|
||||||
|
|
||||||
class Library(object):
|
class Library(object):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Local library interface.
|
Local library interface.
|
||||||
|
|
||||||
@ -70,6 +71,10 @@ class Library(object):
|
|||||||
#: Name of the local library implementation, must be overriden.
|
#: Name of the local library implementation, must be overriden.
|
||||||
name = None
|
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):
|
def __init__(self, config):
|
||||||
self._config = config
|
self._config = config
|
||||||
|
|
||||||
@ -85,6 +90,43 @@ class Library(object):
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
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):
|
def load(self):
|
||||||
"""
|
"""
|
||||||
(Re)load any tracks stored in memory, if any, otherwise just return
|
(Re)load any tracks stored in memory, if any, otherwise just return
|
||||||
@ -135,12 +177,19 @@ class Library(object):
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
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
|
:param track: Track to add to the library
|
||||||
:type track: :class:`~mopidy.models.Track`
|
: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
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,6 @@ from mopidy import backend
|
|||||||
from mopidy.local import storage
|
from mopidy.local import storage
|
||||||
from mopidy.local.library import LocalLibraryProvider
|
from mopidy.local.library import LocalLibraryProvider
|
||||||
from mopidy.local.playback import LocalPlaybackProvider
|
from mopidy.local.playback import LocalPlaybackProvider
|
||||||
from mopidy.local.playlists import LocalPlaylistsProvider
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -36,5 +35,4 @@ class LocalBackend(pykka.ThreadingActor, backend.Backend):
|
|||||||
logger.warning('Local library %s not found', library_name)
|
logger.warning('Local library %s not found', library_name)
|
||||||
|
|
||||||
self.playback = LocalPlaybackProvider(audio=audio, backend=self)
|
self.playback = LocalPlaybackProvider(audio=audio, backend=self)
|
||||||
self.playlists = LocalPlaylistsProvider(backend=self)
|
|
||||||
self.library = LocalLibraryProvider(backend=self, library=library)
|
self.library = LocalLibraryProvider(backend=self, library=library)
|
||||||
|
|||||||
@ -29,6 +29,7 @@ def _get_library(args, config):
|
|||||||
|
|
||||||
|
|
||||||
class LocalCommand(commands.Command):
|
class LocalCommand(commands.Command):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super(LocalCommand, self).__init__()
|
super(LocalCommand, self).__init__()
|
||||||
self.add_child('scan', ScanCommand())
|
self.add_child('scan', ScanCommand())
|
||||||
@ -61,7 +62,10 @@ class ScanCommand(commands.Command):
|
|||||||
super(ScanCommand, self).__init__()
|
super(ScanCommand, self).__init__()
|
||||||
self.add_argument('--limit',
|
self.add_argument('--limit',
|
||||||
action='store', type=int, dest='limit', default=None,
|
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):
|
def run(self, args, config):
|
||||||
media_dir = config['local']['media_dir']
|
media_dir = config['local']['media_dir']
|
||||||
@ -97,7 +101,7 @@ class ScanCommand(commands.Command):
|
|||||||
if mtime is None:
|
if mtime is None:
|
||||||
logger.debug('Missing file %s', track.uri)
|
logger.debug('Missing file %s', track.uri)
|
||||||
uris_to_remove.add(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_to_update.add(track.uri)
|
||||||
uris_in_library.add(track.uri)
|
uris_in_library.add(track.uri)
|
||||||
|
|
||||||
@ -130,7 +134,8 @@ class ScanCommand(commands.Command):
|
|||||||
try:
|
try:
|
||||||
relpath = translator.local_track_uri_to_path(uri, media_dir)
|
relpath = translator.local_track_uri_to_path(uri, media_dir)
|
||||||
file_uri = path.path_to_uri(os.path.join(media_dir, relpath))
|
file_uri = path.path_to_uri(os.path.join(media_dir, relpath))
|
||||||
tags, duration = scanner.scan(file_uri)
|
result = scanner.scan(file_uri)
|
||||||
|
tags, duration = result.tags, result.duration
|
||||||
if duration < MIN_DURATION_MS:
|
if duration < MIN_DURATION_MS:
|
||||||
logger.warning('Failed %s: Track shorter than %dms',
|
logger.warning('Failed %s: Track shorter than %dms',
|
||||||
uri, MIN_DURATION_MS)
|
uri, MIN_DURATION_MS)
|
||||||
@ -138,9 +143,10 @@ class ScanCommand(commands.Command):
|
|||||||
mtime = file_mtimes.get(os.path.join(media_dir, relpath))
|
mtime = file_mtimes.get(os.path.join(media_dir, relpath))
|
||||||
track = utils.convert_tags_to_track(tags).copy(
|
track = utils.convert_tags_to_track(tags).copy(
|
||||||
uri=uri, length=duration, last_modified=mtime)
|
uri=uri, length=duration, last_modified=mtime)
|
||||||
track = translator.add_musicbrainz_coverart_to_track(track)
|
if library.add_supports_tags_and_duration:
|
||||||
# TODO: add tags to call if library supports it.
|
library.add(track, tags=tags, duration=duration)
|
||||||
library.add(track)
|
else:
|
||||||
|
library.add(track)
|
||||||
logger.debug('Added %s', track.uri)
|
logger.debug('Added %s', track.uri)
|
||||||
except exceptions.ScannerError as error:
|
except exceptions.ScannerError as error:
|
||||||
logger.warning('Failed %s: %s', uri, error)
|
logger.warning('Failed %s: %s', uri, error)
|
||||||
@ -157,6 +163,7 @@ class ScanCommand(commands.Command):
|
|||||||
|
|
||||||
|
|
||||||
class _Progress(object):
|
class _Progress(object):
|
||||||
|
|
||||||
def __init__(self, batch_size, total):
|
def __init__(self, batch_size, total):
|
||||||
self.count = 0
|
self.count = 0
|
||||||
self.batch_size = batch_size
|
self.batch_size = batch_size
|
||||||
@ -173,6 +180,6 @@ class _Progress(object):
|
|||||||
logger.info('Scanned %d of %d files in %ds.',
|
logger.info('Scanned %d of %d files in %ds.',
|
||||||
self.count, self.total, duration)
|
self.count, self.total, duration)
|
||||||
else:
|
else:
|
||||||
remainder = duration // self.count * (self.total - self.count)
|
remainder = duration / self.count * (self.total - self.count)
|
||||||
logger.info('Scanned %d of %d files in %ds, ~%ds left.',
|
logger.info('Scanned %d of %d files in %ds, ~%ds left.',
|
||||||
self.count, self.total, duration, remainder)
|
self.count, self.total, duration, remainder)
|
||||||
|
|||||||
@ -3,7 +3,6 @@ enabled = true
|
|||||||
library = json
|
library = json
|
||||||
media_dir = $XDG_MUSIC_DIR
|
media_dir = $XDG_MUSIC_DIR
|
||||||
data_dir = $XDG_DATA_DIR/mopidy/local
|
data_dir = $XDG_DATA_DIR/mopidy/local
|
||||||
playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists
|
|
||||||
scan_timeout = 1000
|
scan_timeout = 1000
|
||||||
scan_flush_threshold = 1000
|
scan_flush_threshold = 1000
|
||||||
scan_follow_symlinks = false
|
scan_follow_symlinks = false
|
||||||
|
|||||||
@ -8,12 +8,11 @@ import os
|
|||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
|
||||||
|
|
||||||
import mopidy
|
import mopidy
|
||||||
from mopidy import compat, local, models
|
from mopidy import compat, local, models
|
||||||
from mopidy.local import search, storage, translator
|
from mopidy.local import search, storage, translator
|
||||||
from mopidy.utils import encoding
|
from mopidy.utils import encoding, timer
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -75,7 +74,7 @@ class _BrowseCache(object):
|
|||||||
parent_uri = None
|
parent_uri = None
|
||||||
child = None
|
child = None
|
||||||
for i in reversed(range(len(parts))):
|
for i in reversed(range(len(parts))):
|
||||||
directory = '/'.join(parts[:i+1])
|
directory = '/'.join(parts[:i + 1])
|
||||||
uri = translator.path_to_local_directory_uri(directory)
|
uri = translator.path_to_local_directory_uri(directory)
|
||||||
|
|
||||||
# First dir we process is our parent
|
# First dir we process is our parent
|
||||||
@ -109,20 +108,6 @@ class _BrowseCache(object):
|
|||||||
return self._cache.get(uri, {}).values()
|
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):
|
class JsonLibrary(local.Library):
|
||||||
name = 'json'
|
name = 'json'
|
||||||
|
|
||||||
@ -142,10 +127,10 @@ class JsonLibrary(local.Library):
|
|||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
logger.debug('Loading library: %s', self._json_file)
|
logger.debug('Loading library: %s', self._json_file)
|
||||||
with DebugTimer('Loading tracks'):
|
with timer.time_logger('Loading tracks'):
|
||||||
library = load_library(self._json_file)
|
library = load_library(self._json_file)
|
||||||
self._tracks = dict((t.uri, t) for t in library.get('tracks', []))
|
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()))
|
self._browse_cache = _BrowseCache(sorted(self._tracks.keys()))
|
||||||
return len(self._tracks)
|
return len(self._tracks)
|
||||||
|
|
||||||
@ -155,13 +140,47 @@ class JsonLibrary(local.Library):
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
return []
|
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):
|
def search(self, query=None, limit=100, offset=0, uris=None, exact=False):
|
||||||
tracks = self._tracks.values()
|
tracks = self._tracks.values()
|
||||||
# TODO: pass limit and offset into search helpers
|
|
||||||
if exact:
|
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:
|
else:
|
||||||
return search.search(tracks, query=query, uris=uris)
|
return search.search(
|
||||||
|
tracks, query=query, limit=limit, offset=offset, uris=uris)
|
||||||
|
|
||||||
def begin(self):
|
def begin(self):
|
||||||
return compat.itervalues(self._tracks)
|
return compat.itervalues(self._tracks)
|
||||||
|
|||||||
@ -8,6 +8,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class LocalLibraryProvider(backend.LibraryProvider):
|
class LocalLibraryProvider(backend.LibraryProvider):
|
||||||
|
|
||||||
"""Proxy library that delegates work to our active local library."""
|
"""Proxy library that delegates work to our active local library."""
|
||||||
|
|
||||||
root_directory = models.Ref.directory(
|
root_directory = models.Ref.directory(
|
||||||
@ -23,6 +24,16 @@ class LocalLibraryProvider(backend.LibraryProvider):
|
|||||||
return []
|
return []
|
||||||
return self._library.browse(uri)
|
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):
|
def refresh(self, uri=None):
|
||||||
if not self._library:
|
if not self._library:
|
||||||
return 0
|
return 0
|
||||||
@ -41,12 +52,7 @@ class LocalLibraryProvider(backend.LibraryProvider):
|
|||||||
tracks = [tracks]
|
tracks = [tracks]
|
||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
def find_exact(self, query=None, uris=None):
|
def search(self, query=None, uris=None, exact=False):
|
||||||
if not self._library:
|
if not self._library:
|
||||||
return None
|
return None
|
||||||
return self._library.search(query=query, uris=uris, exact=True)
|
return self._library.search(query=query, uris=uris, exact=exact)
|
||||||
|
|
||||||
def search(self, query=None, uris=None):
|
|
||||||
if not self._library:
|
|
||||||
return None
|
|
||||||
return self._library.search(query=query, uris=uris, exact=False)
|
|
||||||
|
|||||||
@ -1,16 +1,11 @@
|
|||||||
from __future__ import absolute_import, unicode_literals
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from mopidy import backend
|
from mopidy import backend
|
||||||
from mopidy.local import translator
|
from mopidy.local import translator
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class LocalPlaybackProvider(backend.PlaybackProvider):
|
class LocalPlaybackProvider(backend.PlaybackProvider):
|
||||||
def change_track(self, track):
|
|
||||||
track = track.copy(uri=translator.local_track_uri_to_file_uri(
|
def translate_uri(self, uri):
|
||||||
track.uri, self.backend.config['local']['media_dir']))
|
return translator.local_track_uri_to_file_uri(
|
||||||
return super(LocalPlaybackProvider, self).change_track(track)
|
uri, self.backend.config['local']['media_dir'])
|
||||||
|
|||||||
@ -1,122 +0,0 @@
|
|||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
|
|
||||||
import glob
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
from mopidy import backend
|
|
||||||
from mopidy.models import Playlist
|
|
||||||
from mopidy.utils import formatting, path
|
|
||||||
|
|
||||||
from .translator import parse_m3u
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class LocalPlaylistsProvider(backend.PlaylistsProvider):
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super(LocalPlaylistsProvider, self).__init__(*args, **kwargs)
|
|
||||||
self._media_dir = self.backend.config['local']['media_dir']
|
|
||||||
self._playlists_dir = self.backend.config['local']['playlists_dir']
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def create(self, name):
|
|
||||||
name = formatting.slugify(name)
|
|
||||||
uri = 'local:playlist:%s.m3u' % name
|
|
||||||
playlist = Playlist(uri=uri, name=name)
|
|
||||||
return self.save(playlist)
|
|
||||||
|
|
||||||
def delete(self, uri):
|
|
||||||
playlist = self.lookup(uri)
|
|
||||||
if not playlist:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._playlists.remove(playlist)
|
|
||||||
self._delete_m3u(playlist.uri)
|
|
||||||
|
|
||||||
def lookup(self, uri):
|
|
||||||
# TODO: store as {uri: playlist}?
|
|
||||||
for playlist in self._playlists:
|
|
||||||
if playlist.uri == uri:
|
|
||||||
return playlist
|
|
||||||
|
|
||||||
def refresh(self):
|
|
||||||
playlists = []
|
|
||||||
|
|
||||||
for m3u in glob.glob(os.path.join(self._playlists_dir, '*.m3u')):
|
|
||||||
name = os.path.splitext(os.path.basename(m3u))[0]
|
|
||||||
uri = 'local:playlist:%s' % name
|
|
||||||
|
|
||||||
tracks = []
|
|
||||||
for track in parse_m3u(m3u, self._media_dir):
|
|
||||||
tracks.append(track)
|
|
||||||
|
|
||||||
playlist = Playlist(uri=uri, name=name, tracks=tracks)
|
|
||||||
playlists.append(playlist)
|
|
||||||
|
|
||||||
self.playlists = playlists
|
|
||||||
# TODO: send what scheme we loaded them for?
|
|
||||||
backend.BackendListener.send('playlists_loaded')
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
'Loaded %d local playlists from %s',
|
|
||||||
len(playlists), self._playlists_dir)
|
|
||||||
|
|
||||||
def save(self, playlist):
|
|
||||||
assert playlist.uri, 'Cannot save playlist without URI'
|
|
||||||
|
|
||||||
old_playlist = self.lookup(playlist.uri)
|
|
||||||
|
|
||||||
if old_playlist and playlist.name != old_playlist.name:
|
|
||||||
playlist = playlist.copy(name=formatting.slugify(playlist.name))
|
|
||||||
playlist = self._rename_m3u(playlist)
|
|
||||||
|
|
||||||
self._save_m3u(playlist)
|
|
||||||
|
|
||||||
if old_playlist is not None:
|
|
||||||
index = self._playlists.index(old_playlist)
|
|
||||||
self._playlists[index] = playlist
|
|
||||||
else:
|
|
||||||
self._playlists.append(playlist)
|
|
||||||
|
|
||||||
return playlist
|
|
||||||
|
|
||||||
def _m3u_uri_to_path(self, uri):
|
|
||||||
# TODO: create uri handling helpers for local uri types.
|
|
||||||
file_path = path.uri_to_path(uri).split(':', 1)[1]
|
|
||||||
file_path = os.path.join(self._playlists_dir, file_path)
|
|
||||||
path.check_file_path_is_inside_base_dir(file_path, self._playlists_dir)
|
|
||||||
return file_path
|
|
||||||
|
|
||||||
def _write_m3u_extinf(self, file_handle, track):
|
|
||||||
title = track.name.encode('latin-1', 'replace')
|
|
||||||
runtime = track.length // 1000 if track.length else -1
|
|
||||||
file_handle.write('#EXTINF:' + str(runtime) + ',' + title + '\n')
|
|
||||||
|
|
||||||
def _save_m3u(self, playlist):
|
|
||||||
file_path = self._m3u_uri_to_path(playlist.uri)
|
|
||||||
extended = any(track.name for track in playlist.tracks)
|
|
||||||
with open(file_path, 'w') as file_handle:
|
|
||||||
if extended:
|
|
||||||
file_handle.write('#EXTM3U\n')
|
|
||||||
for track in playlist.tracks:
|
|
||||||
if extended and track.name:
|
|
||||||
self._write_m3u_extinf(file_handle, track)
|
|
||||||
file_handle.write(track.uri + '\n')
|
|
||||||
|
|
||||||
def _delete_m3u(self, uri):
|
|
||||||
file_path = self._m3u_uri_to_path(uri)
|
|
||||||
if os.path.exists(file_path):
|
|
||||||
os.remove(file_path)
|
|
||||||
|
|
||||||
def _rename_m3u(self, playlist):
|
|
||||||
dst_name = formatting.slugify(playlist.name)
|
|
||||||
dst_uri = 'local:playlist:%s.m3u' % dst_name
|
|
||||||
|
|
||||||
src_file_path = self._m3u_uri_to_path(playlist.uri)
|
|
||||||
dst_file_path = self._m3u_uri_to_path(dst_uri)
|
|
||||||
|
|
||||||
shutil.move(src_file_path, dst_file_path)
|
|
||||||
return playlist.copy(uri=dst_uri)
|
|
||||||
@ -3,7 +3,18 @@ from __future__ import absolute_import, unicode_literals
|
|||||||
from mopidy.models import SearchResult
|
from mopidy.models import SearchResult
|
||||||
|
|
||||||
|
|
||||||
def find_exact(tracks, query=None, uris=None):
|
def find_exact(tracks, query=None, limit=100, offset=0, uris=None):
|
||||||
|
"""
|
||||||
|
Filter a list of tracks where ``field`` is ``values``.
|
||||||
|
|
||||||
|
:param list tracks: a list of :class:`~mopidy.models.Track`
|
||||||
|
:param dict query: one or more field/value pairs to search for
|
||||||
|
:param int limit: maximum number of results to return
|
||||||
|
:param int offset: offset into result set to use.
|
||||||
|
:param uris: zero or more URI roots to limit the search to
|
||||||
|
:type uris: list of strings or :class:`None`
|
||||||
|
:rtype: :class:`~mopidy.models.SearchResult`
|
||||||
|
"""
|
||||||
# TODO Only return results within URI roots given by ``uris``
|
# TODO Only return results within URI roots given by ``uris``
|
||||||
|
|
||||||
if query is None:
|
if query is None:
|
||||||
@ -12,8 +23,6 @@ def find_exact(tracks, query=None, uris=None):
|
|||||||
_validate_query(query)
|
_validate_query(query)
|
||||||
|
|
||||||
for (field, values) in query.items():
|
for (field, values) in query.items():
|
||||||
if not hasattr(values, '__iter__'):
|
|
||||||
values = [values]
|
|
||||||
# FIXME this is bound to be slow for large libraries
|
# FIXME this is bound to be slow for large libraries
|
||||||
for value in values:
|
for value in values:
|
||||||
if field == 'track_no':
|
if field == 'track_no':
|
||||||
@ -21,37 +30,52 @@ def find_exact(tracks, query=None, uris=None):
|
|||||||
else:
|
else:
|
||||||
q = value.strip()
|
q = value.strip()
|
||||||
|
|
||||||
uri_filter = lambda t: q == t.uri
|
def uri_filter(t):
|
||||||
track_name_filter = lambda t: q == t.name
|
return q == t.uri
|
||||||
album_filter = lambda t: q == getattr(
|
|
||||||
getattr(t, 'album', None), 'name', None)
|
def track_name_filter(t):
|
||||||
artist_filter = lambda t: filter(
|
return q == t.name
|
||||||
lambda a: q == a.name, t.artists)
|
|
||||||
albumartist_filter = lambda t: any([
|
def album_filter(t):
|
||||||
q == a.name
|
return q == getattr(getattr(t, 'album', None), 'name', None)
|
||||||
for a in getattr(t.album, 'artists', [])])
|
|
||||||
composer_filter = lambda t: any([
|
def artist_filter(t):
|
||||||
q == a.name
|
return filter(lambda a: q == a.name, t.artists)
|
||||||
for a in getattr(t, 'composers', [])])
|
|
||||||
performer_filter = lambda t: any([
|
def albumartist_filter(t):
|
||||||
q == a.name
|
return any([
|
||||||
for a in getattr(t, 'performers', [])])
|
q == a.name for a in getattr(t.album, 'artists', [])])
|
||||||
track_no_filter = lambda t: q == t.track_no
|
|
||||||
genre_filter = lambda t: t.genre and q == t.genre
|
def composer_filter(t):
|
||||||
date_filter = lambda t: q == t.date
|
return any([q == a.name for a in getattr(t, 'composers', [])])
|
||||||
comment_filter = lambda t: q == t.comment
|
|
||||||
any_filter = lambda t: (
|
def performer_filter(t):
|
||||||
uri_filter(t) or
|
return any([q == a.name for a in getattr(t, 'performers', [])])
|
||||||
track_name_filter(t) or
|
|
||||||
album_filter(t) or
|
def track_no_filter(t):
|
||||||
artist_filter(t) or
|
return q == t.track_no
|
||||||
albumartist_filter(t) or
|
|
||||||
composer_filter(t) or
|
def genre_filter(t):
|
||||||
performer_filter(t) or
|
return (t.genre and q == t.genre)
|
||||||
track_no_filter(t) or
|
|
||||||
genre_filter(t) or
|
def date_filter(t):
|
||||||
date_filter(t) or
|
return q == t.date
|
||||||
comment_filter(t))
|
|
||||||
|
def comment_filter(t):
|
||||||
|
return q == t.comment
|
||||||
|
|
||||||
|
def any_filter(t):
|
||||||
|
return (uri_filter(t) or
|
||||||
|
track_name_filter(t) or
|
||||||
|
album_filter(t) or
|
||||||
|
artist_filter(t) or
|
||||||
|
albumartist_filter(t) or
|
||||||
|
composer_filter(t) or
|
||||||
|
performer_filter(t) or
|
||||||
|
track_no_filter(t) or
|
||||||
|
genre_filter(t) or
|
||||||
|
date_filter(t) or
|
||||||
|
comment_filter(t))
|
||||||
|
|
||||||
if field == 'uri':
|
if field == 'uri':
|
||||||
tracks = filter(uri_filter, tracks)
|
tracks = filter(uri_filter, tracks)
|
||||||
@ -80,11 +104,26 @@ def find_exact(tracks, query=None, uris=None):
|
|||||||
else:
|
else:
|
||||||
raise LookupError('Invalid lookup field: %s' % field)
|
raise LookupError('Invalid lookup field: %s' % field)
|
||||||
|
|
||||||
|
if limit is None:
|
||||||
|
tracks = tracks[offset:]
|
||||||
|
else:
|
||||||
|
tracks = tracks[offset:offset + limit]
|
||||||
# TODO: add local:search:<query>
|
# TODO: add local:search:<query>
|
||||||
return SearchResult(uri='local:search', tracks=tracks)
|
return SearchResult(uri='local:search', tracks=tracks)
|
||||||
|
|
||||||
|
|
||||||
def search(tracks, query=None, uris=None):
|
def search(tracks, query=None, limit=100, offset=0, uris=None):
|
||||||
|
"""
|
||||||
|
Filter a list of tracks where ``field`` is like ``values``.
|
||||||
|
|
||||||
|
:param list tracks: a list of :class:`~mopidy.models.Track`
|
||||||
|
:param dict query: one or more field/value pairs to search for
|
||||||
|
:param int limit: maximum number of results to return
|
||||||
|
:param int offset: offset into result set to use.
|
||||||
|
:param uris: zero or more URI roots to limit the search to
|
||||||
|
:type uris: list of strings or :class:`None`
|
||||||
|
:rtype: :class:`~mopidy.models.SearchResult`
|
||||||
|
"""
|
||||||
# TODO Only return results within URI roots given by ``uris``
|
# TODO Only return results within URI roots given by ``uris``
|
||||||
|
|
||||||
if query is None:
|
if query is None:
|
||||||
@ -93,8 +132,6 @@ def search(tracks, query=None, uris=None):
|
|||||||
_validate_query(query)
|
_validate_query(query)
|
||||||
|
|
||||||
for (field, values) in query.items():
|
for (field, values) in query.items():
|
||||||
if not hasattr(values, '__iter__'):
|
|
||||||
values = [values]
|
|
||||||
# FIXME this is bound to be slow for large libraries
|
# FIXME this is bound to be slow for large libraries
|
||||||
for value in values:
|
for value in values:
|
||||||
if field == 'track_no':
|
if field == 'track_no':
|
||||||
@ -102,38 +139,56 @@ def search(tracks, query=None, uris=None):
|
|||||||
else:
|
else:
|
||||||
q = value.strip().lower()
|
q = value.strip().lower()
|
||||||
|
|
||||||
uri_filter = lambda t: bool(t.uri and q in t.uri.lower())
|
def uri_filter(t):
|
||||||
track_name_filter = lambda t: bool(t.name and q in t.name.lower())
|
return bool(t.uri and q in t.uri.lower())
|
||||||
album_filter = lambda t: bool(
|
|
||||||
t.album and t.album.name and q in t.album.name.lower())
|
def track_name_filter(t):
|
||||||
artist_filter = lambda t: bool(filter(
|
return bool(t.name and q in t.name.lower())
|
||||||
lambda a: bool(a.name and q in a.name.lower()), t.artists))
|
|
||||||
albumartist_filter = lambda t: any([
|
def album_filter(t):
|
||||||
a.name and q in a.name.lower()
|
return bool(t.album and t.album.name and
|
||||||
for a in getattr(t.album, 'artists', [])])
|
q in t.album.name.lower())
|
||||||
composer_filter = lambda t: any([
|
|
||||||
a.name and q in a.name.lower()
|
def artist_filter(t):
|
||||||
for a in getattr(t, 'composers', [])])
|
return bool(filter(
|
||||||
performer_filter = lambda t: any([
|
lambda a: bool(a.name and q in a.name.lower()), t.artists))
|
||||||
a.name and q in a.name.lower()
|
|
||||||
for a in getattr(t, 'performers', [])])
|
def albumartist_filter(t):
|
||||||
track_no_filter = lambda t: q == t.track_no
|
return any([a.name and q in a.name.lower()
|
||||||
genre_filter = lambda t: bool(t.genre and q in t.genre.lower())
|
for a in getattr(t.album, 'artists', [])])
|
||||||
date_filter = lambda t: bool(t.date and t.date.startswith(q))
|
|
||||||
comment_filter = lambda t: bool(
|
def composer_filter(t):
|
||||||
t.comment and q in t.comment.lower())
|
return any([a.name and q in a.name.lower()
|
||||||
any_filter = lambda t: (
|
for a in getattr(t, 'composers', [])])
|
||||||
uri_filter(t) or
|
|
||||||
track_name_filter(t) or
|
def performer_filter(t):
|
||||||
album_filter(t) or
|
return any([a.name and q in a.name.lower()
|
||||||
artist_filter(t) or
|
for a in getattr(t, 'performers', [])])
|
||||||
albumartist_filter(t) or
|
|
||||||
composer_filter(t) or
|
def track_no_filter(t):
|
||||||
performer_filter(t) or
|
return q == t.track_no
|
||||||
track_no_filter(t) or
|
|
||||||
genre_filter(t) or
|
def genre_filter(t):
|
||||||
date_filter(t) or
|
return bool(t.genre and q in t.genre.lower())
|
||||||
comment_filter(t))
|
|
||||||
|
def date_filter(t):
|
||||||
|
return bool(t.date and t.date.startswith(q))
|
||||||
|
|
||||||
|
def comment_filter(t):
|
||||||
|
return bool(t.comment and q in t.comment.lower())
|
||||||
|
|
||||||
|
def any_filter(t):
|
||||||
|
return (uri_filter(t) or
|
||||||
|
track_name_filter(t) or
|
||||||
|
album_filter(t) or
|
||||||
|
artist_filter(t) or
|
||||||
|
albumartist_filter(t) or
|
||||||
|
composer_filter(t) or
|
||||||
|
performer_filter(t) or
|
||||||
|
track_no_filter(t) or
|
||||||
|
genre_filter(t) or
|
||||||
|
date_filter(t) or
|
||||||
|
comment_filter(t))
|
||||||
|
|
||||||
if field == 'uri':
|
if field == 'uri':
|
||||||
tracks = filter(uri_filter, tracks)
|
tracks = filter(uri_filter, tracks)
|
||||||
@ -161,6 +216,11 @@ def search(tracks, query=None, uris=None):
|
|||||||
tracks = filter(any_filter, tracks)
|
tracks = filter(any_filter, tracks)
|
||||||
else:
|
else:
|
||||||
raise LookupError('Invalid lookup field: %s' % field)
|
raise LookupError('Invalid lookup field: %s' % field)
|
||||||
|
|
||||||
|
if limit is None:
|
||||||
|
tracks = tracks[offset:]
|
||||||
|
else:
|
||||||
|
tracks = tracks[offset:offset + limit]
|
||||||
# TODO: add local:search:<query>
|
# TODO: add local:search:<query>
|
||||||
return SearchResult(uri='local:search', tracks=tracks)
|
return SearchResult(uri='local:search', tracks=tracks)
|
||||||
|
|
||||||
|
|||||||
@ -20,11 +20,3 @@ def check_dirs_and_files(config):
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
'Could not create local data dir: %s',
|
'Could not create local data dir: %s',
|
||||||
encoding.locale_decode(error))
|
encoding.locale_decode(error))
|
||||||
|
|
||||||
# TODO: replace with data dir?
|
|
||||||
try:
|
|
||||||
path.get_or_create_dir(config['local']['playlists_dir'])
|
|
||||||
except EnvironmentError as error:
|
|
||||||
logger.warning(
|
|
||||||
'Could not create local playlists dir: %s',
|
|
||||||
encoding.locale_decode(error))
|
|
||||||
|
|||||||
@ -2,30 +2,15 @@ from __future__ import absolute_import, unicode_literals
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import urllib
|
import urllib
|
||||||
import urlparse
|
|
||||||
|
|
||||||
from mopidy import compat
|
from mopidy import compat
|
||||||
from mopidy.models import Track
|
|
||||||
from mopidy.utils.encoding import locale_decode
|
|
||||||
from mopidy.utils.path import path_to_uri, uri_to_path
|
from mopidy.utils.path import path_to_uri, uri_to_path
|
||||||
|
|
||||||
|
|
||||||
M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)')
|
|
||||||
COVERART_BASE = 'http://coverartarchive.org/release/%s/front'
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def add_musicbrainz_coverart_to_track(track):
|
|
||||||
if track.album and track.album.musicbrainz_id:
|
|
||||||
images = [COVERART_BASE % track.album.musicbrainz_id]
|
|
||||||
album = track.album.copy(images=images)
|
|
||||||
track = track.copy(album=album)
|
|
||||||
return track
|
|
||||||
|
|
||||||
|
|
||||||
def local_track_uri_to_file_uri(uri, media_dir):
|
def local_track_uri_to_file_uri(uri, media_dir):
|
||||||
return path_to_uri(local_track_uri_to_path(uri, media_dir))
|
return path_to_uri(local_track_uri_to_path(uri, media_dir))
|
||||||
|
|
||||||
@ -38,7 +23,7 @@ def local_track_uri_to_path(uri, media_dir):
|
|||||||
|
|
||||||
|
|
||||||
def path_to_local_track_uri(relpath):
|
def path_to_local_track_uri(relpath):
|
||||||
"""Convert path releative to media_dir to local track URI."""
|
"""Convert path relative to media_dir to local track URI."""
|
||||||
if isinstance(relpath, compat.text_type):
|
if isinstance(relpath, compat.text_type):
|
||||||
relpath = relpath.encode('utf-8')
|
relpath = relpath.encode('utf-8')
|
||||||
return b'local:track:%s' % urllib.quote(relpath)
|
return b'local:track:%s' % urllib.quote(relpath)
|
||||||
@ -49,82 +34,3 @@ def path_to_local_directory_uri(relpath):
|
|||||||
if isinstance(relpath, compat.text_type):
|
if isinstance(relpath, compat.text_type):
|
||||||
relpath = relpath.encode('utf-8')
|
relpath = relpath.encode('utf-8')
|
||||||
return b'local:directory:%s' % urllib.quote(relpath)
|
return b'local:directory:%s' % urllib.quote(relpath)
|
||||||
|
|
||||||
|
|
||||||
def m3u_extinf_to_track(line):
|
|
||||||
"""Convert extended M3U directive to track template."""
|
|
||||||
m = M3U_EXTINF_RE.match(line)
|
|
||||||
if not m:
|
|
||||||
logger.warning('Invalid extended M3U directive: %s', line)
|
|
||||||
return Track()
|
|
||||||
(runtime, title) = m.groups()
|
|
||||||
if int(runtime) > 0:
|
|
||||||
return Track(name=title, length=1000*int(runtime))
|
|
||||||
else:
|
|
||||||
return Track(name=title)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_m3u(file_path, media_dir):
|
|
||||||
r"""
|
|
||||||
Convert M3U file list to list of tracks
|
|
||||||
|
|
||||||
Example M3U data::
|
|
||||||
|
|
||||||
# This is a comment
|
|
||||||
Alternative\Band - Song.mp3
|
|
||||||
Classical\Other Band - New Song.mp3
|
|
||||||
Stuff.mp3
|
|
||||||
D:\More Music\Foo.mp3
|
|
||||||
http://www.example.com:8000/Listen.pls
|
|
||||||
http://www.example.com/~user/Mine.mp3
|
|
||||||
|
|
||||||
Example extended M3U data::
|
|
||||||
|
|
||||||
#EXTM3U
|
|
||||||
#EXTINF:123, Sample artist - Sample title
|
|
||||||
Sample.mp3
|
|
||||||
#EXTINF:321,Example Artist - Example title
|
|
||||||
Greatest Hits\Example.ogg
|
|
||||||
#EXTINF:-1,Radio XMP
|
|
||||||
http://mp3stream.example.com:8000/
|
|
||||||
|
|
||||||
- Relative paths of songs should be with respect to location of M3U.
|
|
||||||
- Paths are normally platform specific.
|
|
||||||
- Lines starting with # are ignored, except for extended M3U directives.
|
|
||||||
- Track.name and Track.length are set from extended M3U directives.
|
|
||||||
- m3u files are latin-1.
|
|
||||||
"""
|
|
||||||
# TODO: uris as bytes
|
|
||||||
tracks = []
|
|
||||||
try:
|
|
||||||
with open(file_path) as m3u:
|
|
||||||
contents = m3u.readlines()
|
|
||||||
except IOError as error:
|
|
||||||
logger.warning('Couldn\'t open m3u: %s', locale_decode(error))
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
if not contents:
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
extended = contents[0].decode('latin1').startswith('#EXTM3U')
|
|
||||||
|
|
||||||
track = Track()
|
|
||||||
for line in contents:
|
|
||||||
line = line.strip().decode('latin1')
|
|
||||||
|
|
||||||
if line.startswith('#'):
|
|
||||||
if extended and line.startswith('#EXTINF'):
|
|
||||||
track = m3u_extinf_to_track(line)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if urlparse.urlsplit(line).scheme:
|
|
||||||
tracks.append(track.copy(uri=line))
|
|
||||||
elif os.path.normpath(line) == os.path.abspath(line):
|
|
||||||
path = path_to_uri(line)
|
|
||||||
tracks.append(track.copy(uri=path))
|
|
||||||
else:
|
|
||||||
path = path_to_uri(os.path.join(media_dir, line))
|
|
||||||
tracks.append(track.copy(uri=path))
|
|
||||||
|
|
||||||
track = Track()
|
|
||||||
return tracks
|
|
||||||
|
|||||||
30
mopidy/m3u/__init__.py
Normal file
30
mopidy/m3u/__init__.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import mopidy
|
||||||
|
from mopidy import config, ext
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Extension(ext.Extension):
|
||||||
|
|
||||||
|
dist_name = 'Mopidy-M3U'
|
||||||
|
ext_name = 'm3u'
|
||||||
|
version = mopidy.__version__
|
||||||
|
|
||||||
|
def get_default_config(self):
|
||||||
|
conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf')
|
||||||
|
return config.read(conf_file)
|
||||||
|
|
||||||
|
def get_config_schema(self):
|
||||||
|
schema = super(Extension, self).get_config_schema()
|
||||||
|
schema['playlists_dir'] = config.Path()
|
||||||
|
return schema
|
||||||
|
|
||||||
|
def setup(self, registry):
|
||||||
|
from .actor import M3UBackend
|
||||||
|
|
||||||
|
registry.add('backend', M3UBackend)
|
||||||
32
mopidy/m3u/actor.py
Normal file
32
mopidy/m3u/actor.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pykka
|
||||||
|
|
||||||
|
from mopidy import backend
|
||||||
|
from mopidy.m3u.library import M3ULibraryProvider
|
||||||
|
from mopidy.m3u.playlists import M3UPlaylistsProvider
|
||||||
|
from mopidy.utils import encoding, path
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class M3UBackend(pykka.ThreadingActor, backend.Backend):
|
||||||
|
uri_schemes = ['m3u']
|
||||||
|
|
||||||
|
def __init__(self, config, audio):
|
||||||
|
super(M3UBackend, self).__init__()
|
||||||
|
|
||||||
|
self._config = config
|
||||||
|
|
||||||
|
try:
|
||||||
|
path.get_or_create_dir(config['m3u']['playlists_dir'])
|
||||||
|
except EnvironmentError as error:
|
||||||
|
logger.warning(
|
||||||
|
'Could not create M3U playlists dir: %s',
|
||||||
|
encoding.locale_decode(error))
|
||||||
|
|
||||||
|
self.playlists = M3UPlaylistsProvider(backend=self)
|
||||||
|
self.library = M3ULibraryProvider(backend=self)
|
||||||
3
mopidy/m3u/ext.conf
Normal file
3
mopidy/m3u/ext.conf
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[m3u]
|
||||||
|
enabled = true
|
||||||
|
playlists_dir = $XDG_DATA_DIR/mopidy/m3u
|
||||||
19
mopidy/m3u/library.py
Normal file
19
mopidy/m3u/library.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mopidy import backend
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class M3ULibraryProvider(backend.LibraryProvider):
|
||||||
|
|
||||||
|
"""Library for looking up M3U playlists."""
|
||||||
|
|
||||||
|
def __init__(self, backend):
|
||||||
|
super(M3ULibraryProvider, self).__init__(backend)
|
||||||
|
|
||||||
|
def lookup(self, uri):
|
||||||
|
# TODO Lookup tracks in M3U playlist
|
||||||
|
return []
|
||||||
127
mopidy/m3u/playlists.py
Normal file
127
mopidy/m3u/playlists.py
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import logging
|
||||||
|
import operator
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from mopidy import backend
|
||||||
|
from mopidy.m3u import translator
|
||||||
|
from mopidy.models import Playlist, Ref
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class M3UPlaylistsProvider(backend.PlaylistsProvider):
|
||||||
|
|
||||||
|
# TODO: currently this only handles UNIX file systems
|
||||||
|
_invalid_filename_chars = re.compile(r'[/]')
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(M3UPlaylistsProvider, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self._playlists_dir = self.backend._config['m3u']['playlists_dir']
|
||||||
|
self._playlists = {}
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def as_list(self):
|
||||||
|
refs = [
|
||||||
|
Ref.playlist(uri=pl.uri, name=pl.name)
|
||||||
|
for pl in self._playlists.values()]
|
||||||
|
return sorted(refs, key=operator.attrgetter('name'))
|
||||||
|
|
||||||
|
def get_items(self, uri):
|
||||||
|
playlist = self._playlists.get(uri)
|
||||||
|
if playlist is None:
|
||||||
|
return None
|
||||||
|
return [Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks]
|
||||||
|
|
||||||
|
def create(self, name):
|
||||||
|
playlist = self._save_m3u(Playlist(name=name))
|
||||||
|
self._playlists[playlist.uri] = playlist
|
||||||
|
logger.info('Created playlist %s', playlist.uri)
|
||||||
|
return playlist
|
||||||
|
|
||||||
|
def delete(self, uri):
|
||||||
|
if uri in self._playlists:
|
||||||
|
path = translator.playlist_uri_to_path(uri, self._playlists_dir)
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
else:
|
||||||
|
logger.warn('Trying to delete missing playlist file %s', path)
|
||||||
|
del self._playlists[uri]
|
||||||
|
else:
|
||||||
|
logger.warn('Trying to delete unknown playlist %s', uri)
|
||||||
|
|
||||||
|
def lookup(self, uri):
|
||||||
|
return self._playlists.get(uri)
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
playlists = {}
|
||||||
|
|
||||||
|
encoding = sys.getfilesystemencoding()
|
||||||
|
for path in glob.glob(os.path.join(self._playlists_dir, b'*.m3u')):
|
||||||
|
relpath = os.path.basename(path)
|
||||||
|
uri = translator.path_to_playlist_uri(relpath)
|
||||||
|
name = os.path.splitext(relpath)[0].decode(encoding)
|
||||||
|
tracks = translator.parse_m3u(path)
|
||||||
|
playlists[uri] = Playlist(uri=uri, name=name, tracks=tracks)
|
||||||
|
|
||||||
|
self._playlists = playlists
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'Loaded %d M3U playlists from %s',
|
||||||
|
len(playlists), self._playlists_dir)
|
||||||
|
|
||||||
|
def save(self, playlist):
|
||||||
|
assert playlist.uri, 'Cannot save playlist without URI'
|
||||||
|
assert playlist.uri in self._playlists, \
|
||||||
|
'Cannot save playlist with unknown URI: %s' % playlist.uri
|
||||||
|
|
||||||
|
original_uri = playlist.uri
|
||||||
|
playlist = self._save_m3u(playlist)
|
||||||
|
if playlist.uri != original_uri and original_uri in self._playlists:
|
||||||
|
self.delete(original_uri)
|
||||||
|
self._playlists[playlist.uri] = playlist
|
||||||
|
return playlist
|
||||||
|
|
||||||
|
def _write_m3u_extinf(self, file_handle, track):
|
||||||
|
title = track.name.encode('latin-1', 'replace')
|
||||||
|
runtime = track.length // 1000 if track.length else -1
|
||||||
|
file_handle.write('#EXTINF:' + str(runtime) + ',' + title + '\n')
|
||||||
|
|
||||||
|
def _sanitize_m3u_name(self, name, encoding=sys.getfilesystemencoding()):
|
||||||
|
name = self._invalid_filename_chars.sub('|', name.strip())
|
||||||
|
# make sure we end up with a valid path segment
|
||||||
|
name = name.encode(encoding, errors='replace')
|
||||||
|
name = os.path.basename(name) # paranoia?
|
||||||
|
name = name.decode(encoding)
|
||||||
|
return name
|
||||||
|
|
||||||
|
def _save_m3u(self, playlist, encoding=sys.getfilesystemencoding()):
|
||||||
|
if playlist.name:
|
||||||
|
name = self._sanitize_m3u_name(playlist.name, encoding)
|
||||||
|
uri = translator.path_to_playlist_uri(
|
||||||
|
name.encode(encoding) + b'.m3u')
|
||||||
|
path = translator.playlist_uri_to_path(uri, self._playlists_dir)
|
||||||
|
elif playlist.uri:
|
||||||
|
uri = playlist.uri
|
||||||
|
path = translator.playlist_uri_to_path(uri, self._playlists_dir)
|
||||||
|
name, _ = os.path.splitext(os.path.basename(path).decode(encoding))
|
||||||
|
else:
|
||||||
|
raise ValueError('M3U playlist needs name or URI')
|
||||||
|
extended = any(track.name for track in playlist.tracks)
|
||||||
|
|
||||||
|
with open(path, 'w') as file_handle:
|
||||||
|
if extended:
|
||||||
|
file_handle.write('#EXTM3U\n')
|
||||||
|
for track in playlist.tracks:
|
||||||
|
if extended and track.name:
|
||||||
|
self._write_m3u_extinf(file_handle, track)
|
||||||
|
file_handle.write(track.uri + '\n')
|
||||||
|
|
||||||
|
# assert playlist name matches file name/uri
|
||||||
|
return playlist.copy(uri=uri, name=name)
|
||||||
110
mopidy/m3u/translator.py
Normal file
110
mopidy/m3u/translator.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import urllib
|
||||||
|
import urlparse
|
||||||
|
|
||||||
|
from mopidy import compat
|
||||||
|
from mopidy.models import Track
|
||||||
|
from mopidy.utils.encoding import locale_decode
|
||||||
|
from mopidy.utils.path import path_to_uri, uri_to_path
|
||||||
|
|
||||||
|
|
||||||
|
M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)')
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def playlist_uri_to_path(uri, playlists_dir):
|
||||||
|
if not uri.startswith('m3u:'):
|
||||||
|
raise ValueError('Invalid URI %s' % uri)
|
||||||
|
file_path = uri_to_path(uri)
|
||||||
|
return os.path.join(playlists_dir, file_path)
|
||||||
|
|
||||||
|
|
||||||
|
def path_to_playlist_uri(relpath):
|
||||||
|
"""Convert path relative to playlists_dir to M3U URI."""
|
||||||
|
if isinstance(relpath, compat.text_type):
|
||||||
|
relpath = relpath.encode('utf-8')
|
||||||
|
return b'm3u:%s' % urllib.quote(relpath)
|
||||||
|
|
||||||
|
|
||||||
|
def m3u_extinf_to_track(line):
|
||||||
|
"""Convert extended M3U directive to track template."""
|
||||||
|
m = M3U_EXTINF_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
logger.warning('Invalid extended M3U directive: %s', line)
|
||||||
|
return Track()
|
||||||
|
(runtime, title) = m.groups()
|
||||||
|
if int(runtime) > 0:
|
||||||
|
return Track(name=title, length=1000 * int(runtime))
|
||||||
|
else:
|
||||||
|
return Track(name=title)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_m3u(file_path, media_dir=None):
|
||||||
|
r"""
|
||||||
|
Convert M3U file list to list of tracks
|
||||||
|
|
||||||
|
Example M3U data::
|
||||||
|
|
||||||
|
# This is a comment
|
||||||
|
Alternative\Band - Song.mp3
|
||||||
|
Classical\Other Band - New Song.mp3
|
||||||
|
Stuff.mp3
|
||||||
|
D:\More Music\Foo.mp3
|
||||||
|
http://www.example.com:8000/Listen.pls
|
||||||
|
http://www.example.com/~user/Mine.mp3
|
||||||
|
|
||||||
|
Example extended M3U data::
|
||||||
|
|
||||||
|
#EXTM3U
|
||||||
|
#EXTINF:123, Sample artist - Sample title
|
||||||
|
Sample.mp3
|
||||||
|
#EXTINF:321,Example Artist - Example title
|
||||||
|
Greatest Hits\Example.ogg
|
||||||
|
#EXTINF:-1,Radio XMP
|
||||||
|
http://mp3stream.example.com:8000/
|
||||||
|
|
||||||
|
- Relative paths of songs should be with respect to location of M3U.
|
||||||
|
- Paths are normally platform specific.
|
||||||
|
- Lines starting with # are ignored, except for extended M3U directives.
|
||||||
|
- Track.name and Track.length are set from extended M3U directives.
|
||||||
|
- m3u files are latin-1.
|
||||||
|
"""
|
||||||
|
# TODO: uris as bytes
|
||||||
|
tracks = []
|
||||||
|
try:
|
||||||
|
with open(file_path) as m3u:
|
||||||
|
contents = m3u.readlines()
|
||||||
|
except IOError as error:
|
||||||
|
logger.warning('Couldn\'t open m3u: %s', locale_decode(error))
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
if not contents:
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
extended = contents[0].decode('latin1').startswith('#EXTM3U')
|
||||||
|
|
||||||
|
track = Track()
|
||||||
|
for line in contents:
|
||||||
|
line = line.strip().decode('latin1')
|
||||||
|
|
||||||
|
if line.startswith('#'):
|
||||||
|
if extended and line.startswith('#EXTINF'):
|
||||||
|
track = m3u_extinf_to_track(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if urlparse.urlsplit(line).scheme:
|
||||||
|
tracks.append(track.copy(uri=line))
|
||||||
|
elif os.path.normpath(line) == os.path.abspath(line):
|
||||||
|
path = path_to_uri(line)
|
||||||
|
tracks.append(track.copy(uri=path))
|
||||||
|
elif media_dir is not None:
|
||||||
|
path = path_to_uri(os.path.join(media_dir, line))
|
||||||
|
tracks.append(track.copy(uri=path))
|
||||||
|
|
||||||
|
track = Track()
|
||||||
|
return tracks
|
||||||
@ -9,6 +9,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class Mixer(object):
|
class Mixer(object):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Audio mixer API
|
Audio mixer API
|
||||||
|
|
||||||
@ -111,6 +112,7 @@ class Mixer(object):
|
|||||||
|
|
||||||
|
|
||||||
class MixerListener(listener.Listener):
|
class MixerListener(listener.Listener):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Marker interface for recipients of events sent by the mixer actor.
|
Marker interface for recipients of events sent by the mixer actor.
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import json
|
|||||||
|
|
||||||
|
|
||||||
class ImmutableObject(object):
|
class ImmutableObject(object):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Superclass for immutable objects whose fields can only be modified via the
|
Superclass for immutable objects whose fields can only be modified via the
|
||||||
constructor.
|
constructor.
|
||||||
@ -102,6 +103,7 @@ class ImmutableObject(object):
|
|||||||
|
|
||||||
|
|
||||||
class ModelJSONEncoder(json.JSONEncoder):
|
class ModelJSONEncoder(json.JSONEncoder):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Automatically serialize Mopidy models to JSON.
|
Automatically serialize Mopidy models to JSON.
|
||||||
|
|
||||||
@ -112,6 +114,7 @@ class ModelJSONEncoder(json.JSONEncoder):
|
|||||||
'{"a_track": {"__model__": "Track", "name": "name"}}'
|
'{"a_track": {"__model__": "Track", "name": "name"}}'
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def default(self, obj):
|
def default(self, obj):
|
||||||
if isinstance(obj, ImmutableObject):
|
if isinstance(obj, ImmutableObject):
|
||||||
return obj.serialize()
|
return obj.serialize()
|
||||||
@ -143,6 +146,7 @@ def model_json_decoder(dct):
|
|||||||
|
|
||||||
|
|
||||||
class Ref(ImmutableObject):
|
class Ref(ImmutableObject):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Model to represent URI references with a human friendly name and type
|
Model to represent URI references with a human friendly name and type
|
||||||
attached. This is intended for use a lightweight object "free" of metadata
|
attached. This is intended for use a lightweight object "free" of metadata
|
||||||
@ -153,7 +157,7 @@ class Ref(ImmutableObject):
|
|||||||
:param name: object name
|
:param name: object name
|
||||||
:type name: string
|
:type name: string
|
||||||
:param type: object type
|
:param type: object type
|
||||||
:type name: string
|
:type type: string
|
||||||
"""
|
"""
|
||||||
|
|
||||||
#: The object URI. Read-only.
|
#: The object URI. Read-only.
|
||||||
@ -212,7 +216,26 @@ class Ref(ImmutableObject):
|
|||||||
return cls(**kwargs)
|
return cls(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class Image(ImmutableObject):
|
||||||
|
|
||||||
|
"""
|
||||||
|
:param string uri: URI of the image
|
||||||
|
:param int width: Optional width of image or :class:`None`
|
||||||
|
:param int height: Optional height of image or :class:`None`
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: The image URI. Read-only.
|
||||||
|
uri = None
|
||||||
|
|
||||||
|
#: Optional width of the image or :class:`None`. Read-only.
|
||||||
|
width = None
|
||||||
|
|
||||||
|
#: Optional height of the image or :class:`None`. Read-only.
|
||||||
|
height = None
|
||||||
|
|
||||||
|
|
||||||
class Artist(ImmutableObject):
|
class Artist(ImmutableObject):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
:param uri: artist URI
|
:param uri: artist URI
|
||||||
:type uri: string
|
:type uri: string
|
||||||
@ -233,6 +256,7 @@ class Artist(ImmutableObject):
|
|||||||
|
|
||||||
|
|
||||||
class Album(ImmutableObject):
|
class Album(ImmutableObject):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
:param uri: album URI
|
:param uri: album URI
|
||||||
:type uri: string
|
:type uri: string
|
||||||
@ -286,6 +310,7 @@ class Album(ImmutableObject):
|
|||||||
|
|
||||||
|
|
||||||
class Track(ImmutableObject):
|
class Track(ImmutableObject):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
:param uri: track URI
|
:param uri: track URI
|
||||||
:type uri: string
|
:type uri: string
|
||||||
@ -308,7 +333,7 @@ class Track(ImmutableObject):
|
|||||||
:param date: track release date (YYYY or YYYY-MM-DD)
|
:param date: track release date (YYYY or YYYY-MM-DD)
|
||||||
:type date: string
|
:type date: string
|
||||||
:param length: track length in milliseconds
|
:param length: track length in milliseconds
|
||||||
:type length: integer
|
:type length: integer or :class:`None` if there is no duration
|
||||||
:param bitrate: bitrate in kbit/s
|
:param bitrate: bitrate in kbit/s
|
||||||
:type bitrate: integer
|
:type bitrate: integer
|
||||||
:param comment: track comment
|
:param comment: track comment
|
||||||
@ -361,13 +386,16 @@ class Track(ImmutableObject):
|
|||||||
#: The MusicBrainz ID of the track. Read-only.
|
#: The MusicBrainz ID of the track. Read-only.
|
||||||
musicbrainz_id = None
|
musicbrainz_id = None
|
||||||
|
|
||||||
#: Integer representing when the track was last modified, exact meaning
|
#: Integer representing when the track was last modified. Exact meaning
|
||||||
#: depends on source of track. For local files this is the mtime, for other
|
#: depends on source of track. For local files this is the modification
|
||||||
#: backends it could be a timestamp or simply a version counter.
|
#: time in milliseconds since Unix epoch. For other backends it could be an
|
||||||
|
#: equivalent timestamp or simply a version counter.
|
||||||
last_modified = None
|
last_modified = None
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
get = lambda key: frozenset(kwargs.pop(key, None) or [])
|
def get(key):
|
||||||
|
return frozenset(kwargs.pop(key, None) or [])
|
||||||
|
|
||||||
self.__dict__['artists'] = get('artists')
|
self.__dict__['artists'] = get('artists')
|
||||||
self.__dict__['composers'] = get('composers')
|
self.__dict__['composers'] = get('composers')
|
||||||
self.__dict__['performers'] = get('performers')
|
self.__dict__['performers'] = get('performers')
|
||||||
@ -375,6 +403,7 @@ class Track(ImmutableObject):
|
|||||||
|
|
||||||
|
|
||||||
class TlTrack(ImmutableObject):
|
class TlTrack(ImmutableObject):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
A tracklist track. Wraps a regular track and it's tracklist ID.
|
A tracklist track. Wraps a regular track and it's tracklist ID.
|
||||||
|
|
||||||
@ -413,6 +442,7 @@ class TlTrack(ImmutableObject):
|
|||||||
|
|
||||||
|
|
||||||
class Playlist(ImmutableObject):
|
class Playlist(ImmutableObject):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
:param uri: playlist URI
|
:param uri: playlist URI
|
||||||
:type uri: string
|
:type uri: string
|
||||||
@ -453,6 +483,7 @@ class Playlist(ImmutableObject):
|
|||||||
|
|
||||||
|
|
||||||
class SearchResult(ImmutableObject):
|
class SearchResult(ImmutableObject):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
:param uri: search result URI
|
:param uri: search result URI
|
||||||
:type uri: string
|
:type uri: string
|
||||||
|
|||||||
@ -24,6 +24,7 @@ class Extension(ext.Extension):
|
|||||||
schema['max_connections'] = config.Integer(minimum=1)
|
schema['max_connections'] = config.Integer(minimum=1)
|
||||||
schema['connection_timeout'] = config.Integer(minimum=1)
|
schema['connection_timeout'] = config.Integer(minimum=1)
|
||||||
schema['zeroconf'] = config.String(optional=True)
|
schema['zeroconf'] = config.String(optional=True)
|
||||||
|
schema['command_blacklist'] = config.List(optional=True)
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
def validate_environment(self):
|
def validate_environment(self):
|
||||||
|
|||||||
@ -6,18 +6,20 @@ import pykka
|
|||||||
|
|
||||||
from mopidy import exceptions, zeroconf
|
from mopidy import exceptions, zeroconf
|
||||||
from mopidy.core import CoreListener
|
from mopidy.core import CoreListener
|
||||||
from mopidy.mpd import session
|
from mopidy.mpd import session, uri_mapper
|
||||||
from mopidy.utils import encoding, network, process
|
from mopidy.utils import encoding, network, process
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MpdFrontend(pykka.ThreadingActor, CoreListener):
|
class MpdFrontend(pykka.ThreadingActor, CoreListener):
|
||||||
|
|
||||||
def __init__(self, config, core):
|
def __init__(self, config, core):
|
||||||
super(MpdFrontend, self).__init__()
|
super(MpdFrontend, self).__init__()
|
||||||
|
|
||||||
self.hostname = network.format_hostname(config['mpd']['hostname'])
|
self.hostname = network.format_hostname(config['mpd']['hostname'])
|
||||||
self.port = config['mpd']['port']
|
self.port = config['mpd']['port']
|
||||||
|
self.uri_map = uri_mapper.MpdUriMapper(core)
|
||||||
|
|
||||||
self.zeroconf_name = config['mpd']['zeroconf']
|
self.zeroconf_name = config['mpd']['zeroconf']
|
||||||
self.zeroconf_service = None
|
self.zeroconf_service = None
|
||||||
@ -29,6 +31,7 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
|
|||||||
protocol_kwargs={
|
protocol_kwargs={
|
||||||
'config': config,
|
'config': config,
|
||||||
'core': core,
|
'core': core,
|
||||||
|
'uri_map': self.uri_map,
|
||||||
},
|
},
|
||||||
max_connections=config['mpd']['max_connections'],
|
max_connections=config['mpd']['max_connections'],
|
||||||
timeout=config['mpd']['connection_timeout'])
|
timeout=config['mpd']['connection_timeout'])
|
||||||
@ -71,3 +74,6 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
|
|||||||
|
|
||||||
def mute_changed(self, mute):
|
def mute_changed(self, mute):
|
||||||
self.send_idle('output')
|
self.send_idle('output')
|
||||||
|
|
||||||
|
def stream_title_changed(self, title):
|
||||||
|
self.send_idle('playlist')
|
||||||
|
|||||||
@ -13,6 +13,7 @@ protocol.load_protocol_modules()
|
|||||||
|
|
||||||
|
|
||||||
class MpdDispatcher(object):
|
class MpdDispatcher(object):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
The MPD session feeds the MPD dispatcher with requests. The dispatcher
|
The MPD session feeds the MPD dispatcher with requests. The dispatcher
|
||||||
finds the correct handler, processes the request and sends the response
|
finds the correct handler, processes the request and sends the response
|
||||||
@ -21,7 +22,7 @@ class MpdDispatcher(object):
|
|||||||
|
|
||||||
_noidle = re.compile(r'^noidle$')
|
_noidle = re.compile(r'^noidle$')
|
||||||
|
|
||||||
def __init__(self, session=None, config=None, core=None):
|
def __init__(self, session=None, config=None, core=None, uri_map=None):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.authenticated = False
|
self.authenticated = False
|
||||||
self.command_list_receiving = False
|
self.command_list_receiving = False
|
||||||
@ -29,7 +30,7 @@ class MpdDispatcher(object):
|
|||||||
self.command_list = []
|
self.command_list = []
|
||||||
self.command_list_index = None
|
self.command_list_index = None
|
||||||
self.context = MpdContext(
|
self.context = MpdContext(
|
||||||
self, session=session, config=config, core=core)
|
self, session=session, config=config, core=core, uri_map=uri_map)
|
||||||
|
|
||||||
def handle_request(self, request, current_command_list_index=None):
|
def handle_request(self, request, current_command_list_index=None):
|
||||||
"""Dispatch incoming requests to the correct handler."""
|
"""Dispatch incoming requests to the correct handler."""
|
||||||
@ -163,6 +164,11 @@ class MpdDispatcher(object):
|
|||||||
|
|
||||||
def _call_handler(self, request):
|
def _call_handler(self, request):
|
||||||
tokens = tokenize.split(request)
|
tokens = tokenize.split(request)
|
||||||
|
# TODO: check that blacklist items are valid commands?
|
||||||
|
blacklist = self.config['mpd'].get('command_blacklist', [])
|
||||||
|
if tokens and tokens[0] in blacklist:
|
||||||
|
logger.warning('Client sent us blacklisted command: %s', tokens[0])
|
||||||
|
raise exceptions.MpdDisabled(command=tokens[0])
|
||||||
try:
|
try:
|
||||||
return protocol.commands.call(tokens, context=self.context)
|
return protocol.commands.call(tokens, context=self.context)
|
||||||
except exceptions.MpdAckError as exc:
|
except exceptions.MpdAckError as exc:
|
||||||
@ -204,6 +210,7 @@ class MpdDispatcher(object):
|
|||||||
|
|
||||||
|
|
||||||
class MpdContext(object):
|
class MpdContext(object):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
This object is passed as the first argument to all MPD command handlers to
|
This object is passed as the first argument to all MPD command handlers to
|
||||||
give the command handlers access to important parts of Mopidy.
|
give the command handlers access to important parts of Mopidy.
|
||||||
@ -227,10 +234,10 @@ class MpdContext(object):
|
|||||||
#: The subsytems that we want to be notified about in idle mode.
|
#: The subsytems that we want to be notified about in idle mode.
|
||||||
subscriptions = None
|
subscriptions = None
|
||||||
|
|
||||||
_invalid_browse_chars = re.compile(r'[\n\r]')
|
_uri_map = None
|
||||||
_invalid_playlist_chars = re.compile(r'[/]')
|
|
||||||
|
|
||||||
def __init__(self, dispatcher, session=None, config=None, core=None):
|
def __init__(self, dispatcher, session=None, config=None, core=None,
|
||||||
|
uri_map=None):
|
||||||
self.dispatcher = dispatcher
|
self.dispatcher = dispatcher
|
||||||
self.session = session
|
self.session = session
|
||||||
if config is not None:
|
if config is not None:
|
||||||
@ -238,58 +245,19 @@ class MpdContext(object):
|
|||||||
self.core = core
|
self.core = core
|
||||||
self.events = set()
|
self.events = set()
|
||||||
self.subscriptions = set()
|
self.subscriptions = set()
|
||||||
self._uri_from_name = {}
|
self._uri_map = uri_map
|
||||||
self._name_from_uri = {}
|
|
||||||
self.refresh_playlists_mapping()
|
|
||||||
|
|
||||||
def create_unique_name(self, name, uri):
|
def lookup_playlist_uri_from_name(self, name):
|
||||||
stripped_name = self._invalid_browse_chars.sub(' ', name)
|
|
||||||
name = stripped_name
|
|
||||||
i = 2
|
|
||||||
while name in self._uri_from_name:
|
|
||||||
if self._uri_from_name[name] == uri:
|
|
||||||
return name
|
|
||||||
name = '%s [%d]' % (stripped_name, i)
|
|
||||||
i += 1
|
|
||||||
return name
|
|
||||||
|
|
||||||
def insert_name_uri_mapping(self, name, uri):
|
|
||||||
name = self.create_unique_name(name, uri)
|
|
||||||
self._uri_from_name[name] = uri
|
|
||||||
self._name_from_uri[uri] = name
|
|
||||||
return name
|
|
||||||
|
|
||||||
def refresh_playlists_mapping(self):
|
|
||||||
"""
|
|
||||||
Maintain map between playlists and unique playlist names to be used by
|
|
||||||
MPD
|
|
||||||
"""
|
|
||||||
if self.core is not None:
|
|
||||||
for playlist in self.core.playlists.playlists.get():
|
|
||||||
if not playlist.name:
|
|
||||||
continue
|
|
||||||
# TODO: add scheme to name perhaps 'foo (spotify)' etc.
|
|
||||||
name = self._invalid_playlist_chars.sub('|', playlist.name)
|
|
||||||
self.insert_name_uri_mapping(name, playlist.uri)
|
|
||||||
|
|
||||||
def lookup_playlist_from_name(self, name):
|
|
||||||
"""
|
"""
|
||||||
Helper function to retrieve a playlist from its unique MPD name.
|
Helper function to retrieve a playlist from its unique MPD name.
|
||||||
"""
|
"""
|
||||||
if not self._uri_from_name:
|
return self._uri_map.playlist_uri_from_name(name)
|
||||||
self.refresh_playlists_mapping()
|
|
||||||
if name not in self._uri_from_name:
|
|
||||||
return None
|
|
||||||
uri = self._uri_from_name[name]
|
|
||||||
return self.core.playlists.lookup(uri).get()
|
|
||||||
|
|
||||||
def lookup_playlist_name_from_uri(self, uri):
|
def lookup_playlist_name_from_uri(self, uri):
|
||||||
"""
|
"""
|
||||||
Helper function to retrieve the unique MPD playlist name from its uri.
|
Helper function to retrieve the unique MPD playlist name from its uri.
|
||||||
"""
|
"""
|
||||||
if uri not in self._name_from_uri:
|
return self._uri_map.playlist_name_from_uri(uri)
|
||||||
self.refresh_playlists_mapping()
|
|
||||||
return self._name_from_uri[uri]
|
|
||||||
|
|
||||||
def browse(self, path, recursive=True, lookup=True):
|
def browse(self, path, recursive=True, lookup=True):
|
||||||
"""
|
"""
|
||||||
@ -301,10 +269,10 @@ class MpdContext(object):
|
|||||||
given path.
|
given path.
|
||||||
|
|
||||||
If ``lookup`` is true and the ``path`` is to a track, the returned
|
If ``lookup`` is true and the ``path`` is to a track, the returned
|
||||||
``data`` is a future which will contain the
|
``data`` is a future which will contain the results from looking up
|
||||||
:class:`mopidy.models.Track` model. If ``lookup`` is false and the
|
the URI with :meth:`mopidy.core.LibraryController.lookup` If ``lookup``
|
||||||
``path`` is to a track, the returned ``data`` will be a
|
is false and the ``path`` is to a track, the returned ``data`` will be
|
||||||
:class:`mopidy.models.Ref` for the track.
|
a :class:`mopidy.models.Ref` for the track.
|
||||||
|
|
||||||
For all entries that are not tracks, the returned ``data`` will be
|
For all entries that are not tracks, the returned ``data`` will be
|
||||||
:class:`None`.
|
:class:`None`.
|
||||||
@ -313,8 +281,8 @@ class MpdContext(object):
|
|||||||
path_parts = re.findall(r'[^/]+', path or '')
|
path_parts = re.findall(r'[^/]+', path or '')
|
||||||
root_path = '/'.join([''] + path_parts)
|
root_path = '/'.join([''] + path_parts)
|
||||||
|
|
||||||
if root_path not in self._uri_from_name:
|
uri = self._uri_map.uri_from_name(root_path)
|
||||||
uri = None
|
if uri is None:
|
||||||
for part in path_parts:
|
for part in path_parts:
|
||||||
for ref in self.core.library.browse(uri).get():
|
for ref in self.core.library.browse(uri).get():
|
||||||
if ref.type != ref.TRACK and ref.name == part:
|
if ref.type != ref.TRACK and ref.name == part:
|
||||||
@ -322,10 +290,7 @@ class MpdContext(object):
|
|||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
raise exceptions.MpdNoExistError('Not found')
|
raise exceptions.MpdNoExistError('Not found')
|
||||||
root_path = self.insert_name_uri_mapping(root_path, uri)
|
root_path = self._uri_map.insert(root_path, uri)
|
||||||
|
|
||||||
else:
|
|
||||||
uri = self._uri_from_name[root_path]
|
|
||||||
|
|
||||||
if recursive:
|
if recursive:
|
||||||
yield (root_path, None)
|
yield (root_path, None)
|
||||||
@ -335,11 +300,12 @@ class MpdContext(object):
|
|||||||
base_path, future = path_and_futures.pop()
|
base_path, future = path_and_futures.pop()
|
||||||
for ref in future.get():
|
for ref in future.get():
|
||||||
path = '/'.join([base_path, ref.name.replace('/', '')])
|
path = '/'.join([base_path, ref.name.replace('/', '')])
|
||||||
path = self.insert_name_uri_mapping(path, ref.uri)
|
path = self._uri_map.insert(path, ref.uri)
|
||||||
|
|
||||||
if ref.type == ref.TRACK:
|
if ref.type == ref.TRACK:
|
||||||
if lookup:
|
if lookup:
|
||||||
yield (path, self.core.library.lookup(ref.uri))
|
# TODO: can we lookup all the refs at once now?
|
||||||
|
yield (path, self.core.library.lookup(uris=[ref.uri]))
|
||||||
else:
|
else:
|
||||||
yield (path, ref)
|
yield (path, ref)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from mopidy.exceptions import MopidyException
|
|||||||
|
|
||||||
|
|
||||||
class MpdAckError(MopidyException):
|
class MpdAckError(MopidyException):
|
||||||
|
|
||||||
"""See fields on this class for available MPD error codes"""
|
"""See fields on this class for available MPD error codes"""
|
||||||
|
|
||||||
ACK_ERROR_NOT_LIST = 1
|
ACK_ERROR_NOT_LIST = 1
|
||||||
@ -59,6 +60,7 @@ class MpdUnknownError(MpdAckError):
|
|||||||
|
|
||||||
|
|
||||||
class MpdUnknownCommand(MpdUnknownError):
|
class MpdUnknownCommand(MpdUnknownError):
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(MpdUnknownCommand, self).__init__(*args, **kwargs)
|
super(MpdUnknownCommand, self).__init__(*args, **kwargs)
|
||||||
assert self.command is not None, 'command must be given explicitly'
|
assert self.command is not None, 'command must be given explicitly'
|
||||||
@ -67,6 +69,7 @@ class MpdUnknownCommand(MpdUnknownError):
|
|||||||
|
|
||||||
|
|
||||||
class MpdNoCommand(MpdUnknownCommand):
|
class MpdNoCommand(MpdUnknownCommand):
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
kwargs['command'] = ''
|
kwargs['command'] = ''
|
||||||
super(MpdNoCommand, self).__init__(*args, **kwargs)
|
super(MpdNoCommand, self).__init__(*args, **kwargs)
|
||||||
@ -87,3 +90,13 @@ class MpdNotImplemented(MpdAckError):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(MpdNotImplemented, self).__init__(*args, **kwargs)
|
super(MpdNotImplemented, self).__init__(*args, **kwargs)
|
||||||
self.message = 'Not implemented'
|
self.message = 'Not implemented'
|
||||||
|
|
||||||
|
|
||||||
|
class MpdDisabled(MpdAckError):
|
||||||
|
# NOTE: This is a custom error for Mopidy that does not exist in MPD.
|
||||||
|
error_code = 0
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(MpdDisabled, self).__init__(*args, **kwargs)
|
||||||
|
assert self.command is not None, 'command must be given explicitly'
|
||||||
|
self.message = '"%s" has been disabled in the server' % self.command
|
||||||
|
|||||||
@ -6,3 +6,4 @@ password =
|
|||||||
max_connections = 20
|
max_connections = 20
|
||||||
connection_timeout = 60
|
connection_timeout = 60
|
||||||
zeroconf = Mopidy MPD server on $hostname
|
zeroconf = Mopidy MPD server on $hostname
|
||||||
|
command_blacklist = listall,listallinfo
|
||||||
|
|||||||
@ -83,6 +83,7 @@ def RANGE(value): # noqa: N802
|
|||||||
|
|
||||||
|
|
||||||
class Commands(object):
|
class Commands(object):
|
||||||
|
|
||||||
"""Collection of MPD commands to expose to users.
|
"""Collection of MPD commands to expose to users.
|
||||||
|
|
||||||
Normally used through the global instance which command handlers have been
|
Normally used through the global instance which command handlers have been
|
||||||
|
|||||||
@ -13,7 +13,9 @@ def disableoutput(context, outputid):
|
|||||||
Turns an output off.
|
Turns an output off.
|
||||||
"""
|
"""
|
||||||
if outputid == 0:
|
if outputid == 0:
|
||||||
context.core.playback.set_mute(False)
|
success = context.core.mixer.set_mute(False).get()
|
||||||
|
if not success:
|
||||||
|
raise exceptions.MpdSystemError('problems disabling output')
|
||||||
else:
|
else:
|
||||||
raise exceptions.MpdNoExistError('No such audio output')
|
raise exceptions.MpdNoExistError('No such audio output')
|
||||||
|
|
||||||
@ -28,13 +30,14 @@ def enableoutput(context, outputid):
|
|||||||
Turns an output on.
|
Turns an output on.
|
||||||
"""
|
"""
|
||||||
if outputid == 0:
|
if outputid == 0:
|
||||||
context.core.playback.set_mute(True)
|
success = context.core.mixer.set_mute(True).get()
|
||||||
|
if not success:
|
||||||
|
raise exceptions.MpdSystemError('problems enabling output')
|
||||||
else:
|
else:
|
||||||
raise exceptions.MpdNoExistError('No such audio output')
|
raise exceptions.MpdNoExistError('No such audio output')
|
||||||
|
|
||||||
|
|
||||||
# TODO: implement and test
|
@protocol.commands.add('toggleoutput', outputid=protocol.UINT)
|
||||||
# @protocol.commands.add('toggleoutput', outputid=protocol.UINT)
|
|
||||||
def toggleoutput(context, outputid):
|
def toggleoutput(context, outputid):
|
||||||
"""
|
"""
|
||||||
*musicpd.org, audio output section:*
|
*musicpd.org, audio output section:*
|
||||||
@ -43,7 +46,13 @@ def toggleoutput(context, outputid):
|
|||||||
|
|
||||||
Turns an output on or off, depending on the current state.
|
Turns an output on or off, depending on the current state.
|
||||||
"""
|
"""
|
||||||
pass
|
if outputid == 0:
|
||||||
|
mute_status = context.core.mixer.get_mute().get()
|
||||||
|
success = context.core.mixer.set_mute(not mute_status)
|
||||||
|
if not success:
|
||||||
|
raise exceptions.MpdSystemError('problems toggling output')
|
||||||
|
else:
|
||||||
|
raise exceptions.MpdNoExistError('No such audio output')
|
||||||
|
|
||||||
|
|
||||||
@protocol.commands.add('outputs')
|
@protocol.commands.add('outputs')
|
||||||
@ -55,7 +64,7 @@ def outputs(context):
|
|||||||
|
|
||||||
Shows information about all outputs.
|
Shows information about all outputs.
|
||||||
"""
|
"""
|
||||||
muted = 1 if context.core.playback.get_mute().get() else 0
|
muted = 1 if context.core.mixer.get_mute().get() else 0
|
||||||
return [
|
return [
|
||||||
('outputid', 0),
|
('outputid', 0),
|
||||||
('outputname', 'Mute'),
|
('outputname', 'Mute'),
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
from __future__ import absolute_import, unicode_literals
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
from mopidy.mpd import exceptions, protocol, translator
|
from mopidy.mpd import exceptions, protocol, translator
|
||||||
|
from mopidy.utils import deprecation
|
||||||
|
|
||||||
|
|
||||||
@protocol.commands.add('add')
|
@protocol.commands.add('add')
|
||||||
@ -22,21 +21,21 @@ def add(context, uri):
|
|||||||
if not uri.strip('/'):
|
if not uri.strip('/'):
|
||||||
return
|
return
|
||||||
|
|
||||||
if context.core.tracklist.add(uri=uri).get():
|
if context.core.tracklist.add(uris=[uri]).get():
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tracks = []
|
uris = []
|
||||||
for path, lookup_future in context.browse(uri):
|
for path, ref in context.browse(uri, lookup=False):
|
||||||
if lookup_future:
|
if ref:
|
||||||
tracks.extend(lookup_future.get())
|
uris.append(ref.uri)
|
||||||
except exceptions.MpdNoExistError as e:
|
except exceptions.MpdNoExistError as e:
|
||||||
e.message = 'directory or file not found'
|
e.message = 'directory or file not found'
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if not tracks:
|
if not uris:
|
||||||
raise exceptions.MpdNoExistError('directory or file not found')
|
raise exceptions.MpdNoExistError('directory or file not found')
|
||||||
context.core.tracklist.add(tracks=tracks)
|
context.core.tracklist.add(uris=uris).get()
|
||||||
|
|
||||||
|
|
||||||
@protocol.commands.add('addid', songpos=protocol.UINT)
|
@protocol.commands.add('addid', songpos=protocol.UINT)
|
||||||
@ -62,7 +61,8 @@ def addid(context, uri, songpos=None):
|
|||||||
raise exceptions.MpdNoExistError('No such song')
|
raise exceptions.MpdNoExistError('No such song')
|
||||||
if songpos is not None and songpos > context.core.tracklist.length.get():
|
if songpos is not None and songpos > context.core.tracklist.length.get():
|
||||||
raise exceptions.MpdArgError('Bad song index')
|
raise exceptions.MpdArgError('Bad song index')
|
||||||
tl_tracks = context.core.tracklist.add(uri=uri, at_position=songpos).get()
|
tl_tracks = context.core.tracklist.add(
|
||||||
|
uris=[uri], at_position=songpos).get()
|
||||||
if not tl_tracks:
|
if not tl_tracks:
|
||||||
raise exceptions.MpdNoExistError('No such song')
|
raise exceptions.MpdNoExistError('No such song')
|
||||||
return ('Id', tl_tracks[0].tlid)
|
return ('Id', tl_tracks[0].tlid)
|
||||||
@ -162,8 +162,7 @@ def playlist(context):
|
|||||||
|
|
||||||
Do not use this, instead use ``playlistinfo``.
|
Do not use this, instead use ``playlistinfo``.
|
||||||
"""
|
"""
|
||||||
warnings.warn(
|
deprecation.warn('mpd.protocol.current_playlist.playlist')
|
||||||
'Do not use this, instead use playlistinfo', DeprecationWarning)
|
|
||||||
return playlistinfo(context)
|
return playlistinfo(context)
|
||||||
|
|
||||||
|
|
||||||
@ -275,9 +274,21 @@ def plchanges(context, version):
|
|||||||
- Calls ``plchanges "-1"`` two times per second to get the entire playlist.
|
- Calls ``plchanges "-1"`` two times per second to get the entire playlist.
|
||||||
"""
|
"""
|
||||||
# XXX Naive implementation that returns all tracks as changed
|
# XXX Naive implementation that returns all tracks as changed
|
||||||
if int(version) < context.core.tracklist.version.get():
|
tracklist_version = context.core.tracklist.version.get()
|
||||||
|
if version < tracklist_version:
|
||||||
return translator.tracks_to_mpd_format(
|
return translator.tracks_to_mpd_format(
|
||||||
context.core.tracklist.tl_tracks.get())
|
context.core.tracklist.tl_tracks.get())
|
||||||
|
elif version == tracklist_version:
|
||||||
|
# A version match could indicate this is just a metadata update, so
|
||||||
|
# check for a stream ref and let the client know about the change.
|
||||||
|
stream_title = context.core.playback.get_stream_title().get()
|
||||||
|
if stream_title is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tl_track = context.core.playback.current_tl_track.get()
|
||||||
|
position = context.core.tracklist.index(tl_track).get()
|
||||||
|
return translator.track_to_mpd_format(
|
||||||
|
tl_track, position=position, stream_title=stream_title)
|
||||||
|
|
||||||
|
|
||||||
@protocol.commands.add('plchangesposid', version=protocol.INT)
|
@protocol.commands.add('plchangesposid', version=protocol.INT)
|
||||||
@ -337,8 +348,12 @@ def swap(context, songpos1, songpos2):
|
|||||||
tracks.insert(songpos1, song2)
|
tracks.insert(songpos1, song2)
|
||||||
del tracks[songpos2]
|
del tracks[songpos2]
|
||||||
tracks.insert(songpos2, song1)
|
tracks.insert(songpos2, song1)
|
||||||
|
|
||||||
|
# TODO: do we need a tracklist.replace()
|
||||||
context.core.tracklist.clear()
|
context.core.tracklist.clear()
|
||||||
context.core.tracklist.add(tracks)
|
|
||||||
|
with deprecation.ignore('core.tracklist.add:tracks_arg'):
|
||||||
|
context.core.tracklist.add(tracks=tracks).get()
|
||||||
|
|
||||||
|
|
||||||
@protocol.commands.add('swapid', tlid1=protocol.UINT, tlid2=protocol.UINT)
|
@protocol.commands.add('swapid', tlid1=protocol.UINT, tlid2=protocol.UINT)
|
||||||
|
|||||||
@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals
|
|||||||
|
|
||||||
import functools
|
import functools
|
||||||
import itertools
|
import itertools
|
||||||
|
import warnings
|
||||||
|
|
||||||
from mopidy.models import Track
|
from mopidy.models import Track
|
||||||
from mopidy.mpd import exceptions, protocol, translator
|
from mopidy.mpd import exceptions, protocol, translator
|
||||||
@ -30,6 +31,15 @@ _LIST_MAPPING = {
|
|||||||
'genre': 'genre',
|
'genre': 'genre',
|
||||||
'performer': 'performer'}
|
'performer': 'performer'}
|
||||||
|
|
||||||
|
_LIST_NAME_MAPPING = {
|
||||||
|
'album': 'Album',
|
||||||
|
'albumartist': 'AlbumArtist',
|
||||||
|
'artist': 'Artist',
|
||||||
|
'composer': 'Composer',
|
||||||
|
'date': 'Date',
|
||||||
|
'genre': 'Genre',
|
||||||
|
'performer': 'Performer'}
|
||||||
|
|
||||||
|
|
||||||
def _query_from_mpd_search_parameters(parameters, mapping):
|
def _query_from_mpd_search_parameters(parameters, mapping):
|
||||||
query = {}
|
query = {}
|
||||||
@ -91,7 +101,7 @@ def count(context, *args):
|
|||||||
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
|
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise exceptions.MpdArgError('incorrect arguments')
|
raise exceptions.MpdArgError('incorrect arguments')
|
||||||
results = context.core.library.find_exact(**query).get()
|
results = context.core.library.search(query=query, exact=True).get()
|
||||||
result_tracks = _get_tracks(results)
|
result_tracks = _get_tracks(results)
|
||||||
return [
|
return [
|
||||||
('songs', len(result_tracks)),
|
('songs', len(result_tracks)),
|
||||||
@ -132,7 +142,7 @@ def find(context, *args):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return
|
return
|
||||||
|
|
||||||
results = context.core.library.find_exact(**query).get()
|
results = context.core.library.search(query=query, exact=True).get()
|
||||||
result_tracks = []
|
result_tracks = []
|
||||||
if ('artist' not in query and
|
if ('artist' not in query and
|
||||||
'albumartist' not in query and
|
'albumartist' not in query and
|
||||||
@ -159,8 +169,14 @@ def findadd(context, *args):
|
|||||||
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
|
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return
|
return
|
||||||
results = context.core.library.find_exact(**query).get()
|
|
||||||
context.core.tracklist.add(_get_tracks(results))
|
results = context.core.library.search(query=query, exact=True).get()
|
||||||
|
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
# TODO: for now just use tracks as other wise we have to lookup the
|
||||||
|
# tracks we just got from the search.
|
||||||
|
warnings.filterwarnings('ignore', 'tracklist.add.*"tracks" argument.*')
|
||||||
|
context.core.tracklist.add(tracks=_get_tracks(results)).get()
|
||||||
|
|
||||||
|
|
||||||
@protocol.commands.add('list')
|
@protocol.commands.add('list')
|
||||||
@ -246,109 +262,30 @@ def list_(context, *args):
|
|||||||
- does not add quotes around the field argument.
|
- does not add quotes around the field argument.
|
||||||
- capitalizes the field argument.
|
- capitalizes the field argument.
|
||||||
"""
|
"""
|
||||||
parameters = list(args)
|
params = list(args)
|
||||||
if not parameters:
|
if not params:
|
||||||
raise exceptions.MpdArgError('incorrect arguments')
|
raise exceptions.MpdArgError('incorrect arguments')
|
||||||
field = parameters.pop(0).lower()
|
field = params.pop(0).lower()
|
||||||
|
|
||||||
if field not in _LIST_MAPPING:
|
if field not in _LIST_MAPPING:
|
||||||
raise exceptions.MpdArgError('incorrect arguments')
|
raise exceptions.MpdArgError('incorrect arguments')
|
||||||
|
|
||||||
if len(parameters) == 1:
|
if len(params) == 1:
|
||||||
if field != 'album':
|
if field != 'album':
|
||||||
raise exceptions.MpdArgError('should be "Album" for 3 arguments')
|
raise exceptions.MpdArgError('should be "Album" for 3 arguments')
|
||||||
return _list_album(context, {'artist': parameters})
|
query = {'artist': params}
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
query = _query_from_mpd_search_parameters(params, _LIST_MAPPING)
|
||||||
|
except exceptions.MpdArgError as e:
|
||||||
|
e.message = 'not able to parse args'
|
||||||
|
raise
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
name = _LIST_NAME_MAPPING[field]
|
||||||
query = _query_from_mpd_search_parameters(parameters, _LIST_MAPPING)
|
result = context.core.library.get_distinct(field, query)
|
||||||
except exceptions.MpdArgError as e:
|
return [(name, value) for value in result.get()]
|
||||||
e.message = 'not able to parse args'
|
|
||||||
raise
|
|
||||||
except ValueError:
|
|
||||||
return
|
|
||||||
|
|
||||||
if field == 'artist':
|
|
||||||
return _list_artist(context, query)
|
|
||||||
if field == 'albumartist':
|
|
||||||
return _list_albumartist(context, query)
|
|
||||||
elif field == 'album':
|
|
||||||
return _list_album(context, query)
|
|
||||||
elif field == 'composer':
|
|
||||||
return _list_composer(context, query)
|
|
||||||
elif field == 'performer':
|
|
||||||
return _list_performer(context, query)
|
|
||||||
elif field == 'date':
|
|
||||||
return _list_date(context, query)
|
|
||||||
elif field == 'genre':
|
|
||||||
return _list_genre(context, query)
|
|
||||||
|
|
||||||
|
|
||||||
def _list_artist(context, query):
|
|
||||||
artists = set()
|
|
||||||
results = context.core.library.find_exact(**query).get()
|
|
||||||
for track in _get_tracks(results):
|
|
||||||
for artist in track.artists:
|
|
||||||
if artist.name:
|
|
||||||
artists.add(('Artist', artist.name))
|
|
||||||
return artists
|
|
||||||
|
|
||||||
|
|
||||||
def _list_albumartist(context, query):
|
|
||||||
albumartists = set()
|
|
||||||
results = context.core.library.find_exact(**query).get()
|
|
||||||
for track in _get_tracks(results):
|
|
||||||
if track.album:
|
|
||||||
for artist in track.album.artists:
|
|
||||||
if artist.name:
|
|
||||||
albumartists.add(('AlbumArtist', artist.name))
|
|
||||||
return albumartists
|
|
||||||
|
|
||||||
|
|
||||||
def _list_album(context, query):
|
|
||||||
albums = set()
|
|
||||||
results = context.core.library.find_exact(**query).get()
|
|
||||||
for track in _get_tracks(results):
|
|
||||||
if track.album and track.album.name:
|
|
||||||
albums.add(('Album', track.album.name))
|
|
||||||
return albums
|
|
||||||
|
|
||||||
|
|
||||||
def _list_composer(context, query):
|
|
||||||
composers = set()
|
|
||||||
results = context.core.library.find_exact(**query).get()
|
|
||||||
for track in _get_tracks(results):
|
|
||||||
for composer in track.composers:
|
|
||||||
if composer.name:
|
|
||||||
composers.add(('Composer', composer.name))
|
|
||||||
return composers
|
|
||||||
|
|
||||||
|
|
||||||
def _list_performer(context, query):
|
|
||||||
performers = set()
|
|
||||||
results = context.core.library.find_exact(**query).get()
|
|
||||||
for track in _get_tracks(results):
|
|
||||||
for performer in track.performers:
|
|
||||||
if performer.name:
|
|
||||||
performers.add(('Performer', performer.name))
|
|
||||||
return performers
|
|
||||||
|
|
||||||
|
|
||||||
def _list_date(context, query):
|
|
||||||
dates = set()
|
|
||||||
results = context.core.library.find_exact(**query).get()
|
|
||||||
for track in _get_tracks(results):
|
|
||||||
if track.date:
|
|
||||||
dates.add(('Date', track.date))
|
|
||||||
return dates
|
|
||||||
|
|
||||||
|
|
||||||
def _list_genre(context, query):
|
|
||||||
genres = set()
|
|
||||||
results = context.core.library.find_exact(**query).get()
|
|
||||||
for track in _get_tracks(results):
|
|
||||||
if track.genre:
|
|
||||||
genres.add(('Genre', track.genre))
|
|
||||||
return genres
|
|
||||||
|
|
||||||
|
|
||||||
@protocol.commands.add('listall')
|
@protocol.commands.add('listall')
|
||||||
@ -359,6 +296,13 @@ def listall(context, uri=None):
|
|||||||
``listall [URI]``
|
``listall [URI]``
|
||||||
|
|
||||||
Lists all songs and directories in ``URI``.
|
Lists all songs and directories in ``URI``.
|
||||||
|
|
||||||
|
Do not use this command. Do not manage a client-side copy of MPD's
|
||||||
|
database. That is fragile and adds huge overhead. It will break with
|
||||||
|
large databases. Instead, query MPD whenever you need something.
|
||||||
|
|
||||||
|
|
||||||
|
.. warning:: This command is disabled by default in Mopidy installs.
|
||||||
"""
|
"""
|
||||||
result = []
|
result = []
|
||||||
for path, track_ref in context.browse(uri, lookup=False):
|
for path, track_ref in context.browse(uri, lookup=False):
|
||||||
@ -381,14 +325,22 @@ def listallinfo(context, uri=None):
|
|||||||
|
|
||||||
Same as ``listall``, except it also returns metadata info in the
|
Same as ``listall``, except it also returns metadata info in the
|
||||||
same format as ``lsinfo``.
|
same format as ``lsinfo``.
|
||||||
|
|
||||||
|
Do not use this command. Do not manage a client-side copy of MPD's
|
||||||
|
database. That is fragile and adds huge overhead. It will break with
|
||||||
|
large databases. Instead, query MPD whenever you need something.
|
||||||
|
|
||||||
|
|
||||||
|
.. warning:: This command is disabled by default in Mopidy installs.
|
||||||
"""
|
"""
|
||||||
result = []
|
result = []
|
||||||
for path, lookup_future in context.browse(uri):
|
for path, lookup_future in context.browse(uri):
|
||||||
if not lookup_future:
|
if not lookup_future:
|
||||||
result.append(('directory', path))
|
result.append(('directory', path))
|
||||||
else:
|
else:
|
||||||
for track in lookup_future.get():
|
for tracks in lookup_future.get().values():
|
||||||
result.extend(translator.track_to_mpd_format(track))
|
for track in tracks:
|
||||||
|
result.extend(translator.track_to_mpd_format(track))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@ -414,9 +366,9 @@ def lsinfo(context, uri=None):
|
|||||||
if not lookup_future:
|
if not lookup_future:
|
||||||
result.append(('directory', path.lstrip('/')))
|
result.append(('directory', path.lstrip('/')))
|
||||||
else:
|
else:
|
||||||
tracks = lookup_future.get()
|
for tracks in lookup_future.get().values():
|
||||||
if tracks:
|
if tracks:
|
||||||
result.extend(translator.track_to_mpd_format(tracks[0]))
|
result.extend(translator.track_to_mpd_format(tracks[0]))
|
||||||
|
|
||||||
if uri in (None, '', '/'):
|
if uri in (None, '', '/'):
|
||||||
result.extend(protocol.stored_playlists.listplaylists(context))
|
result.extend(protocol.stored_playlists.listplaylists(context))
|
||||||
@ -468,7 +420,7 @@ def search(context, *args):
|
|||||||
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
|
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return
|
return
|
||||||
results = context.core.library.search(**query).get()
|
results = context.core.library.search(query).get()
|
||||||
artists = [_artist_as_track(a) for a in _get_artists(results)]
|
artists = [_artist_as_track(a) for a in _get_artists(results)]
|
||||||
albums = [_album_as_track(a) for a in _get_albums(results)]
|
albums = [_album_as_track(a) for a in _get_albums(results)]
|
||||||
tracks = _get_tracks(results)
|
tracks = _get_tracks(results)
|
||||||
@ -492,8 +444,14 @@ def searchadd(context, *args):
|
|||||||
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
|
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return
|
return
|
||||||
results = context.core.library.search(**query).get()
|
|
||||||
context.core.tracklist.add(_get_tracks(results))
|
results = context.core.library.search(query).get()
|
||||||
|
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
# TODO: for now just use tracks as other wise we have to lookup the
|
||||||
|
# tracks we just got from the search.
|
||||||
|
warnings.filterwarnings('ignore', 'tracklist.add.*"tracks".*')
|
||||||
|
context.core.tracklist.add(_get_tracks(results)).get()
|
||||||
|
|
||||||
|
|
||||||
@protocol.commands.add('searchaddpl')
|
@protocol.commands.add('searchaddpl')
|
||||||
@ -519,9 +477,10 @@ def searchaddpl(context, *args):
|
|||||||
query = _query_from_mpd_search_parameters(parameters, _SEARCH_MAPPING)
|
query = _query_from_mpd_search_parameters(parameters, _SEARCH_MAPPING)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return
|
return
|
||||||
results = context.core.library.search(**query).get()
|
results = context.core.library.search(query).get()
|
||||||
|
|
||||||
playlist = context.lookup_playlist_from_name(playlist_name)
|
uri = context.lookup_playlist_uri_from_name(playlist_name)
|
||||||
|
playlist = uri is not None and context.core.playlists.lookup(uri).get()
|
||||||
if not playlist:
|
if not playlist:
|
||||||
playlist = context.core.playlists.create(playlist_name).get()
|
playlist = context.core.playlists.create(playlist_name).get()
|
||||||
tracks = list(playlist.tracks) + _get_tracks(results)
|
tracks = list(playlist.tracks) + _get_tracks(results)
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
from __future__ import absolute_import, unicode_literals
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
from mopidy.core import PlaybackState
|
from mopidy.core import PlaybackState
|
||||||
from mopidy.mpd import exceptions, protocol
|
from mopidy.mpd import exceptions, protocol
|
||||||
|
from mopidy.utils import deprecation
|
||||||
|
|
||||||
|
|
||||||
@protocol.commands.add('consume', state=protocol.BOOL)
|
@protocol.commands.add('consume', state=protocol.BOOL)
|
||||||
@ -32,8 +31,7 @@ def crossfade(context, seconds):
|
|||||||
raise exceptions.MpdNotImplemented # TODO
|
raise exceptions.MpdNotImplemented # TODO
|
||||||
|
|
||||||
|
|
||||||
# TODO: add at least reflection tests before adding NotImplemented version
|
@protocol.commands.add('mixrampdb')
|
||||||
# @protocol.commands.add('mixrampdb')
|
|
||||||
def mixrampdb(context, decibels):
|
def mixrampdb(context, decibels):
|
||||||
"""
|
"""
|
||||||
*musicpd.org, playback section:*
|
*musicpd.org, playback section:*
|
||||||
@ -46,11 +44,10 @@ def mixrampdb(context, decibels):
|
|||||||
volume so use negative values, I prefer -17dB. In the absence of mixramp
|
volume so use negative values, I prefer -17dB. In the absence of mixramp
|
||||||
tags crossfading will be used. See http://sourceforge.net/projects/mixramp
|
tags crossfading will be used. See http://sourceforge.net/projects/mixramp
|
||||||
"""
|
"""
|
||||||
pass
|
raise exceptions.MpdNotImplemented # TODO
|
||||||
|
|
||||||
|
|
||||||
# TODO: add at least reflection tests before adding NotImplemented version
|
@protocol.commands.add('mixrampdelay', seconds=protocol.UINT)
|
||||||
# @protocol.commands.add('mixrampdelay', seconds=protocol.UINT)
|
|
||||||
def mixrampdelay(context, seconds):
|
def mixrampdelay(context, seconds):
|
||||||
"""
|
"""
|
||||||
*musicpd.org, playback section:*
|
*musicpd.org, playback section:*
|
||||||
@ -61,7 +58,7 @@ def mixrampdelay(context, seconds):
|
|||||||
value of "nan" disables MixRamp overlapping and falls back to
|
value of "nan" disables MixRamp overlapping and falls back to
|
||||||
crossfading.
|
crossfading.
|
||||||
"""
|
"""
|
||||||
pass
|
raise exceptions.MpdNotImplemented # TODO
|
||||||
|
|
||||||
|
|
||||||
@protocol.commands.add('next')
|
@protocol.commands.add('next')
|
||||||
@ -136,9 +133,7 @@ def pause(context, state=None):
|
|||||||
- Calls ``pause`` without any arguments to toogle pause.
|
- Calls ``pause`` without any arguments to toogle pause.
|
||||||
"""
|
"""
|
||||||
if state is None:
|
if state is None:
|
||||||
warnings.warn(
|
deprecation.warn('mpd.protocol.playback.pause:state_arg')
|
||||||
'The use of pause command w/o the PAUSE argument is deprecated.',
|
|
||||||
DeprecationWarning)
|
|
||||||
|
|
||||||
if (context.core.playback.state.get() == PlaybackState.PLAYING):
|
if (context.core.playback.state.get() == PlaybackState.PLAYING):
|
||||||
context.core.playback.pause()
|
context.core.playback.pause()
|
||||||
@ -397,7 +392,10 @@ def setvol(context, volume):
|
|||||||
- issues ``setvol 50`` without quotes around the argument.
|
- issues ``setvol 50`` without quotes around the argument.
|
||||||
"""
|
"""
|
||||||
# NOTE: we use INT as clients can pass in +N etc.
|
# NOTE: we use INT as clients can pass in +N etc.
|
||||||
context.core.playback.volume = min(max(0, volume), 100)
|
value = min(max(0, volume), 100)
|
||||||
|
success = context.core.mixer.set_volume(value).get()
|
||||||
|
if not success:
|
||||||
|
raise exceptions.MpdSystemError('problems setting volume')
|
||||||
|
|
||||||
|
|
||||||
@protocol.commands.add('single', state=protocol.BOOL)
|
@protocol.commands.add('single', state=protocol.BOOL)
|
||||||
|
|||||||
@ -35,9 +35,11 @@ def currentsong(context):
|
|||||||
identified in status).
|
identified in status).
|
||||||
"""
|
"""
|
||||||
tl_track = context.core.playback.current_tl_track.get()
|
tl_track = context.core.playback.current_tl_track.get()
|
||||||
|
stream_title = context.core.playback.get_stream_title().get()
|
||||||
if tl_track is not None:
|
if tl_track is not None:
|
||||||
position = context.core.tracklist.index(tl_track).get()
|
position = context.core.tracklist.index(tl_track).get()
|
||||||
return translator.track_to_mpd_format(tl_track, position=position)
|
return translator.track_to_mpd_format(
|
||||||
|
tl_track, position=position, stream_title=stream_title)
|
||||||
|
|
||||||
|
|
||||||
@protocol.commands.add('idle', list_command=False)
|
@protocol.commands.add('idle', list_command=False)
|
||||||
@ -173,7 +175,7 @@ def status(context):
|
|||||||
futures = {
|
futures = {
|
||||||
'tracklist.length': context.core.tracklist.length,
|
'tracklist.length': context.core.tracklist.length,
|
||||||
'tracklist.version': context.core.tracklist.version,
|
'tracklist.version': context.core.tracklist.version,
|
||||||
'playback.volume': context.core.playback.volume,
|
'mixer.volume': context.core.mixer.get_volume(),
|
||||||
'tracklist.consume': context.core.tracklist.consume,
|
'tracklist.consume': context.core.tracklist.consume,
|
||||||
'tracklist.random': context.core.tracklist.random,
|
'tracklist.random': context.core.tracklist.random,
|
||||||
'tracklist.repeat': context.core.tracklist.repeat,
|
'tracklist.repeat': context.core.tracklist.repeat,
|
||||||
@ -287,7 +289,7 @@ def _status_time_total(futures):
|
|||||||
|
|
||||||
|
|
||||||
def _status_volume(futures):
|
def _status_volume(futures):
|
||||||
volume = futures['playback.volume'].get()
|
volume = futures['mixer.volume'].get()
|
||||||
if volume is not None:
|
if volume is not None:
|
||||||
return volume
|
return volume
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from __future__ import absolute_import, division, unicode_literals
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import warnings
|
||||||
|
|
||||||
from mopidy.mpd import exceptions, protocol, translator
|
from mopidy.mpd import exceptions, protocol, translator
|
||||||
|
|
||||||
@ -20,7 +21,8 @@ def listplaylist(context, name):
|
|||||||
file: relative/path/to/file2.ogg
|
file: relative/path/to/file2.ogg
|
||||||
file: relative/path/to/file3.mp3
|
file: relative/path/to/file3.mp3
|
||||||
"""
|
"""
|
||||||
playlist = context.lookup_playlist_from_name(name)
|
uri = context.lookup_playlist_uri_from_name(name)
|
||||||
|
playlist = uri is not None and context.core.playlists.lookup(uri).get()
|
||||||
if not playlist:
|
if not playlist:
|
||||||
raise exceptions.MpdNoExistError('No such playlist')
|
raise exceptions.MpdNoExistError('No such playlist')
|
||||||
return ['file: %s' % t.uri for t in playlist.tracks]
|
return ['file: %s' % t.uri for t in playlist.tracks]
|
||||||
@ -40,7 +42,8 @@ def listplaylistinfo(context, name):
|
|||||||
Standard track listing, with fields: file, Time, Title, Date,
|
Standard track listing, with fields: file, Time, Title, Date,
|
||||||
Album, Artist, Track
|
Album, Artist, Track
|
||||||
"""
|
"""
|
||||||
playlist = context.lookup_playlist_from_name(name)
|
uri = context.lookup_playlist_uri_from_name(name)
|
||||||
|
playlist = uri is not None and context.core.playlists.lookup(uri).get()
|
||||||
if not playlist:
|
if not playlist:
|
||||||
raise exceptions.MpdNoExistError('No such playlist')
|
raise exceptions.MpdNoExistError('No such playlist')
|
||||||
return translator.playlist_to_mpd_format(playlist)
|
return translator.playlist_to_mpd_format(playlist)
|
||||||
@ -73,7 +76,7 @@ def listplaylists(context):
|
|||||||
ignore playlists without names, which isn't very useful anyway.
|
ignore playlists without names, which isn't very useful anyway.
|
||||||
"""
|
"""
|
||||||
result = []
|
result = []
|
||||||
for playlist in context.core.playlists.playlists.get():
|
for playlist in context.core.playlists.get_playlists().get():
|
||||||
if not playlist.name:
|
if not playlist.name:
|
||||||
continue
|
continue
|
||||||
name = context.lookup_playlist_name_from_uri(playlist.uri)
|
name = context.lookup_playlist_name_from_uri(playlist.uri)
|
||||||
@ -121,10 +124,14 @@ def load(context, name, playlist_slice=slice(0, None)):
|
|||||||
- MPD 0.17.1 does not fail if the specified range is outside the playlist,
|
- MPD 0.17.1 does not fail if the specified range is outside the playlist,
|
||||||
in either or both ends.
|
in either or both ends.
|
||||||
"""
|
"""
|
||||||
playlist = context.lookup_playlist_from_name(name)
|
uri = context.lookup_playlist_uri_from_name(name)
|
||||||
|
playlist = uri is not None and context.core.playlists.lookup(uri).get()
|
||||||
if not playlist:
|
if not playlist:
|
||||||
raise exceptions.MpdNoExistError('No such playlist')
|
raise exceptions.MpdNoExistError('No such playlist')
|
||||||
context.core.tracklist.add(playlist.tracks[playlist_slice])
|
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.filterwarnings('ignore', 'tracklist.add.*"tracks".*')
|
||||||
|
context.core.tracklist.add(playlist.tracks[playlist_slice]).get()
|
||||||
|
|
||||||
|
|
||||||
@protocol.commands.add('playlistadd')
|
@protocol.commands.add('playlistadd')
|
||||||
|
|||||||
@ -9,6 +9,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class MpdSession(network.LineProtocol):
|
class MpdSession(network.LineProtocol):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
The MPD client session. Keeps track of a single client session. Any
|
The MPD client session. Keeps track of a single client session. Any
|
||||||
requests from the client is passed on to the MPD request dispatcher.
|
requests from the client is passed on to the MPD request dispatcher.
|
||||||
@ -18,10 +19,10 @@ class MpdSession(network.LineProtocol):
|
|||||||
encoding = protocol.ENCODING
|
encoding = protocol.ENCODING
|
||||||
delimiter = r'\r?\n'
|
delimiter = r'\r?\n'
|
||||||
|
|
||||||
def __init__(self, connection, config=None, core=None):
|
def __init__(self, connection, config=None, core=None, uri_map=None):
|
||||||
super(MpdSession, self).__init__(connection)
|
super(MpdSession, self).__init__(connection)
|
||||||
self.dispatcher = dispatcher.MpdDispatcher(
|
self.dispatcher = dispatcher.MpdDispatcher(
|
||||||
session=self, config=config, core=core)
|
session=self, config=config, core=core, uri_map=uri_map)
|
||||||
|
|
||||||
def on_start(self):
|
def on_start(self):
|
||||||
logger.info('New MPD connection from [%s]:%s', self.host, self.port)
|
logger.info('New MPD connection from [%s]:%s', self.host, self.port)
|
||||||
|
|||||||
@ -15,7 +15,7 @@ def normalize_path(path, relative=False):
|
|||||||
return '/'.join(parts)
|
return '/'.join(parts)
|
||||||
|
|
||||||
|
|
||||||
def track_to_mpd_format(track, position=None):
|
def track_to_mpd_format(track, position=None, stream_title=None):
|
||||||
"""
|
"""
|
||||||
Format track for output to MPD client.
|
Format track for output to MPD client.
|
||||||
|
|
||||||
@ -23,24 +23,28 @@ def track_to_mpd_format(track, position=None):
|
|||||||
:type track: :class:`mopidy.models.Track` or :class:`mopidy.models.TlTrack`
|
:type track: :class:`mopidy.models.Track` or :class:`mopidy.models.TlTrack`
|
||||||
:param position: track's position in playlist
|
:param position: track's position in playlist
|
||||||
:type position: integer
|
:type position: integer
|
||||||
:param key: if we should set key
|
:param stream_title: the current streams title
|
||||||
:type key: boolean
|
:type position: string
|
||||||
:param mtime: if we should set mtime
|
|
||||||
:type mtime: boolean
|
|
||||||
:rtype: list of two-tuples
|
:rtype: list of two-tuples
|
||||||
"""
|
"""
|
||||||
if isinstance(track, TlTrack):
|
if isinstance(track, TlTrack):
|
||||||
(tlid, track) = track
|
(tlid, track) = track
|
||||||
else:
|
else:
|
||||||
(tlid, track) = (None, track)
|
(tlid, track) = (None, track)
|
||||||
|
|
||||||
result = [
|
result = [
|
||||||
('file', track.uri or ''),
|
('file', track.uri or ''),
|
||||||
|
# TODO: only show length if not none, see:
|
||||||
|
# https://github.com/mopidy/mopidy/issues/923#issuecomment-79584110
|
||||||
('Time', track.length and (track.length // 1000) or 0),
|
('Time', track.length and (track.length // 1000) or 0),
|
||||||
('Artist', artists_to_mpd_format(track.artists)),
|
('Artist', artists_to_mpd_format(track.artists)),
|
||||||
('Title', track.name or ''),
|
('Title', track.name or ''),
|
||||||
('Album', track.album and track.album.name or ''),
|
('Album', track.album and track.album.name or ''),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if stream_title:
|
||||||
|
result.append(('Name', stream_title))
|
||||||
|
|
||||||
if track.date:
|
if track.date:
|
||||||
result.append(('Date', track.date))
|
result.append(('Date', track.date))
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user