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*
|
||||
nosetests.xml
|
||||
xunit-*.xml
|
||||
tmp/
|
||||
|
||||
7
.mailmap
7
.mailmap
@ -5,6 +5,9 @@ Kristian Klette <klette@samfundet.no>
|
||||
Johannes Knutsen <johannes@knutseninfo.no> <johannes@iterate.no>
|
||||
Johannes Knutsen <johannes@knutseninfo.no> <johannes@barbarmaclin.(none)>
|
||||
John Bäckstrand <sopues@gmail.com> <sandos@XBMCLive.(none)>
|
||||
David Caruso <deibido.caruso@gmail.com> <dav@dav.com>
|
||||
Adam Rigg <adam@adamrigg.id.au> <radx@live.com.au>
|
||||
Ernst Bammer <herr.ernst@gmail.com>
|
||||
Alli Witheford <alzeih@gmail.com>
|
||||
Alexandre Petitjean <alpetitjean@gmail.com>
|
||||
Alexandre Petitjean <alpetitjean@gmail.com> <alexandre.petitjean@lne.fr>
|
||||
@ -15,5 +18,9 @@ Janez Troha <janez.troha@gmail.com> <dz0ny@users.noreply.github.com>
|
||||
Janez Troha <janez.troha@gmail.com> <dz0ny@ubuntu.si>
|
||||
Luke Giuliani <luke@giuliani.com.au>
|
||||
Colin Montgomerie <kiteflyingmonkey@gmail.com>
|
||||
Nathan Harper <nathan.sam.harper@gmail.com>
|
||||
Ignasi Fosch <natx@y10k.ws> <ifosch@serenity-2.local>
|
||||
Christopher Schirner <christopher@hackerspace-bamberg.de> <schinken@hackerspace-bamberg.de>
|
||||
Laura Barber <laura.c.barber@gmail.com> <artzii.laura@gmail.com>
|
||||
John Cass <john.cass77@gmail.com>
|
||||
Ronald Zielaznicki <zielaznickiz@g.cofc.edu>
|
||||
|
||||
18
.travis.yml
18
.travis.yml
@ -1,8 +1,18 @@
|
||||
sudo: false
|
||||
|
||||
language: python
|
||||
|
||||
python:
|
||||
- "2.7_with_system_site_packages"
|
||||
|
||||
addons:
|
||||
apt:
|
||||
sources:
|
||||
- mopidy-stable
|
||||
packages:
|
||||
- graphviz-dev
|
||||
- mopidy
|
||||
|
||||
env:
|
||||
- TOX_ENV=py27
|
||||
- TOX_ENV=py27-tornado23
|
||||
@ -11,10 +21,6 @@ env:
|
||||
- TOX_ENV=flake8
|
||||
|
||||
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"
|
||||
|
||||
script:
|
||||
@ -23,6 +29,10 @@ script:
|
||||
after_success:
|
||||
- "if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi"
|
||||
|
||||
branches:
|
||||
except:
|
||||
- debian
|
||||
|
||||
notifications:
|
||||
irc:
|
||||
channels:
|
||||
|
||||
14
AUTHORS
14
AUTHORS
@ -8,14 +8,14 @@
|
||||
- John Bäckstrand <sopues@gmail.com>
|
||||
- Fred Hatfull <fred.hatfull@gmail.com>
|
||||
- Erling Børresen <erling@fenicore.net>
|
||||
- David C <dav@dav.com>
|
||||
- David Caruso <deibido.caruso@gmail.com>
|
||||
- Christian Johansen <christian@cjohansen.no>
|
||||
- Matt Bray <mattjbray@gmail.com>
|
||||
- Trygve Aaberge <trygveaa@gmail.com>
|
||||
- Wouter van Wijk <woutervanwijk@gmail.com>
|
||||
- Jeremy B. Merrill <jeremybmerrill@gmail.com>
|
||||
- 0xadam <radx@live.com.au>
|
||||
- herrernst <herr.ernst@gmail.com>
|
||||
- Adam Rigg <adam@adamrigg.id.au>
|
||||
- Ernst Bammer <herr.ernst@gmail.com>
|
||||
- Nick Steel <kingosticks@gmail.com>
|
||||
- Zan Dobersek <zandobersek@gmail.com>
|
||||
- Thomas Refis <refis.thomas@gmail.com>
|
||||
@ -36,7 +36,7 @@
|
||||
- Colin Montgomerie <kiteflyingmonkey@gmail.com>
|
||||
- Simon de Bakker <simon@simbits.nl>
|
||||
- Arnaud Barisain-Monrose <abarisain@gmail.com>
|
||||
- nathanharper <nathan.sam.harper@gmail.com>
|
||||
- Nathan Harper <nathan.sam.harper@gmail.com>
|
||||
- Pierpaolo Frasa <pfrasa@smail.uni-koeln.de>
|
||||
- Thomas Scholtes <thomas-scholtes@gmx.de>
|
||||
- Sam Willcocks <sam@wlcx.cc>
|
||||
@ -47,3 +47,9 @@
|
||||
- Lukas Vogel <lukas@vogelnest.org>
|
||||
- Thomas Amland <thomas.amland@gmail.com>
|
||||
- Deni Bertovic <deni@kset.org>
|
||||
- Ali Ukani <ali.ukani@gmail.com>
|
||||
- Dirk Groenen <dirk_groenen@live.nl>
|
||||
- John Cass <john.cass77@gmail.com>
|
||||
- Laura Barber <laura.c.barber@gmail.com>
|
||||
- Jakab Kristóf <jaksi07c8@gmail.com>
|
||||
- Ronald Zielaznicki <zielaznickiz@g.cofc.edu>
|
||||
|
||||
@ -12,12 +12,11 @@ flake8-import-order
|
||||
mock
|
||||
|
||||
# Test runners
|
||||
nose
|
||||
pytest
|
||||
pytest-cov
|
||||
pytest-xdist
|
||||
tox
|
||||
|
||||
# Measure test's code coverage
|
||||
coverage
|
||||
|
||||
# Check that MANIFEST.in matches Git repo contents before making a release
|
||||
check-manifest
|
||||
|
||||
|
||||
@ -22,15 +22,16 @@ Frontends
|
||||
=========
|
||||
|
||||
Frontends expose Mopidy to the external world. They can implement servers for
|
||||
protocols like MPD and MPRIS, and they can be used to update other services
|
||||
when something happens in Mopidy, like the Last.fm scrobbler frontend does. See
|
||||
:ref:`frontend-api` for more details.
|
||||
protocols like HTTP, MPD and MPRIS, and they can be used to update other
|
||||
services when something happens in Mopidy, like the Last.fm scrobbler frontend
|
||||
does. See :ref:`frontend-api` for more details.
|
||||
|
||||
.. digraph:: frontend_architecture
|
||||
|
||||
"HTTP\nfrontend" -> Core
|
||||
"MPD\nfrontend" -> Core
|
||||
"MPRIS\nfrontend" -> Core
|
||||
"Last.fm\nfrontend" -> Core
|
||||
"Scrobbler\nfrontend" -> Core
|
||||
|
||||
|
||||
Core
|
||||
@ -55,6 +56,7 @@ See :ref:`core-api` for more details.
|
||||
Core -> "Library\ncontroller"
|
||||
Core -> "Playback\ncontroller"
|
||||
Core -> "Playlists\ncontroller"
|
||||
Core -> "History\ncontroller"
|
||||
|
||||
"Library\ncontroller" -> "Local backend"
|
||||
"Library\ncontroller" -> "Spotify backend"
|
||||
@ -95,7 +97,8 @@ Audio
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@ -64,6 +64,14 @@ Manages the music library, e.g. searching for tracks to be added to a playlist.
|
||||
:members:
|
||||
|
||||
|
||||
Mixer controller
|
||||
================
|
||||
|
||||
Manages volume and muting.
|
||||
|
||||
.. autoclass:: mopidy.core.MixerController
|
||||
:members:
|
||||
|
||||
Core listener
|
||||
=============
|
||||
|
||||
|
||||
@ -14,18 +14,6 @@ WebSocket API for use both from browsers and Node.js. The
|
||||
:ref:`http-explore-extension` extension, can also be used to get you
|
||||
familiarized with HTTP based APIs.
|
||||
|
||||
.. warning:: API stability
|
||||
|
||||
Since the HTTP JSON-RPC API exposes our internal core API directly it is to
|
||||
be regarded as **experimental**. We cannot promise to keep any form of
|
||||
backwards compatibility between releases as we will need to change the core
|
||||
API while working out how to support new use cases. Thus, if you use this
|
||||
API, you must expect to do small adjustments to your client for every
|
||||
release of Mopidy.
|
||||
|
||||
From Mopidy 1.0 and onwards, we intend to keep the core API far more
|
||||
stable.
|
||||
|
||||
|
||||
.. _http-post-api:
|
||||
|
||||
|
||||
@ -4,13 +4,10 @@
|
||||
API reference
|
||||
*************
|
||||
|
||||
.. warning:: API stability
|
||||
.. note:: What is public?
|
||||
|
||||
Only APIs documented here are public and open for use by Mopidy
|
||||
extensions. We will change these APIs, but will keep the changelog up to
|
||||
date with all breaking changes.
|
||||
|
||||
From Mopidy 1.0 and onwards, we intend to keep these APIs far more stable.
|
||||
extensions.
|
||||
|
||||
|
||||
.. toctree::
|
||||
|
||||
@ -8,18 +8,6 @@ We've made a JavaScript library, Mopidy.js, which wraps the
|
||||
:ref:`websocket-api` and gets you quickly started with working on your client
|
||||
instead of figuring out how to communicate with Mopidy.
|
||||
|
||||
.. warning:: API stability
|
||||
|
||||
Since the Mopidy.js API exposes our internal core API directly it is to be
|
||||
regarded as **experimental**. We cannot promise to keep any form of
|
||||
backwards compatibility between releases as we will need to change the core
|
||||
API while working out how to support new use cases. Thus, if you use this
|
||||
API, you must expect to do small adjustments to your client for every
|
||||
release of Mopidy.
|
||||
|
||||
From Mopidy 1.0 and onwards, we intend to keep the core API far more
|
||||
stable.
|
||||
|
||||
|
||||
Getting the library for browser use
|
||||
===================================
|
||||
@ -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
|
||||
<http://wiki.commonjs.org/wiki/Promises/A>`_ standard. We use the
|
||||
implementation known as `when.js <https://github.com/cujojs/when>`_. Please
|
||||
refer to when.js' documentation or the standard for further details on how to
|
||||
work with promise objects.
|
||||
implementation known as `when.js <https://github.com/cujojs/when>`_, and
|
||||
reexport it as ``Mopidy.when`` so you don't have to duplicate the dependency.
|
||||
Please refer to when.js' documentation or the standard for further details on
|
||||
how to work with promise objects.
|
||||
|
||||
|
||||
Cleaning up
|
||||
|
||||
@ -28,19 +28,54 @@ Data model relations
|
||||
|
||||
.. digraph:: model_relations
|
||||
|
||||
Playlist -> Track [ label="has 0..n" ]
|
||||
Track -> Album [ label="has 0..1" ]
|
||||
Track -> Artist [ label="has 0..n" ]
|
||||
Album -> Artist [ label="has 0..n" ]
|
||||
Ref -> Album [ style="dotted", weight=1 ]
|
||||
Ref -> Artist [ style="dotted", weight=1 ]
|
||||
Ref -> Directory [ style="dotted", weight=1 ]
|
||||
Ref -> Playlist [ style="dotted", weight=1 ]
|
||||
Ref -> Track [ style="dotted", weight=1 ]
|
||||
|
||||
SearchResult -> Artist [ label="has 0..n" ]
|
||||
SearchResult -> Album [ label="has 0..n" ]
|
||||
SearchResult -> Track [ label="has 0..n" ]
|
||||
Playlist -> Track [ label="has 0..n", weight=2 ]
|
||||
Track -> Album [ label="has 0..1", weight=10 ]
|
||||
Track -> Artist [ label="has 0..n", weight=10 ]
|
||||
Album -> Artist [ label="has 0..n", weight=10 ]
|
||||
|
||||
Image
|
||||
|
||||
SearchResult -> Artist [ label="has 0..n", weight=1 ]
|
||||
SearchResult -> Album [ label="has 0..n", weight=1 ]
|
||||
SearchResult -> Track [ label="has 0..n", weight=1 ]
|
||||
|
||||
TlTrack -> Track [ label="has 1", weight=20 ]
|
||||
|
||||
|
||||
Data model API
|
||||
==============
|
||||
|
||||
.. automodule:: mopidy.models
|
||||
.. module:: mopidy.models
|
||||
:synopsis: Data model API
|
||||
:members:
|
||||
|
||||
.. autoclass:: mopidy.models.Ref
|
||||
|
||||
.. autoclass:: mopidy.models.Track
|
||||
|
||||
.. autoclass:: mopidy.models.Album
|
||||
|
||||
.. autoclass:: mopidy.models.Artist
|
||||
|
||||
.. autoclass:: mopidy.models.Playlist
|
||||
|
||||
.. autoclass:: mopidy.models.Image
|
||||
|
||||
.. autoclass:: mopidy.models.TlTrack
|
||||
|
||||
.. autoclass:: mopidy.models.SearchResult
|
||||
|
||||
|
||||
Data model helpers
|
||||
==================
|
||||
|
||||
.. autoclass:: mopidy.models.ImmutableObject
|
||||
|
||||
.. autoclass:: mopidy.models.ModelJSONEncoder
|
||||
|
||||
.. autofunction:: mopidy.models.model_json_decoder
|
||||
|
||||
@ -14,7 +14,7 @@ our Git repository.
|
||||
|
||||
.. include:: ../AUTHORS
|
||||
|
||||
If you already enjoy Mopidy, or don't enjoy it and want to help us making
|
||||
Mopidy better, the best way to do so is to contribute back to the community.
|
||||
You can contribute code, documentation, tests, bug reports, or help other
|
||||
users, spreading the word, etc. See :ref:`contributing` for a head start.
|
||||
If want to help us making Mopidy better, the best way to do so is to contribute
|
||||
back to the community, either through code, documentation, tests, bug reports,
|
||||
or by helping other users, spreading the word, etc. See :ref:`contributing` for
|
||||
a head start.
|
||||
|
||||
@ -5,53 +5,424 @@ Changelog
|
||||
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
|
||||
tracks have been played. (Fixes: :issue:`423`, PR: :issue:`803`)
|
||||
- Calling :meth:`mopidy.core.library.LibraryController.search`` with ``kwargs``
|
||||
as the query is no longer supported (PR: :issue:`1090`)
|
||||
|
||||
Internal changes
|
||||
----------------
|
||||
|
||||
- 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.Playback.stop`. It was a leaky internal abstraction,
|
||||
which was never intended to be used externally.
|
||||
:meth:`mopidy.core.PlaybackController.stop`.
|
||||
|
||||
**Commands**
|
||||
- Made the following event triggers internal:
|
||||
|
||||
- :meth:`mopidy.core.PlaybackController.on_end_of_track`
|
||||
- :meth:`mopidy.core.PlaybackController.on_stream_changed`
|
||||
- :meth:`mopidy.core.PlaybackController.on_tracklist_changed`
|
||||
|
||||
- :meth:`mopidy.core.PlaybackController.set_current_tl_track` is now
|
||||
internal.
|
||||
|
||||
- **Deprecated:** The old methods on :class:`mopidy.core.PlaybackController`
|
||||
for volume and mute management have been deprecated. Use
|
||||
:class:`mopidy.core.MixerController` instead. (Fixes: :issue:`962`)
|
||||
|
||||
- When seeking while paused, we no longer change to playing. (Fixes:
|
||||
:issue:`939`, PR: :issue:`1018`)
|
||||
|
||||
- Changed :meth:`mopidy.core.PlaybackController.play` to take the return value
|
||||
from :meth:`mopidy.backend.PlaybackProvider.change_track` into account when
|
||||
determining the success of the :meth:`~mopidy.core.PlaybackController.play`
|
||||
call. (PR: :issue:`1071`)
|
||||
|
||||
- Add :meth:`mopidy.core.Listener.stream_title_changed` and
|
||||
:meth:`mopidy.core.PlaybackController.get_stream_title` for letting clients
|
||||
know about the current title in streams. (PR: :issue:`938`, :issue:`1030`)
|
||||
|
||||
Backend API
|
||||
-----------
|
||||
|
||||
In the API implemented by all backends there have been way fewer but somewhat
|
||||
more drastic changes with some methods removed and new ones being required for
|
||||
certain functionality to continue working. Most backends were already updated to
|
||||
be compatible with Mopidy 1.0 before the release. New versions of the backends
|
||||
will be released shortly after Mopidy itself.
|
||||
|
||||
Backend library providers
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
- **Removed:** Remove :meth:`mopidy.backend.LibraryProvider.find_exact`.
|
||||
|
||||
- Add an ``exact`` keyword argument to
|
||||
:meth:`mopidy.backend.LibraryProvider.search` to replace the old
|
||||
:meth:`~mopidy.backend.LibraryProvider.find_exact` method.
|
||||
|
||||
Backend playlist providers
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
- **Removed:** Remove default implementation of
|
||||
:attr:`mopidy.backend.PlaylistsProvider.playlists`. This is potentially
|
||||
backwards incompatible. (PR: :issue:`1046`)
|
||||
|
||||
- Changed the API for :class:`mopidy.backend.PlaylistsProvider`. Note that this
|
||||
change is **not** backwards compatible. These changes are important to reduce
|
||||
the Mopidy startup time. (Fixes: :issue:`1057`, PR: :issue:`1075`)
|
||||
|
||||
- Add :meth:`mopidy.backend.PlaylistsProvider.as_list`.
|
||||
|
||||
- Add :meth:`mopidy.backend.PlaylistsProvider.get_items`.
|
||||
|
||||
- Remove :attr:`mopidy.backend.PlaylistsProvider.playlists` property.
|
||||
|
||||
Backend playback providers
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
- Changed the API for :class:`mopidy.backend.PlaybackProvider`. Note that this
|
||||
change is **not** backwards compatible for certain backends. These changes
|
||||
are crucial to adding gapless in one of the upcoming releases.
|
||||
(Fixes: :issue:`1052`, PR: :issue:`1064`)
|
||||
|
||||
- :meth:`mopidy.backend.PlaybackProvider.translate_uri` has been added. It is
|
||||
strongly recommended that all backends migrate to using this API for
|
||||
translating "Mopidy URIs" to real ones for playback.
|
||||
|
||||
- The semantics and signature of :meth:`mopidy.backend.PlaybackProvider.play`
|
||||
has changed. The method is now only used to set the playback state to
|
||||
playing, and no longer takes a track.
|
||||
|
||||
Backends must migrate to
|
||||
:meth:`mopidy.backend.PlaybackProvider.translate_uri` or
|
||||
:meth:`mopidy.backend.PlaybackProvider.change_track` to continue working.
|
||||
|
||||
- :meth:`mopidy.backend.PlaybackProvider.prepare_change` has been added.
|
||||
|
||||
Models
|
||||
------
|
||||
|
||||
- Add :class:`mopidy.models.Image` model to be returned by
|
||||
:meth:`mopidy.core.LibraryController.get_images`. (Part of :issue:`973`)
|
||||
|
||||
- Change the semantics of :attr:`mopidy.models.Track.last_modified` to be
|
||||
milliseconds instead of seconds since Unix epoch, or a simple counter,
|
||||
depending on the source of the track. This makes it match the semantics of
|
||||
:attr:`mopidy.models.Playlist.last_modified`. (Fixes: :issue:`678`, PR:
|
||||
:issue:`1036`)
|
||||
|
||||
Commands
|
||||
--------
|
||||
|
||||
- Make the ``mopidy`` command print a friendly error message if the
|
||||
:mod:`gobject` Python module cannot be imported. (Fixes: :issue:`836`)
|
||||
|
||||
**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:
|
||||
:issue:`697`, PR: :issue:`802`)
|
||||
- Add path to the current ``mopidy`` executable to the output of ``mopidy
|
||||
deps``. This make it easier to see that a user is using pip-installed Mopidy
|
||||
instead of APT-installed Mopidy without asking for ``which mopidy`` output.
|
||||
|
||||
- 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`)
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
**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`,
|
||||
PR: :isusue:`874`)
|
||||
Logging
|
||||
-------
|
||||
|
||||
**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
|
||||
a whitespace. Pipes are more similar to forward slash.
|
||||
- Add support for per logger color overrides. (Fixes: :issue:`808`, PR:
|
||||
:issue:`1005`)
|
||||
|
||||
Local backend
|
||||
-------------
|
||||
|
||||
- Improve error logging for scanner. (Fixes: :issue:`856`, PR: :issue:`874`)
|
||||
|
||||
- Add symlink support with loop protection to file finder. (Fixes:
|
||||
:issue:`858`, PR: :issue:`874`)
|
||||
|
||||
- Add ``--force`` option for ``mopidy local scan`` for forcing a full rescan of
|
||||
the library. (Fixes: :issue:`910`, PR: :issue:`1010`)
|
||||
|
||||
- Stop ignoring ``offset`` and ``limit`` in searches when using the default
|
||||
JSON backed local library. (Fixes: :issue:`917`, PR: :issue:`949`)
|
||||
|
||||
- Removed double triggering of ``playlists_loaded`` event.
|
||||
(Fixes: :issue:`998`, PR: :issue:`999`)
|
||||
|
||||
- Cleanup and refactoring of local playlist code. Preserves playlist names
|
||||
better and fixes bug in deletion of playlists. (Fixes: :issue:`937`,
|
||||
PR: :issue:`995` and rebased into :issue:`1000`)
|
||||
|
||||
- Sort local playlists by name. (Fixes: :issue:`1026`, PR: :issue:`1028`)
|
||||
|
||||
- Moved playlist support out to a new extension, :ref:`ext-m3u`.
|
||||
|
||||
- *Deprecated:* The config value :confval:`local/playlists_dir` is no longer in
|
||||
use and can be removed from your config.
|
||||
|
||||
Local library API
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
||||
- Implementors of :meth:`mopidy.local.Library.lookup` should now return a list
|
||||
of :class:`~mopidy.models.Track` instead of a single track, just like the
|
||||
other ``lookup()`` methods in Mopidy. For now, returning a single track will
|
||||
continue to work. (PR: :issue:`840`)
|
||||
|
||||
- Add support for giving local libraries direct access to tags and duration.
|
||||
(Fixes: :issue:`967`)
|
||||
|
||||
- Add :meth:`mopidy.local.Library.get_images` for looking up images
|
||||
for local URIs. (Fixes: :issue:`1031`, PR: :issue:`1032` and :issue:`1037`)
|
||||
|
||||
Stream backend
|
||||
--------------
|
||||
|
||||
- Add support for HTTP proxies when doing initial metadata lookup for a stream.
|
||||
(Fixes :issue:`390`, PR: :issue:`982`)
|
||||
|
||||
- Add basic tests for the stream library provider.
|
||||
|
||||
M3U backend
|
||||
-----------
|
||||
|
||||
- Mopidy-M3U is a new bundled backend. It provides the same M3U support as was
|
||||
previously part of the local backend. See :ref:`m3u-migration` for how to
|
||||
migrate your local playlists to work with the M3U backend. (Fixes:
|
||||
:issue:`1054`, PR: :issue:`1066`)
|
||||
|
||||
- In playlist names, replace "/", which are illegal in M3U file names,
|
||||
with "|". (PR: :issue:`1084`)
|
||||
|
||||
MPD frontend
|
||||
------------
|
||||
|
||||
- Add support for blacklisting MPD commands. This is used to prevent clients
|
||||
from using ``listall`` and ``listallinfo`` which recursively lookup the entire
|
||||
"database". If you insist on using a client that needs these commands change
|
||||
:confval:`mpd/command_blacklist`.
|
||||
|
||||
- Start setting the ``Name`` field with the stream title when listening to
|
||||
radio streams. (Fixes: :issue:`944`, PR: :issue:`1030`)
|
||||
|
||||
- Enable browsing of artist references, in addition to albums and playlists.
|
||||
(PR: :issue:`884`)
|
||||
|
||||
**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
|
||||
:class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` to end the
|
||||
stream.
|
||||
- In stored playlist names, replace "/", which are illegal, with "|" instead of
|
||||
a whitespace. Pipes are more similar to forward slash.
|
||||
|
||||
- Share a single mapping between names and URIs across all MPD sessions. (Fixes:
|
||||
:issue:`934`, PR: :issue:`968`)
|
||||
|
||||
- Add support for ``toggleoutput`` command. (PR: :issue:`1015`)
|
||||
|
||||
- The ``mixrampdb`` and ``mixrampdelay`` commands are now known to Mopidy, but
|
||||
are not implemented. (PR: :issue:`1015`)
|
||||
|
||||
- Fix crash on socket error when using a locale causing the exception's error
|
||||
message to contain characters not in ASCII. (Fixes: issue:`971`, PR:
|
||||
:issue:`1044`)
|
||||
|
||||
HTTP frontend
|
||||
-------------
|
||||
|
||||
- **Deprecated:** Deprecated the :confval:`http/static_dir` config. Please make
|
||||
your web clients pip-installable Mopidy extensions to make it easier to
|
||||
install for end users.
|
||||
|
||||
- Prevent a race condition in WebSocket event broadcasting from crashing the
|
||||
web server. (PR: :issue:`1020`)
|
||||
|
||||
Mixers
|
||||
------
|
||||
|
||||
- Add support for disabling volume control in Mopidy entirely by setting the
|
||||
configuration :confval:`audio/mixer` to ``none``. (Fixes: :issue:`936`, PR:
|
||||
:issue:`1015`, :issue:`1035`)
|
||||
|
||||
Audio
|
||||
-----
|
||||
|
||||
- **Removed:** Support for visualizers and the :confval:`audio/visualizer`
|
||||
config value. The feature was originally added as a workaround for all the
|
||||
people asking for ncmpcpp visualizer support, and since we could get it
|
||||
almost for free thanks to GStreamer. But, this feature did never make sense
|
||||
for a server such as Mopidy.
|
||||
|
||||
- **Deprecated:** Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`.
|
||||
Pass a :class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` to end
|
||||
the stream. This should only affect Mopidy-Spotify.
|
||||
|
||||
- Add :meth:`mopidy.audio.AudioListener.tags_changed`. Notifies core when new
|
||||
tags are found.
|
||||
|
||||
- Add :meth:`mopidy.audio.Audio.get_current_tags` for looking up the current
|
||||
tags of the playing media.
|
||||
|
||||
- Internal code cleanup within audio subsystem:
|
||||
|
||||
@ -65,40 +436,59 @@ v0.20.0 (UNRELEASED)
|
||||
|
||||
- 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 tags + duration. Mtime, uri and min
|
||||
length handling are now outside of this class.
|
||||
- Reduce scope of audio scanner to just find tags and duration. Modification
|
||||
time, URI and minimum length handling are now outside of this class.
|
||||
|
||||
- Update scanner to operate with milliseconds for duration.
|
||||
|
||||
- Add :meth:`mopidy.audio.AudioListener.tags_changed`. Notifies core when new tags
|
||||
are found.
|
||||
- Update scanner to use a custom source, typefind and decodebin. This allows
|
||||
us to detect playlists before we try to decode them.
|
||||
|
||||
- Add :meth:`mopidy.audio.Audio.get_current_tags` for looking up the current
|
||||
tags of the playing media.
|
||||
- Refactored scanner to create a new pipeline per track, this is needed as
|
||||
reseting decodebin is much slower than tearing it down and making a fresh
|
||||
one.
|
||||
|
||||
- Move and rename helper for converting tags to tracks.
|
||||
|
||||
- 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
|
||||
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.
|
||||
- Support UTF-8 in M3U playlists. (Fixes: :issue:`853`)
|
||||
|
||||
**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)
|
||||
@ -571,6 +961,7 @@ guys. Thanks to everyone that has contributed!
|
||||
|
||||
- The dummy backend used for testing many frontends have moved from
|
||||
:mod:`mopidy.backends.dummy` to :mod:`mopidy.backend.dummy`.
|
||||
(PR: :issue:`984`)
|
||||
|
||||
**Commands**
|
||||
|
||||
|
||||
@ -37,15 +37,13 @@ There are two ways Mopidy can be made available as an UPnP MediaRenderer:
|
||||
Using Mopidy-MPRIS and Rygel, or using Mopidy-MPD and upmpdcli.
|
||||
|
||||
|
||||
.. _upmpdcli:
|
||||
|
||||
upmpdcli
|
||||
--------
|
||||
|
||||
`upmpdcli <http://www.lesbonscomptes.com/upmpdcli/>`_ is recommended, since it
|
||||
is easier to setup, and offers `OpenHome <http://www.openhome.org> ohMedia`_
|
||||
compatibility. upmpdcli exposes a UPnP MediaRenderer to the network, while
|
||||
using the MPD protocol to control Mopidy.
|
||||
is easier to setup, and offers `OpenHome
|
||||
<http://www.openhome.org/wiki/OhMedia>`_ compatibility. upmpdcli exposes a UPnP
|
||||
MediaRenderer to the network, while using the MPD protocol to control Mopidy.
|
||||
|
||||
1. Install upmpdcli. On Debian/Ubuntu::
|
||||
|
||||
@ -68,8 +66,6 @@ using the MPD protocol to control Mopidy.
|
||||
4. A UPnP renderer should be available now.
|
||||
|
||||
|
||||
.. _rygel:
|
||||
|
||||
Rygel
|
||||
-----
|
||||
|
||||
|
||||
@ -43,7 +43,7 @@ Options
|
||||
|
||||
.. cmdoption:: --verbose, -v
|
||||
|
||||
Show more output. Repeat up to 3 times for even more.
|
||||
Show more output. Repeat up to four times for even more.
|
||||
|
||||
.. cmdoption:: --save-debug-log
|
||||
|
||||
|
||||
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):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
@ -34,11 +35,13 @@ class Mock(object):
|
||||
elif name == 'get_user_config_dir':
|
||||
# glib.get_user_config_dir()
|
||||
return str
|
||||
elif (name[0] == name[0].upper()
|
||||
elif (name[0] == name[0].upper() and
|
||||
# gst.Caps
|
||||
not name.startswith('Caps') and
|
||||
# gst.PadTemplate
|
||||
and not name.startswith('PadTemplate')
|
||||
not name.startswith('PadTemplate') and
|
||||
# dbus.String()
|
||||
and not name == 'String'):
|
||||
not name == 'String'):
|
||||
return type(name, (), {})
|
||||
else:
|
||||
return Mock()
|
||||
@ -112,6 +115,9 @@ modindex_common_prefix = ['mopidy.']
|
||||
|
||||
# -- Options for HTML output --------------------------------------------------
|
||||
|
||||
# 'sphinx_rtd_theme' is bundled with Sphinx 1.3, which we don't have when
|
||||
# building the docs as part of the Debian packages on e.g. Debian wheezy.
|
||||
# html_theme = 'sphinx_rtd_theme'
|
||||
html_theme = 'default'
|
||||
html_theme_path = ['_themes']
|
||||
html_static_path = ['_static']
|
||||
@ -155,6 +161,7 @@ man_pages = [
|
||||
extlinks = {
|
||||
'issue': ('https://github.com/mopidy/mopidy/issues/%s', '#'),
|
||||
'commit': ('https://github.com/mopidy/mopidy/commit/%s', 'commit '),
|
||||
'js': ('https://github.com/mopidy/mopidy.js/issues/%s', 'mopidy.js#'),
|
||||
'mpris': (
|
||||
'https://github.com/mopidy/mopidy-mpris/issues/%s', 'mopidy-mpris#'),
|
||||
'discuss': ('https://discuss.mopidy.com/t/%s', 'discuss.mopidy.com/t/'),
|
||||
|
||||
@ -70,6 +70,8 @@ Audio configuration
|
||||
will affect the audio volume if you're streaming the audio from Mopidy
|
||||
through Shoutcast.
|
||||
|
||||
If you want to disable audio mixing set the value to ``none``.
|
||||
|
||||
If you want to use a hardware mixer, you need to install a Mopidy extension
|
||||
which integrates with your sound subsystem. E.g. for ALSA, install
|
||||
`Mopidy-ALSAMixer <https://github.com/mopidy/mopidy-alsamixer>`_.
|
||||
@ -131,6 +133,14 @@ Logging configuration
|
||||
level to use for that logger, one of ``debug``, ``info``, ``warning``,
|
||||
``error``, or ``critical``.
|
||||
|
||||
.. confval:: logcolors/*
|
||||
|
||||
The ``logcolors`` config section can be used to change the log color for
|
||||
specific parts of Mopidy during development or debugging. Each key in the
|
||||
config section should match the name of a logger. The value is the color
|
||||
to use for that logger, one of ``black``, ``red``, ``green``, ``yellow``,
|
||||
``blue``, ``magenta``, ``cyan`` or ``white``.
|
||||
|
||||
.. _the Python logging docs: http://docs.python.org/2/library/logging.config.html
|
||||
|
||||
|
||||
|
||||
@ -4,128 +4,116 @@
|
||||
Contributing
|
||||
************
|
||||
|
||||
If you are thinking about making Mopidy better, or you just want to hack on it,
|
||||
that’s great. Here are some tips to get you started.
|
||||
If you want to contribute to Mopidy, here are some tips to get you started.
|
||||
|
||||
|
||||
Getting started
|
||||
===============
|
||||
.. _asking-questions:
|
||||
|
||||
#. Make sure you have a `GitHub account <https://github.com/signup/free>`_.
|
||||
Asking questions
|
||||
================
|
||||
|
||||
#. `Submit <https://github.com/mopidy/mopidy/issues/new>`_ a ticket for your
|
||||
issue, assuming one does not already exist. Clearly describe the issue
|
||||
including steps to reproduce when it is a bug.
|
||||
Please get in touch with us in one of these ways when requesting help with
|
||||
Mopidy and its extensions:
|
||||
|
||||
#. Fork the repository on GitHub.
|
||||
- Our discussion forum: `discuss.mopidy.com <https://discuss.mopidy.com>`_.
|
||||
Just sign in and fire away.
|
||||
|
||||
- Our IRC channel: `#mopidy <https://webchat.freenode.net/?channels=#mopidy>`_
|
||||
on `irc.freenode.net <http://freenode.net>`_,
|
||||
with public `searchable logs <https://botbot.me/freenode/mopidy/>`_. Be
|
||||
prepared to hang around for a while, as we're not always around to answer
|
||||
straight away.
|
||||
|
||||
Before asking for help, it might be worth your time to read the
|
||||
:ref:`troubleshooting` page, both so you might find a solution to your problem
|
||||
but also to be able to provide useful details when asking for help.
|
||||
|
||||
|
||||
Making changes
|
||||
==============
|
||||
Helping users
|
||||
=============
|
||||
|
||||
#. Clone your fork on GitHub to your computer.
|
||||
|
||||
#. Consider making a Python `virtualenv <http://www.virtualenv.org/>`_ for
|
||||
Mopidy development to wall of Mopidy and it's dependencies from the rest of
|
||||
your system. If you do so, create the virtualenv with the
|
||||
``--system-site-packages`` flag so that Mopidy can use globally installed
|
||||
dependencies like GStreamer. If you don't use a virtualenv, you may need to
|
||||
run the following ``pip`` and ``python setup.py`` commands with ``sudo`` to
|
||||
install stuff globally on your computer.
|
||||
|
||||
#. Install dependencies as described in the :ref:`installation` section.
|
||||
|
||||
#. Install additional development dependencies::
|
||||
|
||||
pip install -r dev-requirements.txt
|
||||
|
||||
#. Checkout a new branch (usually based on ``develop``) and name it accordingly
|
||||
to what you intend to do.
|
||||
|
||||
- Features get the prefix ``feature/``
|
||||
|
||||
- Bug fixes get the prefix ``fix/``
|
||||
|
||||
- Improvements to the documentation get the prefix ``docs/``
|
||||
If you want to contribute to Mopidy, a great place to start is by helping other
|
||||
users on IRC and in the discussion forum. This is a contribution we value
|
||||
highly. As more people help with user support, new users get faster and better
|
||||
help. For your own benefit, you'll quickly learn what users find confusing,
|
||||
difficult or lacking, giving you some ideas for where you may contribute
|
||||
improvements, either to code or documentation. Lastly, this may also free up
|
||||
time for other contributors to spend more time on fixing bugs or implementing
|
||||
new features.
|
||||
|
||||
|
||||
.. _run-from-git:
|
||||
.. _issue-guidelines:
|
||||
|
||||
Running Mopidy from Git
|
||||
Issue guidelines
|
||||
================
|
||||
|
||||
#. If you need help, see :ref:`asking-questions` above. The GitHub issue
|
||||
tracker is not a support forum.
|
||||
|
||||
#. If you are not sure if what you're experiencing is a bug or not, post in the
|
||||
`discussion forum <https://discuss.mopidy.com>`__ first to verify that it's
|
||||
a bug.
|
||||
|
||||
#. If you are sure that you've found a bug or have a feature request, check if
|
||||
there's already an issue in the `issue tracker
|
||||
<https://github.com/mopidy/mopidy/issues>`_. If there is, see if there is
|
||||
anything you can add to help reproduce or fix the issue.
|
||||
|
||||
#. If there is no exising issue matching your bug or feature request, create a
|
||||
`new issue <https://github.com/mopidy/mopidy/issues/new>`_. Please include
|
||||
as much relevant information as possible. If it's a bug, including how to
|
||||
reproduce the bug and any relevant logs or error messages.
|
||||
|
||||
|
||||
Pull request guidelines
|
||||
=======================
|
||||
|
||||
If you want to hack on Mopidy, you should run Mopidy directly from the Git
|
||||
repo.
|
||||
#. Before spending any time on making a pull request:
|
||||
|
||||
#. Go to the Git repo root::
|
||||
- If it's a bug, :ref:`file an issue <issue-guidelines>`.
|
||||
|
||||
cd mopidy/
|
||||
- If it's an enhancement, discuss it with other Mopidy developers first,
|
||||
either in a GitHub issue, on the discussion forum, or on IRC. Making sure
|
||||
your ideas and solutions are aligned with other contributors greatly
|
||||
increases the odds of your pull request being quickly accepted.
|
||||
|
||||
#. To get a ``mopidy`` executable and register all bundled extensions with
|
||||
setuptools, run::
|
||||
#. Create a new branch, based on the ``develop`` branch, for every feature or
|
||||
bug fix. Keep branches small and on topic, as that makes them far easier to
|
||||
review. We often use the following naming convention for branches:
|
||||
|
||||
python setup.py develop
|
||||
- Features get the prefix ``feature/``, e.g.
|
||||
``feature/track-last-modified-as-ms``.
|
||||
|
||||
It still works to run ``python mopidy`` directly on the ``mopidy`` Python
|
||||
package directory, but if you have never run ``python setup.py develop`` the
|
||||
extensions bundled with Mopidy isn't registered with setuptools, so Mopidy
|
||||
will start without any frontends or backends, making it quite useless.
|
||||
- Bug fixes get the prefix ``fix/``, e.g. ``fix/902-consume-track-on-next``.
|
||||
|
||||
#. Now you can run the Mopidy command, and it will run using the code
|
||||
in the Git repo::
|
||||
- Improvements to the documentation get the prefix ``docs/``, e.g.
|
||||
``docs/add-ext-mopidy-spotify-tunigo``.
|
||||
|
||||
mopidy
|
||||
#. Follow the :ref:`code style <codestyle>`, especially make sure the
|
||||
``flake8`` linter does not complain about anything. Travis CI will check
|
||||
that your pull request is "flake8 clean". See :ref:`code-linting`.
|
||||
|
||||
If you do any changes to the code, you'll just need to restart ``mopidy``
|
||||
to see the changes take effect.
|
||||
#. Include tests for any new feature or substantial bug fix. See
|
||||
:ref:`running-tests`.
|
||||
|
||||
#. Include documentation for any new feature. See :ref:`writing-docs`.
|
||||
|
||||
Testing
|
||||
=======
|
||||
#. Feel free to include a changelog entry in your pull request. The changelog
|
||||
is in :file:`docs/changelog.rst`.
|
||||
|
||||
Mopidy has quite good test coverage, and we would like all new code going into
|
||||
Mopidy to come with tests.
|
||||
#. Write good commit messages.
|
||||
|
||||
#. To run all tests, go to the project directory and run::
|
||||
- Follow the template "topic: description" for the first line of the commit
|
||||
message, e.g. "mpd: Switch list command to using list_distinct". See the
|
||||
commit history for inspiration.
|
||||
|
||||
nosetests
|
||||
- Use the rest of the commit message to explain anything you feel isn't
|
||||
obvious. It's better to have the details here than in the pull request
|
||||
description, since the commit message will live forever.
|
||||
|
||||
To run tests with test coverage statistics::
|
||||
- Write in the imperative, present tense: "add" not "added".
|
||||
|
||||
nosetests --with-coverage
|
||||
|
||||
Test coverage statistics can also be viewed online at
|
||||
`coveralls.io <https://coveralls.io/r/mopidy/mopidy>`_.
|
||||
|
||||
#. Always check the code for errors and style issues using flake8::
|
||||
|
||||
flake8
|
||||
|
||||
If successful, the command will not print anything at all. Ignore the rare
|
||||
cases you need to ignore a check use `# noqa: <code>` so we can lookup what
|
||||
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:
|
||||
For more inspiration, feel free to read these blog posts:
|
||||
|
||||
- `Writing Git commit messages
|
||||
<http://365git.tumblr.com/post/3308646748/writing-git-commit-messages>`_
|
||||
@ -136,17 +124,5 @@ Submitting changes
|
||||
- `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
|
||||
#. 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 package
|
||||
**************
|
||||
***************
|
||||
Debian packages
|
||||
***************
|
||||
|
||||
The Mopidy Debian package is available from `apt.mopidy.com
|
||||
The Mopidy Debian package, ``mopidy``, is available from `apt.mopidy.com
|
||||
<http://apt.mopidy.com/>`__ as well as from Debian, Ubuntu and other
|
||||
Debian-based Linux distributions.
|
||||
|
||||
Some extensions are also available from all of these sources, while others,
|
||||
like Mopidy-Spotify and its dependencies, are only available from
|
||||
apt.mopidy.com. This may either be temporary until the package is uploaded to
|
||||
Debian and with time propagates to the other distributions. It may also be more
|
||||
long term, like in the Mopidy-Spotify case where there is uncertainities around
|
||||
licensing and distribution of non-free packages.
|
||||
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
593
docs/devenv.rst
Normal file
593
docs/devenv.rst
Normal file
@ -0,0 +1,593 @@
|
||||
.. _devenv:
|
||||
|
||||
***********************
|
||||
Development environment
|
||||
***********************
|
||||
|
||||
This page describes a common development setup for working with Mopidy and
|
||||
Mopidy extensions. Of course, there may be other ways that work better for you
|
||||
and the tools you use, but here's one recommended way to do it.
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
|
||||
|
||||
Initial setup
|
||||
=============
|
||||
|
||||
The following steps help you get a good initial setup. They build on each other
|
||||
to some degree, so if you're not very familiar with Python development it might
|
||||
be wise to proceed in the order laid out here.
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
|
||||
|
||||
Install Mopidy the regular way
|
||||
------------------------------
|
||||
|
||||
Install Mopidy the regular way. Mopidy has some non-Python dependencies which
|
||||
may be tricky to install. Thus we recommend to always start with a full regular
|
||||
Mopidy install, as described in :ref:`installation`. That is, if you're running
|
||||
e.g. Debian, start with installing Mopidy from Debian packages.
|
||||
|
||||
|
||||
Make a development workspace
|
||||
----------------------------
|
||||
|
||||
Make a directory to be used as a workspace for all your Mopidy development::
|
||||
|
||||
mkdir ~/mopidy-dev
|
||||
|
||||
It will contain all the Git repositories you'll check out when working on
|
||||
Mopidy and extensions.
|
||||
|
||||
|
||||
Make a virtualenv
|
||||
-----------------
|
||||
|
||||
Make a Python `virtualenv <https://virtualenv.pypa.io/>`_ for Mopidy
|
||||
development. The virtualenv will wall off Mopidy and its dependencies from the
|
||||
rest of your system. All development and installation of Python dependencies,
|
||||
versions of Mopidy, and extensions are done inside the virtualenv. This way
|
||||
your regular Mopidy install, which you set up in the first step, is unaffected
|
||||
by your hacking and will always be working.
|
||||
|
||||
Most of us use the `virtualenvwrapper
|
||||
<https://virtualenvwrapper.readthedocs.org/>`_ to ease working with
|
||||
virtualenvs, so that's what we'll be using for the examples here. First,
|
||||
install and setup virtualenvwrapper as described in their docs.
|
||||
|
||||
To create a virtualenv named ``mopidy`` which uses Python 2.7, allows access to
|
||||
system-wide packages like GStreamer, and uses the Mopidy workspace directory as
|
||||
the "project path", run::
|
||||
|
||||
mkvirtualenv -a ~/mopidy-dev --python `which python2.7` \
|
||||
--system-site-packages mopidy
|
||||
|
||||
Now, each time you open a terminal and want to activate the ``mopidy``
|
||||
virtualenv, run::
|
||||
|
||||
workon mopidy
|
||||
|
||||
This will both activate the ``mopidy`` virtualenv, and change the current
|
||||
working directory to ``~/mopidy-dev``.
|
||||
|
||||
|
||||
Clone the repo from GitHub
|
||||
--------------------------
|
||||
|
||||
Once inside the virtualenv, it's time to clone the ``mopidy/mopidy`` Git repo
|
||||
from GitHub::
|
||||
|
||||
git clone https://github.com/mopidy/mopidy.git
|
||||
|
||||
When you've cloned the ``mopidy`` Git repo, ``cd`` into it::
|
||||
|
||||
cd ~/mopidy-dev/mopidy/
|
||||
|
||||
With a fresh clone of the Git repo, you should start out on the ``develop``
|
||||
branch. This is where all features for the next feature release land. To
|
||||
confirm that you're on the right branch, run::
|
||||
|
||||
git branch
|
||||
|
||||
|
||||
Install development tools
|
||||
-------------------------
|
||||
|
||||
We use a number of Python development tools. The :file:`dev-requirements.txt`
|
||||
file has comments describing what we use each dependency for, so we might just
|
||||
as well include the file verbatim here:
|
||||
|
||||
.. literalinclude:: ../dev-requirements.txt
|
||||
|
||||
Install them all into the active virtualenv by running `pip
|
||||
<https://pip.pypa.io/>`_::
|
||||
|
||||
pip install --upgrade -r dev-requirements.txt
|
||||
|
||||
To upgrade the tools in the future, just rerun the exact same command.
|
||||
|
||||
|
||||
Install Mopidy from the Git repo
|
||||
--------------------------------
|
||||
|
||||
Next up, we'll want to run Mopidy from the Git repo. There's two reasons for
|
||||
this: first of all, it lets you easily change the source code, restart Mopidy,
|
||||
and see the change take effect. Second, it's a convenient way to keep at the
|
||||
bleeding edge, testing the latest developments in Mopidy itself or test some
|
||||
extension against the latest Mopidy changes.
|
||||
|
||||
Assuming you're still inside the Git repo, use pip to install Mopidy from the
|
||||
Git repo in an "editable" form::
|
||||
|
||||
pip install --editable .
|
||||
|
||||
This will not copy the source code into the virtualenv's ``site-packages``
|
||||
directory, but instead create a link there pointing to the Git repo. Using
|
||||
``cdsitepackages`` from virtualenvwrapper, we can quickly show that the
|
||||
installed :file:`Mopidy.egg-link` file points back to the Git repo::
|
||||
|
||||
$ cdsitepackages
|
||||
$ cat Mopidy.egg-link
|
||||
/home/user/mopidy-dev/mopidy
|
||||
.%
|
||||
$
|
||||
|
||||
It will also create a ``mopidy`` executable inside the virtualenv that will
|
||||
always run the latest code from the Git repo. Using another
|
||||
virtualenvwrapper command, ``cdvirtualenv``, we can show that too::
|
||||
|
||||
$ cdvirtualenv
|
||||
$ cat bin/mopidy
|
||||
...
|
||||
|
||||
The executable should contain something like this, using :mod:`pkg_resources`
|
||||
to look up Mopidy's "console script" entry point::
|
||||
|
||||
#!/home/user/virtualenvs/mopidy/bin/python2
|
||||
# EASY-INSTALL-ENTRY-SCRIPT: 'Mopidy==0.19.5','console_scripts','mopidy'
|
||||
__requires__ = 'Mopidy==0.19.5'
|
||||
import sys
|
||||
from pkg_resources import load_entry_point
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(
|
||||
load_entry_point('Mopidy==0.19.5', 'console_scripts', 'mopidy')()
|
||||
)
|
||||
|
||||
.. note::
|
||||
|
||||
It still works to run ``python mopidy`` directly on the
|
||||
:file:`~/mopidy-dev/mopidy/mopidy/` Python package directory, but if
|
||||
you don't run the ``pip install`` command above, the extensions bundled
|
||||
with Mopidy will not be registered with :mod:`pkg_resources`, making Mopidy
|
||||
quite useless.
|
||||
|
||||
Third, the ``pip install`` command will register the bundled Mopidy
|
||||
extensions so that Mopidy may find them through :mod:`pkg_resources`. The
|
||||
result of this can be seen in the Git repo, in a new directory called
|
||||
:file:`Mopidy.egg-info`, which is ignored by Git. The
|
||||
:file:`Mopidy.egg-info/entry_points.txt` file is of special interest as it
|
||||
shows both how the above executable and the bundled extensions are connected to
|
||||
the Mopidy source code:
|
||||
|
||||
.. code-block:: ini
|
||||
|
||||
[console_scripts]
|
||||
mopidy = mopidy.__main__:main
|
||||
|
||||
[mopidy.ext]
|
||||
http = mopidy.http:Extension
|
||||
local = mopidy.local:Extension
|
||||
mpd = mopidy.mpd:Extension
|
||||
softwaremixer = mopidy.softwaremixer:Extension
|
||||
stream = mopidy.stream:Extension
|
||||
|
||||
.. warning::
|
||||
|
||||
It's not uncommon to clean up in the Git repo now and then, e.g. by running
|
||||
``git clean``.
|
||||
|
||||
If you do this, then the :file:`Mopidy.egg-info` directory will be removed,
|
||||
and :mod:`pkg_resources` will no longer know how to locate the "console
|
||||
script" entry point or the bundled Mopidy extensions.
|
||||
|
||||
The fix is simply to run the install command again::
|
||||
|
||||
pip install --editable .
|
||||
|
||||
Finally, we can go back to the workspace, again using a virtualenvwrapper
|
||||
tool::
|
||||
|
||||
cdproject
|
||||
|
||||
|
||||
.. _running-from-git:
|
||||
|
||||
Running Mopidy from Git
|
||||
=======================
|
||||
|
||||
As long as the virtualenv is activated, you can start Mopidy from any
|
||||
directory. Simply run::
|
||||
|
||||
mopidy
|
||||
|
||||
To stop it again, press :kbd:`Ctrl+C`.
|
||||
|
||||
Every time you change code in Mopidy or an extension and want to see it
|
||||
live, you must restart Mopidy.
|
||||
|
||||
If you want to iterate quickly while developing, it may sound a bit tedious to
|
||||
restart Mopidy for every minor change. Then it's useful to have tests to
|
||||
exercise your code...
|
||||
|
||||
|
||||
.. _running-tests:
|
||||
|
||||
Running tests
|
||||
=============
|
||||
|
||||
Mopidy has quite good test coverage, and we would like all new code going into
|
||||
Mopidy to come with tests.
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
|
||||
|
||||
Test it all
|
||||
-----------
|
||||
|
||||
You need to know at least one command; the one that runs all the tests::
|
||||
|
||||
tox
|
||||
|
||||
This will run exactly the same tests as `Travis CI
|
||||
<https://travis-ci.org/mopidy/mopidy>`_ runs for all our branches and pull
|
||||
requests. If this command turns green, you can be quite confident that your
|
||||
pull request will get the green flag from Travis as well, which is a
|
||||
requirement for it to be merged.
|
||||
|
||||
As this is the ultimate test command, it's also the one taking the most time to
|
||||
run; up to a minute, depending on your system. But, if you have patience, this
|
||||
is all you need to know. Always run this command before pushing your changes to
|
||||
GitHub.
|
||||
|
||||
If you take a look at the tox config file, :file:`tox.ini`, you'll see that tox
|
||||
runs tests in multiple environments, including a ``flake8`` environment that
|
||||
lints the source code for issues and a ``docs`` environment that tests that the
|
||||
documentation can be built. You can also limit tox to just test specific
|
||||
environments using the ``-e`` option, e.g. to run just unit tests::
|
||||
|
||||
tox -e py27
|
||||
|
||||
To learn more, see the `tox documentation <http://tox.readthedocs.org/>`_ .
|
||||
|
||||
|
||||
Running unit tests
|
||||
------------------
|
||||
|
||||
Under the hood, ``tox -e py27`` will use `pytest <http://pytest.org/>`_ as the
|
||||
test runner. We can also use it directly to run all tests::
|
||||
|
||||
py.test
|
||||
|
||||
py.test has lots of possibilities, so you'll have to dive into their docs and
|
||||
plugins to get full benefit from it. To get you interested, here are some
|
||||
examples.
|
||||
|
||||
We can limit to just tests in a single directory to save time::
|
||||
|
||||
py.test tests/http/
|
||||
|
||||
With the help of the pytest-xdist plugin, we can run tests with four Python
|
||||
processes in parallel, which usually cuts the test time in half or more::
|
||||
|
||||
py.test -n 4
|
||||
|
||||
Another useful feature from pytest-xdist, is the possiblity to stop on the
|
||||
first test failure, watch the file system for changes, and then rerun the
|
||||
tests. This makes for a very quick code-test cycle::
|
||||
|
||||
py.test -f # or --looponfail
|
||||
|
||||
With the help of the pytest-cov plugin, we can get a report on what parts of
|
||||
the given module, ``mopidy`` in this example, are covered by the test suite::
|
||||
|
||||
py.test --cov=mopidy --cov-report=term-missing
|
||||
|
||||
.. note::
|
||||
|
||||
Up to date test coverage statistics can also be viewed online at
|
||||
`coveralls.io <https://coveralls.io/r/mopidy/mopidy>`_.
|
||||
|
||||
If we want to speed up the test suite, we can even get a list of the ten
|
||||
slowest tests::
|
||||
|
||||
py.test --durations=10
|
||||
|
||||
By now, you should be convinced that running py.test directly during
|
||||
development can be very useful.
|
||||
|
||||
|
||||
Continuous integration
|
||||
----------------------
|
||||
|
||||
Mopidy uses the free service `Travis CI <https://travis-ci.org/mopidy/mopidy>`_
|
||||
for automatically running the test suite when code is pushed to GitHub. This
|
||||
works both for the main Mopidy repo, but also for any forks. This way, any
|
||||
contributions to Mopidy through GitHub will automatically be tested by Travis
|
||||
CI, and the build status will be visible in the GitHub pull request interface,
|
||||
making it easier to evaluate the quality of pull requests.
|
||||
|
||||
For each successful build, Travis submits code coverage data to `coveralls.io
|
||||
<https://coveralls.io/r/mopidy/mopidy>`_. If you're out of work, coveralls might
|
||||
help you find areas in the code which could need better test coverage.
|
||||
|
||||
In addition, we run a Jenkins CI server at https://ci.mopidy.com/ that runs all
|
||||
tests on multiple platforms (Ubuntu, OS X, x86, arm) for every commit we push
|
||||
to the ``develop`` branch in the main Mopidy repo on GitHub. Thus, new code
|
||||
isn't tested by Jenkins before it is merged into the ``develop`` branch, which
|
||||
is a bit late, but good enough to get broad testing before new code is
|
||||
released.
|
||||
|
||||
|
||||
.. _code-linting:
|
||||
|
||||
Style checking and linting
|
||||
--------------------------
|
||||
|
||||
We're quite pedantic about :ref:`codestyle` and try hard to keep the Mopidy
|
||||
code base a very clean and nice place to work in.
|
||||
|
||||
Luckily, you can get very far by using the `flake8
|
||||
<http://flake8.readthedocs.org/>`_ linter to check your code for issues before
|
||||
submitting a pull request. Mopidy passes all of flake8's checks, with only a
|
||||
very few exceptions configured in :file:`setup.cfg`. You can either run the
|
||||
``flake8`` tox environment, like Travis CI will do on your pull request::
|
||||
|
||||
tox -e flake8
|
||||
|
||||
Or you can run flake8 directly::
|
||||
|
||||
flake8
|
||||
|
||||
If successful, the command will not print anything at all.
|
||||
|
||||
.. note::
|
||||
|
||||
In some rare cases it doesn't make sense to listen to flake8's warnings. In
|
||||
those cases, ignore the check by appending ``# noqa: <warning code>`` to
|
||||
the source line that triggers the warning. The ``# noqa`` part will make
|
||||
flake8 skip all checks on the line, while the warning code will help other
|
||||
developers lookup what you are ignoring.
|
||||
|
||||
|
||||
.. _writing-docs:
|
||||
|
||||
Writing documentation
|
||||
=====================
|
||||
|
||||
To write documentation, we use `Sphinx <http://sphinx-doc.org/>`_. See their
|
||||
site for lots of documentation on how to use Sphinx.
|
||||
|
||||
.. note::
|
||||
|
||||
To generate a few graphs which are part of the documentation, you need some
|
||||
additional dependencies. You can install them from APT with::
|
||||
|
||||
sudo apt-get install python-pygraphviz graphviz
|
||||
|
||||
To build the documentation, go into the :file:`docs/` directory::
|
||||
|
||||
cd ~/mopidy-dev/mopidy/docs/
|
||||
|
||||
Then, to see all available build targets, run::
|
||||
|
||||
make
|
||||
|
||||
To generate an HTML version of the documentation, run::
|
||||
|
||||
make html
|
||||
|
||||
The generated HTML will be available at :file:`_build/html/index.html`. To open
|
||||
it in a browser you can run either of the following commands, depending on your
|
||||
OS::
|
||||
|
||||
xdg-open _build/html/index.html # Linux
|
||||
open _build/html/index.html # OS X
|
||||
|
||||
The documentation at https://docs.mopidy.com/ is hosted by `Read the Docs
|
||||
<https://www.readhtedocs.org/>`_, which automatically updates the documentation
|
||||
when a change is pushed to the ``mopidy/mopidy`` repo at GitHub.
|
||||
|
||||
|
||||
Working on extensions
|
||||
=====================
|
||||
|
||||
Much of the above also applies to Mopidy extensions, though they're often a bit
|
||||
simpler. They don't have documentation sites and their test suites are either
|
||||
small and fast, or sadly missing entirely. Most of them use tox and flake8, and
|
||||
py.test can be used to run their test suites.
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
|
||||
|
||||
Installing extensions
|
||||
---------------------
|
||||
|
||||
As always, the ``mopidy`` virtualenv should be active when working on
|
||||
extensions::
|
||||
|
||||
workon mopidy
|
||||
|
||||
Just like with non-development Mopidy installations, you can install extensions
|
||||
using pip::
|
||||
|
||||
pip install Mopidy-Scrobbler
|
||||
|
||||
Installing an extension from its Git repo works the same way as with Mopidy
|
||||
itself. First, go to the Mopidy workspace::
|
||||
|
||||
cdproject # or cd ~/mopidy-dev/
|
||||
|
||||
Clone the desired Mopidy extension::
|
||||
|
||||
git clone https://github.com/mopidy/mopidy-spotify.git
|
||||
|
||||
Change to the newly created extension directory::
|
||||
|
||||
cd mopidy-spotify/
|
||||
|
||||
Then, install the extension in "editable" mode, so that it can be imported from
|
||||
anywhere inside the virtualenv and the extension is registered and discoverable
|
||||
through :mod:`pkg_resources`::
|
||||
|
||||
pip install --editable .
|
||||
|
||||
Every extension will have a ``README.rst`` file. It may contain information
|
||||
about extra dependencies required, development process, etc. Extensions usually
|
||||
have a changelog in the readme file.
|
||||
|
||||
|
||||
Upgrading extensions
|
||||
--------------------
|
||||
|
||||
Extensions often have a much quicker life cycle than Mopidy itself, often with
|
||||
daily releases in periods of active development. To find outdated extensions in
|
||||
your virtualenv, you can run::
|
||||
|
||||
pip search mopidy
|
||||
|
||||
This will list all available Mopidy extensions and compare the installed
|
||||
versions with the latest available ones.
|
||||
|
||||
To upgrade an extension installed with pip, simply use pip::
|
||||
|
||||
pip install --upgrade Mopidy-Scrobbler
|
||||
|
||||
To upgrade an extension installed from a Git repo, it's usually enough to pull
|
||||
the new changes in::
|
||||
|
||||
cd ~/mopidy-dev/mopidy-spotify/
|
||||
git pull
|
||||
|
||||
Of course, if you have local modifications, you'll need to stash these away on
|
||||
a branch or similar first.
|
||||
|
||||
Depending on the changes to the extension, it may be necessary to update the
|
||||
metadata about the extension package by installing it in "editable" mode
|
||||
again::
|
||||
|
||||
pip install --editable .
|
||||
|
||||
|
||||
Contribution workflow
|
||||
=====================
|
||||
|
||||
Before you being, make sure you've read the :ref:`contributing` page and the
|
||||
guidelines there. This section will focus more on the practical workflow.
|
||||
|
||||
For the examples, we're making a change to Mopidy. Approximately the same
|
||||
workflow should work for most Mopidy extensions too.
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
|
||||
|
||||
Setting up Git remotes
|
||||
----------------------
|
||||
|
||||
Assuming we already have a local Git clone of the upstream Git repo in
|
||||
:file:`~/mopidy-dev/mopidy/`, we can run ``git remote -v`` to list the
|
||||
configured remotes of the repo::
|
||||
|
||||
$ git remote -v
|
||||
origin https://github.com/mopidy/mopidy.git (fetch)
|
||||
origin https://github.com/mopidy/mopidy.git (push)
|
||||
|
||||
For clarity, we can rename the ``origin`` remote to ``upstream``::
|
||||
|
||||
$ git remote rename origin upstream
|
||||
$ git remote -v
|
||||
upstream https://github.com/mopidy/mopidy.git (fetch)
|
||||
upstream https://github.com/mopidy/mopidy.git (push)
|
||||
|
||||
If you haven't already, `fork the repository
|
||||
<https://help.github.com/articles/fork-a-repo/>`_ to your own GitHub account.
|
||||
|
||||
Then, add the new fork as a remote to your local clone::
|
||||
|
||||
git remote add myuser git@github.com:myuser/mopidy.git
|
||||
|
||||
The end result is that you have both the upstream repo and your own fork as
|
||||
remotes::
|
||||
|
||||
$ git remote -v
|
||||
myuser git@github.com:myuser/mopidy.git (fetch)
|
||||
myuser git@github.com:myuser/mopidy.git (push)
|
||||
upstream https://github.com/mopidy/mopidy.git (fetch)
|
||||
upstream https://github.com/mopidy/mopidy.git (push)
|
||||
|
||||
|
||||
Creating a branch
|
||||
-----------------
|
||||
|
||||
Fetch the latest data from all remotes without affecting your working
|
||||
directory::
|
||||
|
||||
git remote update
|
||||
|
||||
Now, we are ready to create and checkout a new branch off of the upstream
|
||||
``develop`` branch for our work::
|
||||
|
||||
git checkout -b fix/666-crash-on-foo upstream/develop
|
||||
|
||||
Do the work, while remembering to adhere to code style, test the changes, make
|
||||
necessary updates to the documentation, and making small commits with good
|
||||
commit messages. All as described in :ref:`contributing` and elsewhere in
|
||||
the :ref:`devenv` guide.
|
||||
|
||||
|
||||
Creating a pull request
|
||||
-----------------------
|
||||
|
||||
When everything is done and committed, push the branch to your fork on GitHub::
|
||||
|
||||
git push myuser fix/666-crash-on-foo
|
||||
|
||||
Go to the repository on GitHub where you want the change merged, in this case
|
||||
https://github.com/mopidy/mopidy, and `create a pull request
|
||||
<https://help.github.com/articles/creating-a-pull-request/>`_.
|
||||
|
||||
|
||||
Updating a pull request
|
||||
-----------------------
|
||||
|
||||
When the pull request is created, `Travis CI
|
||||
<https://travis-ci.org/mopidy/mopidy>`__ will run all tests on it. If something
|
||||
fails, you'll get notified by email. You might as well just fix the issues
|
||||
right away, as we won't merge a pull request without a green Travis build. See
|
||||
:ref:`running-tests` on how to run the same tests locally as Travis CI runs on
|
||||
your pull request.
|
||||
|
||||
When you've fixed the issues, you can update the pull request simply by pushing
|
||||
more commits to the same branch in your fork::
|
||||
|
||||
git push myuser fix/666-crash-on-foo
|
||||
|
||||
Likewise, when you get review comments from other developers on your pull
|
||||
request, you're expected to create additional commits which addresses the
|
||||
comments. Push them to your branch so that the pull request is updated.
|
||||
|
||||
.. note::
|
||||
|
||||
Setup the remote as the default push target for your branch::
|
||||
|
||||
git branch --set-upstream-to myuser/fix/666-crash-on-foo
|
||||
|
||||
Then you can push more commits without specifying the remote::
|
||||
|
||||
git push
|
||||
@ -90,6 +90,17 @@ Mopidy-Local
|
||||
Bundled with Mopidy. See :ref:`ext-local`.
|
||||
|
||||
|
||||
Mopidy-Local-Images
|
||||
===================
|
||||
|
||||
https://github.com/tkem/mopidy-local-images
|
||||
|
||||
Extension which plugs into Mopidy-Local to allow Web clients access to
|
||||
album art embedded in local media files. Not to be used on its own,
|
||||
but acting as a proxy between ``mopidy local scan`` and the actual
|
||||
local library provider being used.
|
||||
|
||||
|
||||
Mopidy-Local-SQLite
|
||||
===================
|
||||
|
||||
|
||||
@ -73,17 +73,21 @@ See :ref:`config` for general help on configuring Mopidy.
|
||||
|
||||
.. confval:: http/static_dir
|
||||
|
||||
**Deprecated:** This config is deprecated and will be removed in a future
|
||||
version of Mopidy.
|
||||
|
||||
Which directory the HTTP server should serve at "/"
|
||||
|
||||
Change this to have Mopidy serve e.g. files for your JavaScript client.
|
||||
"/mopidy" will continue to work as usual even if you change this setting.
|
||||
"/mopidy" will continue to work as usual even if you change this setting,
|
||||
but any other Mopidy webclient installed with pip to be served at
|
||||
"/ext_name" will stop working if you set this config.
|
||||
|
||||
This config value isn't deprecated yet, but you're strongly encouraged to
|
||||
make Mopidy extensions which use the the :ref:`http-server-api` to host
|
||||
static files on Mopidy's web server instead of using
|
||||
:confval:`http/static_dir`. That way, installation of your web client will
|
||||
be a lot easier for your end users, and multiple web clients can easily
|
||||
share the same web server.
|
||||
You're strongly encouraged to make Mopidy extensions which use the the
|
||||
:ref:`http-server-api` to host static files on Mopidy's web server instead
|
||||
of using :confval:`http/static_dir`. That way, installation of your web
|
||||
client will be a lot easier for your end users, and multiple web clients
|
||||
can easily share the same web server.
|
||||
|
||||
.. confval:: http/zeroconf
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Set to an empty string to disable Zeroconf for MPD.
|
||||
|
||||
.. confval:: mpd/command_blacklist
|
||||
|
||||
List of MPD commands which are disabled by the server. By default this
|
||||
setting blacklists ``listall`` and ``listallinfo``. These commands don't
|
||||
fit well with many of Mopidy's backends and are better left disabled unless
|
||||
you know what you are doing.
|
||||
|
||||
@ -30,17 +30,39 @@ To install, run::
|
||||
pip install Mopidy-API-Explorer
|
||||
|
||||
|
||||
Mopidy-HTTP-Kuechenradio
|
||||
=========================
|
||||
Mopidy-Local-Images
|
||||
===================
|
||||
|
||||
https://github.com/tkem/mopidy-http-kuechenradio
|
||||
https://github.com/tkem/mopidy-local-images
|
||||
|
||||
A deliberately simple Mopidy Web client for mobile devices. Made with jQuery
|
||||
Mobile by Thomas Kemmer.
|
||||
Not a full-featured Web client, but rather a local library and Web
|
||||
extension which allows other Web clients access to album art embedded
|
||||
in local media files.
|
||||
|
||||
.. image:: /ext/local_images.jpg
|
||||
:width: 640
|
||||
:height: 480
|
||||
|
||||
To install, run::
|
||||
|
||||
pip install Mopidy-HTTP-Kuechenradio
|
||||
pip install Mopidy-Local-Images
|
||||
|
||||
|
||||
Mopidy-Mobile
|
||||
=============
|
||||
|
||||
https://github.com/tkem/mopidy-mobile
|
||||
|
||||
A Mopidy Web client extension and hybrid mobile app, made with Ionic,
|
||||
AngularJS and Apache Cordova by Thomas Kemmer.
|
||||
|
||||
.. image:: /ext/mobile.png
|
||||
:width: 1024
|
||||
:height: 606
|
||||
|
||||
To install, run::
|
||||
|
||||
pip install Mopidy-Mobile
|
||||
|
||||
|
||||
Mopidy-Moped
|
||||
@ -64,12 +86,13 @@ Mopidy-Mopify
|
||||
|
||||
https://github.com/dirkgroenen/mopidy-mopify
|
||||
|
||||
An web client that mainly targets using Spotify through Mopidy. Made by Dirk
|
||||
Groenen.
|
||||
A web client that uses external web services to provide additional features and
|
||||
a more "complete" Spotify music experience. It's currently targeted at people
|
||||
using Spotify through Mopidy. Made by Dirk Groenen.
|
||||
|
||||
.. image:: /ext/mopify.png
|
||||
:width: 720
|
||||
:height: 424
|
||||
.. image:: /ext/mopify.jpg
|
||||
:width: 800
|
||||
:height: 416
|
||||
|
||||
To install, run::
|
||||
|
||||
|
||||
@ -189,11 +189,6 @@ class that will connect the rest of the dots.
|
||||
'Pykka >= 1.1',
|
||||
'pysoundspot',
|
||||
],
|
||||
test_suite='nose.collector',
|
||||
tests_require=[
|
||||
'nose',
|
||||
'mock >= 1.0',
|
||||
],
|
||||
entry_points={
|
||||
'mopidy.ext': [
|
||||
'soundspot = mopidy_soundspot:Extension',
|
||||
@ -312,12 +307,6 @@ This is ``mopidy_soundspot/__init__.py``::
|
||||
from .backend import SoundspotBackend
|
||||
registry.add('backend', SoundspotBackend)
|
||||
|
||||
# Register a custom GStreamer element
|
||||
from .mixer import SoundspotMixer
|
||||
gobject.type_register(SoundspotMixer)
|
||||
gst.element_register(
|
||||
SoundspotMixer, 'soundspotmixer', gst.RANK_MARGINAL)
|
||||
|
||||
# Or nothing to register e.g. command extension
|
||||
pass
|
||||
|
||||
@ -421,17 +410,6 @@ examples, see the :ref:`http-server-api` docs or explore with
|
||||
:ref:`http-explore-extension` extension.
|
||||
|
||||
|
||||
Example GStreamer element
|
||||
=========================
|
||||
|
||||
If you want to extend Mopidy's GStreamer pipeline with new custom GStreamer
|
||||
elements, you'll need to register them in GStreamer before they can be used.
|
||||
|
||||
Basically, you just implement your GStreamer element in Python and then make
|
||||
your :meth:`~mopidy.ext.Extension.setup` method register all your custom
|
||||
GStreamer elements.
|
||||
|
||||
|
||||
Running an extension
|
||||
====================
|
||||
|
||||
|
||||
@ -94,6 +94,7 @@ Extensions
|
||||
:maxdepth: 2
|
||||
|
||||
ext/local
|
||||
ext/m3u
|
||||
ext/stream
|
||||
ext/http
|
||||
ext/mpd
|
||||
@ -132,10 +133,11 @@ Development
|
||||
===========
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:maxdepth: 2
|
||||
|
||||
contributing
|
||||
devtools
|
||||
devenv
|
||||
releasing
|
||||
codestyle
|
||||
extensiondev
|
||||
|
||||
|
||||
@ -14,14 +14,27 @@ If you are running Arch Linux, you can install Mopidy using the
|
||||
|
||||
To upgrade Mopidy to future releases, just upgrade your system using::
|
||||
|
||||
yaourt -Syu
|
||||
|
||||
#. Optional: If you want to use any Mopidy extensions, like Spotify support or
|
||||
Last.fm scrobbling, AUR also has `packages for several Mopidy extensions
|
||||
<https://aur.archlinux.org/packages/?K=mopidy>`_.
|
||||
|
||||
For a full list of available Mopidy extensions, including those not
|
||||
installable from AUR, see :ref:`ext`.
|
||||
yaourt -Syua
|
||||
|
||||
#. Finally, you need to set a couple of :doc:`config values </config>`, and
|
||||
then you're ready to :doc:`run Mopidy </running>`.
|
||||
|
||||
|
||||
Installing extensions
|
||||
=====================
|
||||
|
||||
If you want to use any Mopidy extensions, like Spotify support or Last.fm
|
||||
scrobbling, AUR also has `packages for lots of Mopidy extensions
|
||||
<https://aur.archlinux.org/packages/?K=mopidy>`_.
|
||||
|
||||
You can also install any Mopidy extension directly from PyPI with ``pip``. To
|
||||
list all the extensions available from PyPI, run::
|
||||
|
||||
pip search mopidy
|
||||
|
||||
Note that extensions installed from PyPI will only automatically install Python
|
||||
dependencies. Please refer to the extension's documentation for information
|
||||
about any other requirements needed for the extension to work properly.
|
||||
|
||||
For a full list of available Mopidy extensions, including those not installable
|
||||
from AUR, see :ref:`ext`.
|
||||
|
||||
@ -52,20 +52,6 @@ from scratch, we have a guide for installing Debian/Raspbian and Mopidy. See
|
||||
sudo apt-get update
|
||||
sudo apt-get install mopidy
|
||||
|
||||
#. Optional: If you want to use any Mopidy extensions, like Spotify support or
|
||||
Last.fm scrobbling, you need to install additional packages.
|
||||
|
||||
To list all the extensions available from apt.mopidy.com, you can run::
|
||||
|
||||
apt-cache search mopidy
|
||||
|
||||
To install one of the listed packages, e.g. ``mopidy-spotify``, simply run::
|
||||
|
||||
sudo apt-get install mopidy-spotify
|
||||
|
||||
For a full list of available Mopidy extensions, including those not
|
||||
installable from apt.mopidy.com, see :ref:`ext`.
|
||||
|
||||
#. Before continuing, make sure you've read the :ref:`debian` section to learn
|
||||
about the differences between running Mopidy as a system service and
|
||||
manually as your own system user.
|
||||
@ -78,3 +64,71 @@ figure it out for itself, run the following to upgrade right away::
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get dist-upgrade
|
||||
|
||||
|
||||
Installing extensions
|
||||
=====================
|
||||
|
||||
If you want to use any Mopidy extensions, like Spotify support or Last.fm
|
||||
scrobbling, you need to install additional packages.
|
||||
|
||||
To list all the extensions available from apt.mopidy.com, you can run::
|
||||
|
||||
apt-cache search mopidy
|
||||
|
||||
To install one of the listed packages, e.g. ``mopidy-spotify``, simply run::
|
||||
|
||||
sudo apt-get install mopidy-spotify
|
||||
|
||||
You can also install any Mopidy extension directly from PyPI with ``pip``. To
|
||||
list all the extensions available from PyPI, run::
|
||||
|
||||
pip search mopidy
|
||||
|
||||
Note that extensions installed from PyPI will only automatically install Python
|
||||
dependencies. Please refer to the extension's documentation for information
|
||||
about any other requirements needed for the extension to work properly.
|
||||
|
||||
For a full list of available Mopidy extensions, including those not
|
||||
installable from apt.mopidy.com, see :ref:`ext`.
|
||||
|
||||
|
||||
Missing extensions
|
||||
==================
|
||||
|
||||
If you've installed a Mopidy extension with pip, restarted Mopidy, and Mopidy
|
||||
doesn't find the extension, there's probably a simple explanation and solution.
|
||||
|
||||
Mopidy installed with APT can detect and use Mopidy extensions installed with
|
||||
both APT and pip. APT installs Mopidy as :file:`/usr/bin/mopidy`.
|
||||
|
||||
Mopidy installed with pip can only detect Mopidy extensions installed with pip.
|
||||
pip usually installs Mopidy as :file:`/usr/local/bin/mopidy`.
|
||||
|
||||
If you have Mopidy installed from both APT and pip, then the pip-installed
|
||||
Mopidy will probably shadow the APT-installed Mopidy because
|
||||
:file:`/usr/local/bin` usually has precedence over :file:`/usr/bin` in the
|
||||
``PATH`` environment variable. To check if this is the case on your system, you
|
||||
can use ``which`` to see what installation of Mopidy you use when you run
|
||||
``mopidy`` in your shell::
|
||||
|
||||
$ which mopidy
|
||||
/usr/local/bin/mopidy
|
||||
|
||||
If this is the case on your system, the recommended solution is to check that
|
||||
you have Mopidy installed from APT too::
|
||||
|
||||
$ /usr/bin/mopidy --version
|
||||
Mopidy 0.19.5
|
||||
|
||||
And then uninstall the pip-installed Mopidy::
|
||||
|
||||
sudo pip uninstall mopidy
|
||||
|
||||
Depending on what shell you use, the shell may still try to use
|
||||
:file:`/usr/local/bin/mopidy` even if it no longer exists. Check again with
|
||||
``which mopidy`` what your shell believes is the right ``mopidy`` executable to
|
||||
run. If the shell is still confused, you may need to restart it, or in the case
|
||||
of zsh, run ``rehash`` to update the shell.
|
||||
|
||||
For more details on why this works this way, see :ref:`debian`.
|
||||
|
||||
@ -7,8 +7,9 @@ Installation
|
||||
There are several ways to install Mopidy. What way is best depends upon your OS
|
||||
and/or distribution.
|
||||
|
||||
If you want to contribute to the development of Mopidy, you should first read
|
||||
the general installation instructions, then have a look at :ref:`run-from-git`.
|
||||
If you want to contribute to the development of Mopidy, you should first follow
|
||||
the instructions here to install a regular install of Mopidy, then continue
|
||||
with reading :ref:`contributing` and :ref:`devenv`.
|
||||
|
||||
.. toctree::
|
||||
|
||||
|
||||
@ -57,16 +57,77 @@ If you are running OS X, you can install everything needed with Homebrew.
|
||||
|
||||
brew install mopidy
|
||||
|
||||
#. Optional: If you want to use any Mopidy extensions, like Spotify support or
|
||||
Last.fm scrobbling, the Homebrew tap has formulas for several Mopidy
|
||||
extensions as well.
|
||||
#. Finally, you need to set a couple of :doc:`config values </config>`, and
|
||||
then you're ready to :doc:`run Mopidy </running>`.
|
||||
|
||||
|
||||
Installing extensions
|
||||
=====================
|
||||
|
||||
If you want to use any Mopidy extensions, like Spotify support or Last.fm
|
||||
scrobbling, the Homebrew tap has formulas for several Mopidy extensions as
|
||||
well. Extensions installed from Homebrew will come complete with all
|
||||
dependencies, both Python and non-Python ones.
|
||||
|
||||
To list all the extensions available from our tap, you can run::
|
||||
|
||||
brew search mopidy
|
||||
|
||||
For a full list of available Mopidy extensions, including those not
|
||||
installable from Homebrew, see :ref:`ext`.
|
||||
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::
|
||||
|
||||
#. Finally, you need to set a couple of :doc:`config values </config>`, and
|
||||
then you're ready to :doc:`run Mopidy </running>`.
|
||||
pip search mopidy
|
||||
|
||||
Note that extensions installed from PyPI will only automatically install Python
|
||||
dependencies. Please refer to the extension's documentation for information
|
||||
about any other requirements needed for the extension to work properly.
|
||||
|
||||
For a full list of available Mopidy extensions, including those not installable
|
||||
from Homebrew, see :ref:`ext`.
|
||||
|
||||
|
||||
Running Mopidy automatically on login
|
||||
=====================================
|
||||
|
||||
On OS X, you can use launchd to start Mopidy automatically at login.
|
||||
|
||||
If you installed Mopidy from Homebrew, simply run ``brew info mopidy`` and
|
||||
follow the instructions in the "Caveats" section::
|
||||
|
||||
$ brew info mopidy
|
||||
...
|
||||
==> Caveats
|
||||
To have launchd start mopidy at login:
|
||||
ln -sfv /usr/local/opt/mopidy/*.plist ~/Library/LaunchAgents
|
||||
Then to load mopidy now:
|
||||
launchctl load ~/Library/LaunchAgents/homebrew.mopidy.mopidy.plist
|
||||
Or, if you don't want/need launchctl, you can just run:
|
||||
mopidy
|
||||
|
||||
If you happen to be on OS X, but didn't install Mopidy with Homebrew, you can
|
||||
get the same effect by adding the file
|
||||
:file:`~/Library/LaunchAgents/mopidy.plist` with the following contents::
|
||||
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>mopidy</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/mopidy</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
You might need to adjust the path to the ``mopidy`` executable,
|
||||
``/usr/local/bin/mopidy``, to match your system.
|
||||
|
||||
Then, to start Mopidy with launchd right away::
|
||||
|
||||
launchctl load ~/Library/LaunchAgents/mopidy.plist
|
||||
|
||||
@ -6,7 +6,10 @@ Install from source
|
||||
|
||||
If you are on Linux, but can't install :ref:`from the APT archive
|
||||
<debian-install>` or :ref:`from AUR <arch-install>`, you can install Mopidy
|
||||
from source by hand.
|
||||
from PyPI using the ``pip`` installer.
|
||||
|
||||
If you are looking to contribute or wish to install from source using ``git``
|
||||
please follow the directions :ref:`here <contributing>`.
|
||||
|
||||
#. First of all, you need Python 2.7. Check if you have Python and what
|
||||
version by running::
|
||||
@ -69,46 +72,32 @@ from source by hand.
|
||||
|
||||
sudo pip install -U mopidy
|
||||
|
||||
To upgrade Mopidy to future releases, just rerun this command.
|
||||
This will use ``pip`` to install the latest release of `Mopidy from PyPI
|
||||
<https://pypi.python.org/pypi/Mopidy>`_. To upgrade Mopidy to future
|
||||
releases, just rerun this command.
|
||||
|
||||
Alternatively, if you want to track Mopidy development closer, you may
|
||||
install a snapshot of Mopidy's ``develop`` Git branch using pip::
|
||||
|
||||
sudo pip install --allow-unverified=mopidy mopidy==dev
|
||||
|
||||
#. Optional: If you want Spotify support in Mopidy, you'll need to install
|
||||
libspotify and the Mopidy-Spotify extension.
|
||||
|
||||
#. Download and install the latest version of libspotify for your OS and CPU
|
||||
architecture from `Spotify
|
||||
<https://developer.spotify.com/technologies/libspotify/>`_.
|
||||
|
||||
For libspotify 12.1.51 for 64-bit Linux the process is as follows::
|
||||
|
||||
wget https://developer.spotify.com/download/libspotify/libspotify-12.1.51-Linux-x86_64-release.tar.gz
|
||||
tar zxfv libspotify-12.1.51-Linux-x86_64-release.tar.gz
|
||||
cd libspotify-12.1.51-Linux-x86_64-release/
|
||||
sudo make install prefix=/usr/local
|
||||
|
||||
Remember to adjust the above example for the latest libspotify version
|
||||
supported by pyspotify, your OS, and your CPU architecture.
|
||||
|
||||
#. If you're on Fedora, you must add a configuration file so libspotify.so
|
||||
can be found::
|
||||
|
||||
echo /usr/local/lib | sudo tee /etc/ld.so.conf.d/libspotify.conf
|
||||
sudo ldconfig
|
||||
|
||||
#. Then install the latest release of Mopidy-Spotify using pip::
|
||||
|
||||
sudo pip install -U mopidy-spotify
|
||||
|
||||
#. Optional: If you want to scrobble your played tracks to Last.fm, you need
|
||||
to install Mopidy-Scrobbler::
|
||||
|
||||
sudo pip install -U mopidy-scrobbler
|
||||
|
||||
#. For a full list of available Mopidy extensions, see :ref:`ext`.
|
||||
|
||||
#. Finally, you need to set a couple of :doc:`config values </config>`, and
|
||||
then you're ready to :doc:`run Mopidy </running>`.
|
||||
|
||||
|
||||
Installing extensions
|
||||
=====================
|
||||
|
||||
If you want to use any Mopidy extensions, like Spotify support or Last.fm
|
||||
scrobbling, you need to install additional Mopidy extensions.
|
||||
|
||||
You can install any Mopidy extension directly from PyPI with ``pip``. To list
|
||||
all the extensions available from PyPI, run::
|
||||
|
||||
pip search mopidy
|
||||
|
||||
Note that extensions installed from PyPI will only automatically install Python
|
||||
dependencies. Please refer to the extension's documentation for information
|
||||
about any other requirements needed for the extension to work properly.
|
||||
|
||||
For a full list of available Mopidy extensions see :ref:`ext`.
|
||||
|
||||
@ -1,51 +1,10 @@
|
||||
*****************
|
||||
Development tools
|
||||
*****************
|
||||
******************
|
||||
Release procedures
|
||||
******************
|
||||
|
||||
Here you'll find description of the development tools we use.
|
||||
|
||||
|
||||
Continuous integration
|
||||
======================
|
||||
|
||||
Mopidy uses the free service `Travis CI <https://travis-ci.org/mopidy/mopidy>`_
|
||||
for automatically running the test suite when code is pushed to GitHub. This
|
||||
works both for the main Mopidy repo, but also for any forks. This way, any
|
||||
contributions to Mopidy through GitHub will automatically be tested by Travis
|
||||
CI, and the build status will be visible in the GitHub pull request interface,
|
||||
making it easier to evaluate the quality of pull requests.
|
||||
|
||||
In addition, we run a Jenkins CI server at http://ci.mopidy.com/ that runs all
|
||||
test on multiple platforms (Ubuntu, OS X, x86, arm) for every commit we push to
|
||||
the ``develop`` branch in the main Mopidy repo on GitHub. Thus, new code isn't
|
||||
tested by Jenkins before it is merged into the ``develop`` branch, which is a
|
||||
bit late, but good enough to get broad testing before new code is released.
|
||||
|
||||
In addition to running tests, the Jenkins CI server also gathers coverage
|
||||
statistics and uses flake8 to check for errors and possible improvements in our
|
||||
code. So, if you're out of work, the code coverage and flake8 data at the CI
|
||||
server should give you a place to start.
|
||||
|
||||
|
||||
Documentation writing
|
||||
=====================
|
||||
|
||||
To write documentation, we use `Sphinx <http://sphinx-doc.org/>`_. See their
|
||||
site for lots of documentation on how to use Sphinx. To generate HTML from the
|
||||
documentation files, you need some additional dependencies.
|
||||
|
||||
You can install them through Debian/Ubuntu package management::
|
||||
|
||||
sudo apt-get install python-sphinx python-pygraphviz graphviz
|
||||
|
||||
Then, to generate docs::
|
||||
|
||||
cd docs/
|
||||
make # For help on available targets
|
||||
make html # To generate HTML docs
|
||||
|
||||
The documentation at http://docs.mopidy.com/ is automatically updated when a
|
||||
documentation update is pushed to ``mopidy/mopidy`` at GitHub.
|
||||
Here we try to keep an up to date record of how Mopidy releases are made. This
|
||||
documentation serves both as a checklist, to reduce the project's dependency on
|
||||
key individuals, and as a stepping stone to more automation.
|
||||
|
||||
|
||||
Creating releases
|
||||
@ -4,9 +4,9 @@
|
||||
Troubleshooting
|
||||
***************
|
||||
|
||||
If you run into problems with Mopidy, we usually hang around at ``#mopidy`` at
|
||||
`irc.freenode.net <http://freenode.net/>`_ and also have a `mailing list at
|
||||
Google Groups <https://groups.google.com/forum/?fromgroups=#!forum/mopidy>`_.
|
||||
If you run into problems with Mopidy, we usually hang around at ``#mopidy`` on
|
||||
`irc.freenode.net <http://freenode.net/>`_ and also have a `discussion forum
|
||||
<https://discuss.mopidy.com/c/mopidy>`_.
|
||||
If you stumble into a bug or have a feature request, please create an issue in
|
||||
the `issue tracker <https://github.com/mopidy/mopidy/issues>`_.
|
||||
|
||||
|
||||
@ -1,23 +1,39 @@
|
||||
.. _versioning:
|
||||
|
||||
**********
|
||||
Versioning
|
||||
**********
|
||||
|
||||
Mopidy uses `Semantic Versioning <http://semver.org/>`_, but since we're still
|
||||
pre-1.0 that doesn't mean much yet.
|
||||
Mopidy follows `Semantic Versioning <http://semver.org/>`_. In summary this
|
||||
means that our version numbers have three parts, MAJOR.MINOR.PATCH, which
|
||||
change according to the following rules:
|
||||
|
||||
- When we *make incompatible API changes*, we increase the MAJOR number.
|
||||
|
||||
- When we *add features* in a backwards-compatible manner, we increase the
|
||||
MINOR number.
|
||||
|
||||
- When we *fix bugs* in a backwards-compatible manner, we increase the PATCH
|
||||
number.
|
||||
|
||||
The promise is that if you make a Mopidy extension for Mopidy 1.0, it should
|
||||
work unchanged with any Mopidy 1.x release, but probably not with 2.0. When a
|
||||
new major version is released, you must review the incompatible changes and
|
||||
update your extension accordingly.
|
||||
|
||||
|
||||
Release schedule
|
||||
================
|
||||
|
||||
We intend to have about one feature release every month in periods of active
|
||||
development. The feature releases are numbered 0.x.0. The features added is a
|
||||
mix of what we feel is most important/requested of the missing features, and
|
||||
features we develop just because we find them fun to make, even though they may
|
||||
be useful for very few users or for a limited use case.
|
||||
development. The features added is a mix of what we feel is most
|
||||
important/requested of the missing features, and features we develop just
|
||||
because we find them fun to make, even though they may be useful for very few
|
||||
users or for a limited use case.
|
||||
|
||||
Bugfix releases, numbered 0.x.y, will be released whenever we discover bugs
|
||||
that are too serious to wait for the next feature release. We will only release
|
||||
bugfix releases for the last feature release. E.g. when 0.14.0 is released, we
|
||||
will no longer provide bugfix releases for the 0.13 series. In other words,
|
||||
there will be just a single supported release at any point in time. This is to
|
||||
not spread our limited resources too thin.
|
||||
Bugfix releases will be released whenever we discover bugs that are too serious
|
||||
to wait for the next feature release. We will only release bugfix releases for
|
||||
the last feature release. E.g. when 1.2.0 is released, we will no longer
|
||||
provide bugfix releases for the 1.1.x series. In other words, there will be just
|
||||
a single supported release at any point in time. This is to not spread our
|
||||
limited resources too thin.
|
||||
|
||||
@ -30,4 +30,4 @@ except ImportError:
|
||||
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
|
||||
from .actor import Audio
|
||||
from .dummy import DummyAudio
|
||||
from .listener import AudioListener
|
||||
from .constants import PlaybackState
|
||||
from .utils import (
|
||||
|
||||
@ -16,7 +16,7 @@ from mopidy import exceptions
|
||||
from mopidy.audio import playlists, utils
|
||||
from mopidy.audio.constants import PlaybackState
|
||||
from mopidy.audio.listener import AudioListener
|
||||
from mopidy.utils import process
|
||||
from mopidy.utils import deprecation, process
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -34,26 +34,11 @@ _GST_STATE_MAPPING = {
|
||||
gst.STATE_PAUSED: PlaybackState.PAUSED,
|
||||
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):
|
||||
|
||||
"""Helper for tracking gobject signal registrations"""
|
||||
|
||||
def __init__(self):
|
||||
self._ids = {}
|
||||
|
||||
@ -82,7 +67,9 @@ class _Signals(object):
|
||||
|
||||
# TODO: expose this as a property on audio?
|
||||
class _Appsrc(object):
|
||||
|
||||
"""Helper class for dealing with appsrc based playback."""
|
||||
|
||||
def __init__(self):
|
||||
self._signals = _Signals()
|
||||
self.reset()
|
||||
@ -112,7 +99,7 @@ class _Appsrc(object):
|
||||
source.set_property('caps', self._caps)
|
||||
source.set_property('format', b'time')
|
||||
source.set_property('stream-type', b'seekable')
|
||||
source.set_property('max-bytes', 1 * MB)
|
||||
source.set_property('max-bytes', 1 << 20) # 1MB
|
||||
source.set_property('min-percent', 50)
|
||||
|
||||
if self._need_data_callback:
|
||||
@ -128,6 +115,9 @@ class _Appsrc(object):
|
||||
self._source = source
|
||||
|
||||
def push(self, buffer_):
|
||||
if self._source is None:
|
||||
return False
|
||||
|
||||
if buffer_ is None:
|
||||
gst_logger.debug('Sending appsrc end-of-stream event.')
|
||||
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.
|
||||
class _Outputs(gst.Bin):
|
||||
|
||||
def __init__(self):
|
||||
gst.Bin.__init__(self)
|
||||
gst.Bin.__init__(self, 'outputs')
|
||||
|
||||
self._tee = gst.element_factory_make('tee')
|
||||
self.add(self._tee)
|
||||
|
||||
# Queue element to buy us time between the about to finish event and
|
||||
# the actual switch, i.e. about to switch can block for longer thanks
|
||||
# to this queue.
|
||||
# TODO: make the min-max values a setting?
|
||||
# TODO: this does not belong in this class.
|
||||
queue = gst.element_factory_make('queue')
|
||||
queue.set_property('max-size-buffers', 0)
|
||||
queue.set_property('max-size-bytes', 0)
|
||||
queue.set_property('max-size-time', 5 * gst.SECOND)
|
||||
queue.set_property('min-threshold-time', 3 * gst.SECOND)
|
||||
self.add(queue)
|
||||
|
||||
queue.link(self._tee)
|
||||
|
||||
ghost_pad = gst.GhostPad('sink', queue.get_pad('sink'))
|
||||
ghost_pad = gst.GhostPad('sink', self._tee.get_pad('sink'))
|
||||
self.add_pad(ghost_pad)
|
||||
|
||||
# Add an always connected fakesink which respects the clock so the tee
|
||||
@ -190,7 +167,9 @@ class _Outputs(gst.Bin):
|
||||
|
||||
def _add(self, element):
|
||||
# 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.set_property('max-size-buffers', 5)
|
||||
self.add(element)
|
||||
self.add(queue)
|
||||
queue.link(element)
|
||||
@ -209,10 +188,6 @@ class SoftwareMixer(object):
|
||||
|
||||
def setup(self, element, mixer_ref):
|
||||
self._element = element
|
||||
|
||||
self._signals.connect(element, 'notify::volume', self._volume_changed)
|
||||
self._signals.connect(element, 'notify::mute', self._mute_changed)
|
||||
|
||||
self._mixer.setup(mixer_ref)
|
||||
|
||||
def teardown(self):
|
||||
@ -224,41 +199,20 @@ class SoftwareMixer(object):
|
||||
|
||||
def set_volume(self, volume):
|
||||
self._element.set_property('volume', volume / 100.0)
|
||||
self._mixer.trigger_volume_changed(volume)
|
||||
|
||||
def get_mute(self):
|
||||
return self._element.get_property('mute')
|
||||
|
||||
def set_mute(self, mute):
|
||||
return self._element.set_property('mute', bool(mute))
|
||||
|
||||
def _volume_changed(self, element, property_):
|
||||
old_volume, self._last_volume = self._last_volume, self.get_volume()
|
||||
if old_volume != self._last_volume:
|
||||
gst_logger.debug('Notify volume: %s', self._last_volume / 100.0)
|
||||
self._mixer.trigger_volume_changed(self._last_volume)
|
||||
|
||||
def _mute_changed(self, element, property_):
|
||||
old_mute, self._last_mute = self._last_mute, self.get_mute()
|
||||
if old_mute != self._last_mute:
|
||||
gst_logger.debug('Notify mute: %s', self._last_mute)
|
||||
self._mixer.trigger_mute_changed(self._last_mute)
|
||||
|
||||
|
||||
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'))
|
||||
result = self._element.set_property('mute', bool(mute))
|
||||
if result:
|
||||
self._mixer.trigger_mute_changed(bool(mute))
|
||||
return result
|
||||
|
||||
|
||||
class _Handler(object):
|
||||
|
||||
def __init__(self, audio):
|
||||
self._audio = audio
|
||||
self._element = None
|
||||
@ -290,7 +244,7 @@ class _Handler(object):
|
||||
if msg.type == gst.MESSAGE_STATE_CHANGED and msg.src == self._element:
|
||||
self.on_playbin_state_changed(*msg.parse_state_changed())
|
||||
elif msg.type == gst.MESSAGE_BUFFERING:
|
||||
self.on_buffering(msg.parse_buffering())
|
||||
self.on_buffering(msg.parse_buffering(), msg.structure)
|
||||
elif msg.type == gst.MESSAGE_EOS:
|
||||
self.on_end_of_stream()
|
||||
elif msg.type == gst.MESSAGE_ERROR:
|
||||
@ -353,16 +307,23 @@ class _Handler(object):
|
||||
gst.DEBUG_BIN_TO_DOT_FILE(
|
||||
self._audio._playbin, gst.DEBUG_GRAPH_SHOW_ALL, 'mopidy')
|
||||
|
||||
def on_buffering(self, percent):
|
||||
gst_logger.debug('Got buffering message: percent=%d%%', percent)
|
||||
def on_buffering(self, percent, structure=None):
|
||||
if structure and structure.has_field('buffering-mode'):
|
||||
if structure['buffering-mode'] == gst.BUFFERING_LIVE:
|
||||
return # Live sources stall in paused.
|
||||
|
||||
level = logging.getLevelName('TRACE')
|
||||
if percent < 10 and not self._audio._buffering:
|
||||
self._audio._playbin.set_state(gst.STATE_PAUSED)
|
||||
self._audio._buffering = True
|
||||
level = logging.DEBUG
|
||||
if percent == 100:
|
||||
self._audio._buffering = False
|
||||
if self._audio._target_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):
|
||||
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
|
||||
class Audio(pykka.ThreadingActor):
|
||||
|
||||
"""
|
||||
Audio output through `GStreamer <http://gstreamer.freedesktop.org/>`_.
|
||||
"""
|
||||
@ -453,8 +415,8 @@ class Audio(pykka.ThreadingActor):
|
||||
try:
|
||||
self._setup_preferences()
|
||||
self._setup_playbin()
|
||||
self._setup_output()
|
||||
self._setup_mixer()
|
||||
self._setup_outputs()
|
||||
self._setup_audio_sink()
|
||||
except gobject.GError as ex:
|
||||
logger.exception(ex)
|
||||
process.exit_process()
|
||||
@ -474,11 +436,11 @@ class Audio(pykka.ThreadingActor):
|
||||
|
||||
def _setup_playbin(self):
|
||||
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...
|
||||
playbin.set_property('buffer-size', 2*1024*1024)
|
||||
playbin.set_property('buffer-duration', 2*gst.SECOND)
|
||||
playbin.set_property('buffer-size', 5 << 20) # 5MB
|
||||
playbin.set_property('buffer-duration', 5 * gst.SECOND)
|
||||
|
||||
self._signals.connect(playbin, 'source-setup', self._on_source_setup)
|
||||
self._signals.connect(playbin, 'about-to-finish',
|
||||
@ -494,7 +456,7 @@ class Audio(pykka.ThreadingActor):
|
||||
self._signals.disconnect(self._playbin, 'source-setup')
|
||||
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
|
||||
# an unsynced fakesink when someone asks for a 'testoutput'.
|
||||
if self._config['audio']['output'] == 'testoutput':
|
||||
@ -507,11 +469,36 @@ class Audio(pykka.ThreadingActor):
|
||||
process.exit_process() # TODO: move this up the chain
|
||||
|
||||
self._handler.setup_event_handling(self._outputs.get_pad('sink'))
|
||||
self._playbin.set_property('audio-sink', self._outputs)
|
||||
|
||||
def _setup_mixer(self):
|
||||
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:
|
||||
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):
|
||||
if self.mixer:
|
||||
@ -531,8 +518,7 @@ class Audio(pykka.ThreadingActor):
|
||||
else:
|
||||
self._appsrc.reset()
|
||||
|
||||
if hasattr(source.props, 'proxy'):
|
||||
setup_proxy(source, self._config['proxy'])
|
||||
utils.setup_proxy(source, self._config['proxy'])
|
||||
|
||||
def set_uri(self, uri):
|
||||
"""
|
||||
@ -543,9 +529,20 @@ class Audio(pykka.ThreadingActor):
|
||||
:param uri: the URI to play
|
||||
:type uri: string
|
||||
"""
|
||||
|
||||
# XXX: Hack to workaround issue on Mac OS X where volume level
|
||||
# does not persist between track changes. mopidy/mopidy#886
|
||||
if self.mixer is not None:
|
||||
current_volume = self.mixer.get_volume()
|
||||
else:
|
||||
current_volume = None
|
||||
|
||||
self._tags = {} # TODO: add test for this somehow
|
||||
self._playbin.set_property('uri', uri)
|
||||
|
||||
if self.mixer is not None and current_volume is not None:
|
||||
self.mixer.set_volume(current_volume)
|
||||
|
||||
def set_appsrc(
|
||||
self, caps, need_data=None, enough_data=None, seek_data=None):
|
||||
"""
|
||||
@ -594,9 +591,10 @@ class Audio(pykka.ThreadingActor):
|
||||
We will get a GStreamer message when the stream playback reaches the
|
||||
token, and can then do any end-of-stream related tasks.
|
||||
|
||||
.. deprecated:: 0.20
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`emit_data` with a :class:`None` buffer instead.
|
||||
"""
|
||||
deprecation.warn('audio.emit_end_of_stream')
|
||||
self._appsrc.push(None)
|
||||
|
||||
def set_about_to_finish_callback(self, callback):
|
||||
|
||||
@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals
|
||||
|
||||
|
||||
class PlaybackState(object):
|
||||
|
||||
"""
|
||||
Enum of playback states.
|
||||
"""
|
||||
|
||||
@ -4,6 +4,7 @@ from mopidy import listener
|
||||
|
||||
|
||||
class AudioListener(listener.Listener):
|
||||
|
||||
"""
|
||||
Marker interface for recipients of events sent by the audio actor.
|
||||
|
||||
|
||||
@ -136,6 +136,7 @@ def register_typefinders():
|
||||
|
||||
|
||||
class BasePlaylistElement(gst.Bin):
|
||||
|
||||
"""Base class for creating GStreamer elements for playlist support.
|
||||
|
||||
This element performs the following steps:
|
||||
|
||||
@ -1,42 +1,38 @@
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
|
||||
import time
|
||||
import collections
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst # noqa
|
||||
import gst.pbutils
|
||||
|
||||
from mopidy import exceptions
|
||||
from mopidy.audio import utils
|
||||
from mopidy.utils import encoding
|
||||
|
||||
_missing_plugin_desc = gst.pbutils.missing_plugin_message_get_description
|
||||
|
||||
_Result = collections.namedtuple(
|
||||
'Result', ('uri', 'tags', 'duration', 'seekable', 'mime'))
|
||||
|
||||
_RAW_AUDIO = gst.Caps(b'audio/x-raw-int; audio/x-raw-float')
|
||||
|
||||
|
||||
# TODO: replace with a scan(uri, timeout=1000, proxy_config=None)?
|
||||
class Scanner(object):
|
||||
|
||||
"""
|
||||
Helper to get tags and other relevant info from URIs.
|
||||
|
||||
:param timeout: timeout for scanning a URI in ms
|
||||
:param proxy_config: dictionary containing proxy config strings.
|
||||
:type event: int
|
||||
"""
|
||||
|
||||
def __init__(self, timeout=1000):
|
||||
self._timeout_ms = timeout
|
||||
|
||||
sink = gst.element_factory_make('fakesink')
|
||||
|
||||
audio_caps = gst.Caps(b'audio/x-raw-int; audio/x-raw-float')
|
||||
pad_added = lambda src, pad: pad.link(sink.get_pad('sink'))
|
||||
|
||||
self._uribin = gst.element_factory_make('uridecodebin')
|
||||
self._uribin.set_property('caps', audio_caps)
|
||||
self._uribin.connect('pad-added', pad_added)
|
||||
|
||||
self._pipe = gst.element_factory_make('pipeline')
|
||||
self._pipe.add(self._uribin)
|
||||
self._pipe.add(sink)
|
||||
|
||||
self._bus = self._pipe.get_bus()
|
||||
self._bus.set_flushing(True)
|
||||
def __init__(self, timeout=1000, proxy_config=None):
|
||||
self._timeout_ms = int(timeout)
|
||||
self._proxy_config = proxy_config or {}
|
||||
|
||||
def scan(self, uri):
|
||||
"""
|
||||
@ -44,64 +40,72 @@ class Scanner(object):
|
||||
|
||||
:param uri: URI of the resource to scan.
|
||||
:type event: string
|
||||
:return: (tags, duration) pair. tags is a dictionary of lists for all
|
||||
the tags we found and duration is the length of the URI in
|
||||
milliseconds, or :class:`None` if the URI has no duration.
|
||||
:return: A named tuple containing
|
||||
``(uri, tags, duration, seekable, mime)``.
|
||||
``tags`` is a dictionary of lists for all the tags we found.
|
||||
``duration`` is the length of the URI in milliseconds, or
|
||||
:class:`None` if the URI has no duration. ``seekable`` is boolean.
|
||||
indicating if a seek would succeed.
|
||||
"""
|
||||
tags, duration = None, None
|
||||
tags, duration, seekable, mime = None, None, None, None
|
||||
pipeline = _setup_pipeline(uri, self._proxy_config)
|
||||
|
||||
try:
|
||||
self._setup(uri)
|
||||
tags = self._collect()
|
||||
duration = self._query_duration()
|
||||
_start_pipeline(pipeline)
|
||||
tags, mime = _process(pipeline, self._timeout_ms)
|
||||
duration = _query_duration(pipeline)
|
||||
seekable = _query_seekable(pipeline)
|
||||
finally:
|
||||
self._reset()
|
||||
pipeline.set_state(gst.STATE_NULL)
|
||||
del pipeline
|
||||
|
||||
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):
|
||||
"""Polls for messages to collect data."""
|
||||
start = time.time()
|
||||
timeout_s = self._timeout_ms / 1000.0
|
||||
tags = {}
|
||||
# Turns out it's _much_ faster to just create a new pipeline for every as
|
||||
# decodebins and other elements don't seem to take well to being reused.
|
||||
def _setup_pipeline(uri, proxy_config=None):
|
||||
src = gst.element_make_from_uri(gst.URI_SRC, uri)
|
||||
if not src:
|
||||
raise exceptions.ScannerError('GStreamer can not open: %s' % uri)
|
||||
|
||||
while time.time() - start < timeout_s:
|
||||
if not self._bus.have_pending():
|
||||
continue
|
||||
message = self._bus.pop()
|
||||
typefind = gst.element_factory_make('typefind')
|
||||
decodebin = gst.element_factory_make('decodebin2')
|
||||
sink = gst.element_factory_make('fakesink')
|
||||
|
||||
if message.type == gst.MESSAGE_ERROR:
|
||||
raise exceptions.ScannerError(
|
||||
encoding.locale_decode(message.parse_error()[0]))
|
||||
elif message.type == gst.MESSAGE_EOS:
|
||||
return tags
|
||||
elif message.type == gst.MESSAGE_ASYNC_DONE:
|
||||
if message.src == self._pipe:
|
||||
return tags
|
||||
elif message.type == gst.MESSAGE_TAG:
|
||||
taglist = message.parse_tag()
|
||||
# Note that this will only keep the last tag.
|
||||
tags.update(utils.convert_taglist(taglist))
|
||||
pipeline = gst.element_factory_make('pipeline')
|
||||
for e in (src, typefind, decodebin, sink):
|
||||
pipeline.add(e)
|
||||
gst.element_link_many(src, typefind, decodebin)
|
||||
|
||||
raise exceptions.ScannerError('Timeout after %dms' % self._timeout_ms)
|
||||
if proxy_config:
|
||||
utils.setup_proxy(src, proxy_config)
|
||||
|
||||
def _reset(self):
|
||||
"""Ensures we cleanup child elements and flush the bus."""
|
||||
self._bus.set_flushing(True)
|
||||
self._pipe.set_state(gst.STATE_NULL)
|
||||
decodebin.set_property('caps', _RAW_AUDIO)
|
||||
decodebin.connect('pad-added', _pad_added, sink)
|
||||
typefind.connect('have-type', _have_type, decodebin)
|
||||
|
||||
def _query_duration(self):
|
||||
return pipeline
|
||||
|
||||
|
||||
def _have_type(element, probability, caps, decodebin):
|
||||
decodebin.set_property('sink-caps', caps)
|
||||
msg = gst.message_new_application(element, caps.get_structure(0))
|
||||
element.get_bus().post(msg)
|
||||
|
||||
|
||||
def _pad_added(element, pad, sink):
|
||||
return pad.link(sink.get_pad('sink'))
|
||||
|
||||
|
||||
def _start_pipeline(pipeline):
|
||||
if pipeline.set_state(gst.STATE_PAUSED) == gst.STATE_CHANGE_NO_PREROLL:
|
||||
pipeline.set_state(gst.STATE_PLAYING)
|
||||
|
||||
|
||||
def _query_duration(pipeline):
|
||||
try:
|
||||
duration = self._pipe.query_duration(gst.FORMAT_TIME, None)[0]
|
||||
duration = pipeline.query_duration(gst.FORMAT_TIME, None)[0]
|
||||
except gst.QueryError:
|
||||
return None
|
||||
|
||||
@ -109,3 +113,52 @@ class Scanner(object):
|
||||
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):
|
||||
"""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`
|
||||
"""
|
||||
album_kwargs = {}
|
||||
@ -130,6 +131,26 @@ def convert_tags_to_track(tags):
|
||||
return Track(**track_kwargs)
|
||||
|
||||
|
||||
def setup_proxy(element, config):
|
||||
"""Configure a GStreamer element with proxy settings.
|
||||
|
||||
:param element: element to setup proxy in.
|
||||
:type element: :class:`gst.GstElement`
|
||||
:param config: proxy settings to use.
|
||||
:type config: :class:`dict`
|
||||
"""
|
||||
if not hasattr(element.props, 'proxy') or not config.get('hostname'):
|
||||
return
|
||||
|
||||
proxy = "%s://%s:%d" % (config.get('scheme', 'http'),
|
||||
config.get('hostname'),
|
||||
config.get('port', 80))
|
||||
|
||||
element.set_property('proxy', proxy)
|
||||
element.set_property('proxy-id', config.get('username'))
|
||||
element.set_property('proxy-pw', config.get('password'))
|
||||
|
||||
|
||||
def convert_taglist(taglist):
|
||||
"""Convert a :class:`gst.Taglist` to plain Python types.
|
||||
|
||||
@ -147,7 +168,8 @@ def convert_taglist(taglist):
|
||||
.. _GstTagList: http://gstreamer.freedesktop.org/data/doc/gstreamer/\
|
||||
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.
|
||||
"""
|
||||
result = {}
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import copy
|
||||
|
||||
from mopidy import listener
|
||||
from mopidy import listener, models
|
||||
|
||||
|
||||
class Backend(object):
|
||||
|
||||
"""Backend API
|
||||
|
||||
If the backend has problems during initialization it should raise
|
||||
@ -61,6 +60,7 @@ class Backend(object):
|
||||
|
||||
|
||||
class LibraryProvider(object):
|
||||
|
||||
"""
|
||||
:param backend: backend the controller is a part of
|
||||
:type backend: :class:`mopidy.backend.Backend`
|
||||
@ -92,14 +92,34 @@ class LibraryProvider(object):
|
||||
"""
|
||||
return []
|
||||
|
||||
# TODO: replace with search(query, exact=True, ...)
|
||||
def find_exact(self, query=None, uris=None):
|
||||
def get_distinct(self, field, query=None):
|
||||
"""
|
||||
See :meth:`mopidy.core.LibraryController.find_exact`.
|
||||
See :meth:`mopidy.core.LibraryController.get_distinct`.
|
||||
|
||||
*MAY be implemented by subclass.*
|
||||
|
||||
Default implementation will simply return an empty set.
|
||||
"""
|
||||
pass
|
||||
return set()
|
||||
|
||||
def get_images(self, uris):
|
||||
"""
|
||||
See :meth:`mopidy.core.LibraryController.get_images`.
|
||||
|
||||
*MAY be implemented by subclass.*
|
||||
|
||||
Default implementation will simply call lookup and try and use the
|
||||
album art for any tracks returned. Most extensions should replace this
|
||||
with something smarter or simply return an empty dictionary.
|
||||
"""
|
||||
result = {}
|
||||
for uri in uris:
|
||||
image_uris = set()
|
||||
for track in self.lookup(uri):
|
||||
if track.album and track.album.images:
|
||||
image_uris.update(track.album.images)
|
||||
result[uri] = [models.Image(uri=u) for u in image_uris]
|
||||
return result
|
||||
|
||||
def lookup(self, uri):
|
||||
"""
|
||||
@ -117,16 +137,20 @@ class LibraryProvider(object):
|
||||
"""
|
||||
pass
|
||||
|
||||
def search(self, query=None, uris=None):
|
||||
def search(self, query=None, uris=None, exact=False):
|
||||
"""
|
||||
See :meth:`mopidy.core.LibraryController.search`.
|
||||
|
||||
*MAY be implemented by subclass.*
|
||||
|
||||
.. versionadded:: 1.0
|
||||
The ``exact`` param which replaces the old ``find_exact``.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class PlaybackProvider(object):
|
||||
|
||||
"""
|
||||
:param audio: the audio actor
|
||||
:type audio: actor proxy to an instance of :class:`mopidy.audio.Audio`
|
||||
@ -172,23 +196,44 @@ class PlaybackProvider(object):
|
||||
"""
|
||||
self.audio.prepare_change().get()
|
||||
|
||||
def translate_uri(self, uri):
|
||||
"""
|
||||
Convert custom URI scheme to real playable URI.
|
||||
|
||||
*MAY be reimplemented by subclass.*
|
||||
|
||||
This is very likely the *only* thing you need to override as a backend
|
||||
author. Typically this is where you convert any Mopidy specific URI
|
||||
to a real URI and then return it. If you can't convert the URI just
|
||||
return :class:`None`.
|
||||
|
||||
:param uri: the URI to translate
|
||||
:type uri: string
|
||||
:rtype: string or :class:`None` if the URI could not be translated
|
||||
"""
|
||||
return uri
|
||||
|
||||
def change_track(self, track):
|
||||
"""
|
||||
Swith to provided track.
|
||||
|
||||
*MAY be reimplemented by subclass.*
|
||||
|
||||
This is very likely the *only* thing you need to override as a backend
|
||||
author. Typically this is where you convert any mopidy specific URIs
|
||||
to real URIs and then return::
|
||||
It is unlikely it makes sense for any backends to override
|
||||
this. For most practical purposes it should be considered an internal
|
||||
call between backends and core that backend authors should not touch.
|
||||
|
||||
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
|
||||
:type track: :class:`mopidy.models.Track`
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
self.audio.set_uri(track.uri).get()
|
||||
uri = self.translate_uri(track.uri)
|
||||
if not uri:
|
||||
return False
|
||||
self.audio.set_uri(uri).get()
|
||||
return True
|
||||
|
||||
def resume(self):
|
||||
@ -219,7 +264,7 @@ class PlaybackProvider(object):
|
||||
|
||||
*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.
|
||||
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
@ -238,6 +283,7 @@ class PlaybackProvider(object):
|
||||
|
||||
|
||||
class PlaylistsProvider(object):
|
||||
|
||||
"""
|
||||
A playlist provider exposes a collection of playlists, methods to
|
||||
create/change/delete playlists in this collection, and lookup of any
|
||||
@ -251,25 +297,36 @@ class PlaylistsProvider(object):
|
||||
|
||||
def __init__(self, backend):
|
||||
self.backend = backend
|
||||
self._playlists = []
|
||||
|
||||
# TODO Replace playlists property with a get_playlists() method which
|
||||
# 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):
|
||||
def as_list(self):
|
||||
"""
|
||||
Currently available playlists.
|
||||
Get a list of the currently available playlists.
|
||||
|
||||
Read/write. List of :class:`mopidy.models.Playlist`.
|
||||
Returns a list of :class:`~mopidy.models.Ref` objects referring to the
|
||||
playlists. In other words, no information about the playlists' content
|
||||
is given.
|
||||
|
||||
:rtype: list of :class:`mopidy.models.Ref`
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
return copy.copy(self._playlists)
|
||||
raise NotImplementedError
|
||||
|
||||
@playlists.setter # noqa
|
||||
def playlists(self, playlists):
|
||||
self._playlists = playlists
|
||||
def get_items(self, uri):
|
||||
"""
|
||||
Get the items in a playlist specified by ``uri``.
|
||||
|
||||
Returns a list of :class:`~mopidy.models.Ref` objects referring to the
|
||||
playlist's items.
|
||||
|
||||
If a playlist with the given ``uri`` doesn't exist, it returns
|
||||
:class:`None`.
|
||||
|
||||
:rtype: list of :class:`mopidy.models.Ref`, or :class:`None`
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def create(self, name):
|
||||
"""
|
||||
@ -338,6 +395,7 @@ class PlaylistsProvider(object):
|
||||
|
||||
|
||||
class BackendListener(listener.Listener):
|
||||
|
||||
"""
|
||||
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 collections
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import glib
|
||||
|
||||
@ -15,7 +13,7 @@ import gobject
|
||||
from mopidy import config as config_lib, exceptions
|
||||
from mopidy.audio import Audio
|
||||
from mopidy.core import Core
|
||||
from mopidy.utils import deps, process, versioning
|
||||
from mopidy.utils import deps, process, timer, versioning
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -40,7 +38,9 @@ def config_override_type(value):
|
||||
|
||||
|
||||
class _ParserError(Exception):
|
||||
pass
|
||||
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
|
||||
|
||||
class _HelpError(Exception):
|
||||
@ -48,11 +48,13 @@ class _HelpError(Exception):
|
||||
|
||||
|
||||
class _ArgumentParser(argparse.ArgumentParser):
|
||||
|
||||
def error(self, message):
|
||||
raise _ParserError(message)
|
||||
|
||||
|
||||
class _HelpAction(argparse.Action):
|
||||
|
||||
def __init__(self, option_strings, dest=None, help=None):
|
||||
super(_HelpAction, self).__init__(
|
||||
option_strings=option_strings,
|
||||
@ -65,14 +67,8 @@ class _HelpAction(argparse.Action):
|
||||
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):
|
||||
|
||||
"""Command parser and runner for building trees of commands.
|
||||
|
||||
This class provides a wraper around :class:`argparse.ArgumentParser`
|
||||
@ -236,6 +232,7 @@ class Command(object):
|
||||
|
||||
# TODO: move out of this file
|
||||
class RootCommand(Command):
|
||||
|
||||
def __init__(self):
|
||||
super(RootCommand, self).__init__()
|
||||
self.set(base_verbosity_level=0)
|
||||
@ -277,10 +274,12 @@ class RootCommand(Command):
|
||||
|
||||
exit_status_code = 0
|
||||
try:
|
||||
mixer = None
|
||||
if mixer_class is not None:
|
||||
mixer = self.start_mixer(config, mixer_class)
|
||||
audio = self.start_audio(config, mixer)
|
||||
backends = self.start_backends(config, backend_classes, audio)
|
||||
core = self.start_core(audio, mixer, backends)
|
||||
core = self.start_core(mixer, backends, audio)
|
||||
self.start_frontends(config, frontend_classes, core)
|
||||
loop.run()
|
||||
except (exceptions.BackendError,
|
||||
@ -298,6 +297,7 @@ class RootCommand(Command):
|
||||
self.stop_core()
|
||||
self.stop_backends(backend_classes)
|
||||
self.stop_audio()
|
||||
if mixer_class is not None:
|
||||
self.stop_mixer(mixer_class)
|
||||
process.stop_remaining_actors()
|
||||
return exit_status_code
|
||||
@ -307,13 +307,18 @@ class RootCommand(Command):
|
||||
'Available Mopidy mixers: %s',
|
||||
', '.join(m.__name__ for m in mixer_classes) or 'none')
|
||||
|
||||
if config['audio']['mixer'] == 'none':
|
||||
logger.debug('Mixer disabled')
|
||||
return None
|
||||
|
||||
selected_mixers = [
|
||||
m for m in mixer_classes if m.name == config['audio']['mixer']]
|
||||
if len(selected_mixers) != 1:
|
||||
logger.error(
|
||||
'Did not find unique mixer "%s". Alternatives are: %s',
|
||||
config['audio']['mixer'],
|
||||
', '.join([m.name for m in mixer_classes]))
|
||||
', '.join([m.name for m in mixer_classes]) + ', none' or
|
||||
'none')
|
||||
process.exit_process()
|
||||
return selected_mixers[0]
|
||||
|
||||
@ -349,7 +354,7 @@ class RootCommand(Command):
|
||||
backends = []
|
||||
for backend_class in backend_classes:
|
||||
try:
|
||||
with _startup_timer(backend_class.__name__):
|
||||
with timer.time_logger(backend_class.__name__):
|
||||
backend = backend_class.start(
|
||||
config=config, audio=audio).proxy()
|
||||
backends.append(backend)
|
||||
@ -361,9 +366,9 @@ class RootCommand(Command):
|
||||
|
||||
return backends
|
||||
|
||||
def start_core(self, audio, mixer, backends):
|
||||
def start_core(self, mixer, backends, audio):
|
||||
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):
|
||||
logger.info(
|
||||
@ -372,7 +377,7 @@ class RootCommand(Command):
|
||||
|
||||
for frontend_class in frontend_classes:
|
||||
try:
|
||||
with _startup_timer(frontend_class.__name__):
|
||||
with timer.time_logger(frontend_class.__name__):
|
||||
frontend_class.start(config=config, core=core)
|
||||
except exceptions.FrontendError as exc:
|
||||
logger.error(
|
||||
|
||||
@ -22,7 +22,8 @@ _logging_schema['debug_format'] = String()
|
||||
_logging_schema['debug_file'] = Path()
|
||||
_logging_schema['config_file'] = Path(optional=True)
|
||||
|
||||
_loglevels_schema = LogLevelConfigSchema('loglevels')
|
||||
_loglevels_schema = MapConfigSchema('loglevels', LogLevel())
|
||||
_logcolors_schema = MapConfigSchema('logcolors', LogColor())
|
||||
|
||||
_audio_schema = ConfigSchema('audio')
|
||||
_audio_schema['mixer'] = String()
|
||||
@ -42,7 +43,8 @@ _proxy_schema['password'] = Secret(optional=True)
|
||||
# NOTE: if multiple outputs ever comes something like LogLevelConfigSchema
|
||||
# _outputs_schema = config.AudioOutputConfigSchema()
|
||||
|
||||
_schemas = [_logging_schema, _loglevels_schema, _audio_schema, _proxy_schema]
|
||||
_schemas = [_logging_schema, _loglevels_schema, _logcolors_schema,
|
||||
_audio_schema, _proxy_schema]
|
||||
|
||||
_INITIAL_HELP = """
|
||||
# For further information about options in this file see:
|
||||
@ -148,6 +150,11 @@ def _load_file(parser, filename):
|
||||
logger.debug(
|
||||
'Loading config from %s failed; it does not exist', filename)
|
||||
return
|
||||
if not os.access(filename, os.R_OK):
|
||||
logger.warning(
|
||||
'Loading config from %s failed; read permission missing',
|
||||
filename)
|
||||
return
|
||||
|
||||
try:
|
||||
logger.info('Loading config from %s', filename)
|
||||
@ -170,13 +177,19 @@ def _validate(raw_config, schemas):
|
||||
# Get validated config
|
||||
config = {}
|
||||
errors = {}
|
||||
sections = set(raw_config)
|
||||
for schema in schemas:
|
||||
sections.discard(schema.name)
|
||||
values = raw_config.get(schema.name, {})
|
||||
result, error = schema.deserialize(values)
|
||||
if error:
|
||||
errors[schema.name] = error
|
||||
if result:
|
||||
config[schema.name] = result
|
||||
|
||||
for section in sections:
|
||||
logger.debug('Ignoring unknown config section: %s', section)
|
||||
|
||||
return config, errors
|
||||
|
||||
|
||||
@ -251,6 +264,7 @@ def _postprocess(config_string):
|
||||
|
||||
|
||||
class Proxy(collections.Mapping):
|
||||
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
|
||||
@ -38,6 +38,7 @@ def _levenshtein(a, b):
|
||||
|
||||
|
||||
class ConfigSchema(collections.OrderedDict):
|
||||
|
||||
"""Logical group of config values that correspond to a config section.
|
||||
|
||||
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
|
||||
persistence.
|
||||
"""
|
||||
|
||||
def __init__(self, name):
|
||||
super(ConfigSchema, self).__init__()
|
||||
self.name = name
|
||||
@ -94,17 +96,17 @@ class ConfigSchema(collections.OrderedDict):
|
||||
return result
|
||||
|
||||
|
||||
class LogLevelConfigSchema(object):
|
||||
"""Special cased schema for handling a config section with loglevels.
|
||||
class MapConfigSchema(object):
|
||||
|
||||
Expects the config keys to be logger names and the values to be log levels
|
||||
as understood by the :class:`LogLevel` config value. Does not sub-class
|
||||
:class:`ConfigSchema`, but implements the same serialize/deserialize
|
||||
interface.
|
||||
"""Schema for handling multiple unknown keys with the same type.
|
||||
|
||||
Does not sub-class :class:`ConfigSchema`, but implements the same
|
||||
serialize/deserialize interface.
|
||||
"""
|
||||
def __init__(self, name):
|
||||
|
||||
def __init__(self, name, value_type):
|
||||
self.name = name
|
||||
self._config_value = types.LogLevel()
|
||||
self._value_type = value_type
|
||||
|
||||
def deserialize(self, values):
|
||||
errors = {}
|
||||
@ -112,7 +114,7 @@ class LogLevelConfigSchema(object):
|
||||
|
||||
for key, value in values.items():
|
||||
try:
|
||||
result[key] = self._config_value.deserialize(value)
|
||||
result[key] = self._value_type.deserialize(value)
|
||||
except ValueError as e: # deserialization failed
|
||||
result[key] = None
|
||||
errors[key] = str(e)
|
||||
@ -121,5 +123,5 @@ class LogLevelConfigSchema(object):
|
||||
def serialize(self, values, display=False):
|
||||
result = collections.OrderedDict()
|
||||
for key in sorted(values.keys()):
|
||||
result[key] = self._config_value.serialize(values[key], display)
|
||||
result[key] = self._value_type.serialize(values[key], display)
|
||||
return result
|
||||
|
||||
@ -6,7 +6,7 @@ import socket
|
||||
|
||||
from mopidy import compat
|
||||
from mopidy.config import validators
|
||||
from mopidy.utils import path
|
||||
from mopidy.utils import log, path
|
||||
|
||||
|
||||
def decode(value):
|
||||
@ -25,6 +25,7 @@ def encode(value):
|
||||
|
||||
|
||||
class ExpandedPath(bytes):
|
||||
|
||||
def __new__(cls, original, expanded):
|
||||
return super(ExpandedPath, cls).__new__(cls, expanded)
|
||||
|
||||
@ -37,6 +38,7 @@ class DeprecatedValue(object):
|
||||
|
||||
|
||||
class ConfigValue(object):
|
||||
|
||||
"""Represents a config key's value and how to handle it.
|
||||
|
||||
Normally you will only be interacting with sub-classes for config values
|
||||
@ -65,6 +67,7 @@ class ConfigValue(object):
|
||||
|
||||
|
||||
class Deprecated(ConfigValue):
|
||||
|
||||
"""Deprecated value
|
||||
|
||||
Used for ignoring old config values that are no longer in use, but should
|
||||
@ -79,10 +82,12 @@ class Deprecated(ConfigValue):
|
||||
|
||||
|
||||
class String(ConfigValue):
|
||||
|
||||
"""String value.
|
||||
|
||||
Is decoded as utf-8 and \\n \\t escapes should work and be preserved.
|
||||
"""
|
||||
|
||||
def __init__(self, optional=False, choices=None):
|
||||
self._required = not optional
|
||||
self._choices = choices
|
||||
@ -102,6 +107,7 @@ class String(ConfigValue):
|
||||
|
||||
|
||||
class Secret(String):
|
||||
|
||||
"""Secret string value.
|
||||
|
||||
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
|
||||
displayed.
|
||||
"""
|
||||
|
||||
def __init__(self, optional=False, choices=None):
|
||||
self._required = not optional
|
||||
self._choices = None # Choices doesn't make sense for secrets
|
||||
@ -120,6 +127,7 @@ class Secret(String):
|
||||
|
||||
|
||||
class Integer(ConfigValue):
|
||||
|
||||
"""Integer value."""
|
||||
|
||||
def __init__(
|
||||
@ -141,6 +149,7 @@ class Integer(ConfigValue):
|
||||
|
||||
|
||||
class Boolean(ConfigValue):
|
||||
|
||||
"""Boolean value.
|
||||
|
||||
Accepts ``1``, ``yes``, ``true``, and ``on`` with any casing as
|
||||
@ -173,11 +182,13 @@ class Boolean(ConfigValue):
|
||||
|
||||
|
||||
class List(ConfigValue):
|
||||
|
||||
"""List value.
|
||||
|
||||
Supports elements split by commas or newlines. Newlines take presedence and
|
||||
empty list items will be filtered out.
|
||||
"""
|
||||
|
||||
def __init__(self, optional=False):
|
||||
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)
|
||||
|
||||
|
||||
class LogColor(ConfigValue):
|
||||
|
||||
def deserialize(self, value):
|
||||
validators.validate_choice(value.lower(), log.COLORS)
|
||||
return value.lower()
|
||||
|
||||
def serialize(self, value, display=False):
|
||||
if value.lower() in log.COLORS:
|
||||
return value.lower()
|
||||
return b''
|
||||
|
||||
|
||||
class LogLevel(ConfigValue):
|
||||
|
||||
"""Log level value.
|
||||
|
||||
Expects one of ``critical``, ``error``, ``warning``, ``info``, ``debug``
|
||||
with any casing.
|
||||
Expects one of ``critical``, ``error``, ``warning``, ``info``, ``debug``,
|
||||
or ``all``, with any casing.
|
||||
"""
|
||||
levels = {
|
||||
b'critical': logging.CRITICAL,
|
||||
@ -209,6 +233,7 @@ class LogLevel(ConfigValue):
|
||||
b'warning': logging.WARNING,
|
||||
b'info': logging.INFO,
|
||||
b'debug': logging.DEBUG,
|
||||
b'all': logging.NOTSET,
|
||||
}
|
||||
|
||||
def deserialize(self, value):
|
||||
@ -223,6 +248,7 @@ class LogLevel(ConfigValue):
|
||||
|
||||
|
||||
class Hostname(ConfigValue):
|
||||
|
||||
"""Network hostname value."""
|
||||
|
||||
def __init__(self, optional=False):
|
||||
@ -240,18 +266,21 @@ class Hostname(ConfigValue):
|
||||
|
||||
|
||||
class Port(Integer):
|
||||
|
||||
"""Network port value.
|
||||
|
||||
Expects integer in the range 0-65535, zero tells the kernel to simply
|
||||
allocate a port for us.
|
||||
"""
|
||||
# TODO: consider probing if port is free or not?
|
||||
|
||||
def __init__(self, choices=None, optional=False):
|
||||
super(Port, self).__init__(
|
||||
minimum=0, maximum=2 ** 16 - 1, choices=choices, optional=optional)
|
||||
|
||||
|
||||
class Path(ConfigValue):
|
||||
|
||||
"""File system path
|
||||
|
||||
The following expansions of the path will be done:
|
||||
@ -266,6 +295,7 @@ class Path(ConfigValue):
|
||||
|
||||
- ``$XDG_MUSIC_DIR`` according to the XDG spec
|
||||
"""
|
||||
|
||||
def __init__(self, optional=False):
|
||||
self._required = not optional
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ from .actor import Core
|
||||
from .history import HistoryController
|
||||
from .library import LibraryController
|
||||
from .listener import CoreListener
|
||||
from .mixer import MixerController
|
||||
from .playback import PlaybackController, PlaybackState
|
||||
from .playlists import PlaylistsController
|
||||
from .tracklist import TracklistController
|
||||
|
||||
@ -10,10 +10,12 @@ from mopidy.audio import PlaybackState
|
||||
from mopidy.core.history import HistoryController
|
||||
from mopidy.core.library import LibraryController
|
||||
from mopidy.core.listener import CoreListener
|
||||
from mopidy.core.mixer import MixerController
|
||||
from mopidy.core.playback import PlaybackController
|
||||
from mopidy.core.playlists import PlaylistsController
|
||||
from mopidy.core.tracklist import TracklistController
|
||||
from mopidy.utils import versioning
|
||||
from mopidy.utils.deprecation import deprecated_property
|
||||
|
||||
|
||||
class Core(
|
||||
@ -28,6 +30,10 @@ class Core(
|
||||
"""The playback history controller. An instance of
|
||||
:class:`mopidy.core.HistoryController`."""
|
||||
|
||||
mixer = None
|
||||
"""The mixer controller. An instance of
|
||||
:class:`mopidy.core.MixerController`."""
|
||||
|
||||
playback = None
|
||||
"""The playback controller. An instance of
|
||||
:class:`mopidy.core.PlaybackController`."""
|
||||
@ -40,43 +46,49 @@ class Core(
|
||||
"""The tracklist controller. An instance of
|
||||
: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__()
|
||||
|
||||
self.backends = Backends(backends)
|
||||
|
||||
self.library = LibraryController(backends=self.backends, core=self)
|
||||
|
||||
self.history = HistoryController()
|
||||
|
||||
self.mixer = MixerController(mixer=mixer)
|
||||
self.playback = PlaybackController(
|
||||
audio=audio, mixer=mixer, backends=self.backends, core=self)
|
||||
|
||||
self.playlists = PlaylistsController(
|
||||
backends=self.backends, core=self)
|
||||
|
||||
audio=audio, backends=self.backends, core=self)
|
||||
self.playlists = PlaylistsController(backends=self.backends, core=self)
|
||||
self.tracklist = TracklistController(core=self)
|
||||
|
||||
self.audio = audio
|
||||
|
||||
def get_uri_schemes(self):
|
||||
"""Get list of URI schemes we can handle"""
|
||||
futures = [b.uri_schemes for b in self.backends]
|
||||
results = pykka.get_all(futures)
|
||||
uri_schemes = itertools.chain(*results)
|
||||
return sorted(uri_schemes)
|
||||
|
||||
uri_schemes = property(get_uri_schemes)
|
||||
"""List of URI schemes we can handle"""
|
||||
uri_schemes = deprecated_property(get_uri_schemes)
|
||||
"""
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`get_uri_schemes` instead.
|
||||
"""
|
||||
|
||||
def get_version(self):
|
||||
"""Get version of the Mopidy core API"""
|
||||
return versioning.get_version()
|
||||
|
||||
version = property(get_version)
|
||||
"""Version of the Mopidy core API"""
|
||||
version = deprecated_property(get_version)
|
||||
"""
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`get_version` instead.
|
||||
"""
|
||||
|
||||
def reached_end_of_stream(self):
|
||||
self.playback.on_end_of_stream()
|
||||
self.playback._on_end_of_stream()
|
||||
|
||||
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):
|
||||
# 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
|
||||
# updates (at least for now) and we need to get #234 fixed...
|
||||
if (new_state == PlaybackState.PAUSED and not target_state
|
||||
and self.playback.state != PlaybackState.PAUSED):
|
||||
if (new_state == PlaybackState.PAUSED and not target_state and
|
||||
self.playback.state != PlaybackState.PAUSED):
|
||||
self.playback.state = new_state
|
||||
self.playback._trigger_track_playback_paused()
|
||||
|
||||
@ -105,8 +117,25 @@ class Core(
|
||||
# Forward event from mixer to frontends
|
||||
CoreListener.send('mute_changed', mute=mute)
|
||||
|
||||
def tags_changed(self, tags):
|
||||
if not self.audio or 'title' not in tags:
|
||||
return
|
||||
|
||||
tags = self.audio.get_current_tags().get()
|
||||
if not tags:
|
||||
return
|
||||
|
||||
# TODO: this limits us to only streams that set organization, this is
|
||||
# a hack to make sure we don't emit stream title changes for plain
|
||||
# tracks. We need a better way to decide if something is a stream.
|
||||
if 'title' in tags and tags['title'] and 'organization' in tags:
|
||||
title = tags['title'][0]
|
||||
self.playback._stream_title = title
|
||||
CoreListener.send('stream_title_changed', title=title)
|
||||
|
||||
|
||||
class Backends(list):
|
||||
|
||||
def __init__(self, backends):
|
||||
super(Backends, self).__init__(backends)
|
||||
|
||||
@ -116,7 +145,9 @@ class Backends(list):
|
||||
self.with_playlists = collections.OrderedDict()
|
||||
|
||||
backends_by_scheme = {}
|
||||
name = lambda b: b.actor_ref.actor_class.__name__
|
||||
|
||||
def name(b):
|
||||
return b.actor_ref.actor_class.__name__
|
||||
|
||||
for b in backends:
|
||||
has_library = b.has_library().get()
|
||||
|
||||
@ -15,9 +15,11 @@ class HistoryController(object):
|
||||
def __init__(self):
|
||||
self._history = []
|
||||
|
||||
def add(self, track):
|
||||
def _add_track(self, track):
|
||||
"""Add track to the playback history.
|
||||
|
||||
Internal method for :class:`mopidy.core.PlaybackController`.
|
||||
|
||||
:param track: track to add
|
||||
:type track: :class:`mopidy.models.Track`
|
||||
"""
|
||||
|
||||
@ -1,11 +1,17 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import collections
|
||||
import logging
|
||||
import operator
|
||||
import urlparse
|
||||
|
||||
import pykka
|
||||
|
||||
from mopidy.utils import deprecation
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LibraryController(object):
|
||||
pykka_traversable = True
|
||||
@ -60,6 +66,8 @@ class LibraryController(object):
|
||||
|
||||
:param string uri: URI to browse
|
||||
:rtype: list of :class:`mopidy.models.Ref`
|
||||
|
||||
.. versionadded:: 0.18
|
||||
"""
|
||||
if uri is None:
|
||||
backends = self.backends.with_library_browse.values()
|
||||
@ -72,52 +80,65 @@ class LibraryController(object):
|
||||
return []
|
||||
return backend.library.browse(uri).get()
|
||||
|
||||
def find_exact(self, query=None, uris=None, **kwargs):
|
||||
def get_distinct(self, field, query=None):
|
||||
"""
|
||||
Search the library for tracks where ``field`` is ``values``.
|
||||
List distinct values for a given field from the library.
|
||||
|
||||
If the query is empty, and the backend can support it, all available
|
||||
tracks are returned.
|
||||
This has mainly been added to support the list commands the MPD
|
||||
protocol supports in a more sane fashion. Other frontends are not
|
||||
recommended to use this method.
|
||||
|
||||
If ``uris`` is given, the search is limited to results from within the
|
||||
URI roots. For example passing ``uris=['file:']`` will limit the search
|
||||
to the local backend.
|
||||
:param string field: One of ``artist``, ``albumartist``, ``album``,
|
||||
``composer``, ``performer``, ``date``or ``genre``.
|
||||
:param dict query: Query to use for limiting results, see
|
||||
:meth:`search` for details about the query format.
|
||||
:rtype: set of values corresponding to the requested field type.
|
||||
|
||||
Examples::
|
||||
|
||||
# Returns results matching 'a' from any backend
|
||||
find_exact({'any': ['a']})
|
||||
find_exact(any=['a'])
|
||||
|
||||
# Returns results matching artist 'xyz' from any backend
|
||||
find_exact({'artist': ['xyz']})
|
||||
find_exact(artist=['xyz'])
|
||||
|
||||
# Returns results matching 'a' and 'b' and artist 'xyz' from any
|
||||
# backend
|
||||
find_exact({'any': ['a', 'b'], 'artist': ['xyz']})
|
||||
find_exact(any=['a', 'b'], artist=['xyz'])
|
||||
|
||||
# Returns results matching 'a' if within the given URI roots
|
||||
# "file:///media/music" and "spotify:"
|
||||
find_exact(
|
||||
{'any': ['a']}, uris=['file:///media/music', 'spotify:'])
|
||||
find_exact(any=['a'], uris=['file:///media/music', 'spotify:'])
|
||||
|
||||
:param query: one or more queries to search for
|
||||
:type query: dict
|
||||
:param uris: zero or more URI roots to limit the search to
|
||||
:type uris: list of strings or :class:`None`
|
||||
:rtype: list of :class:`mopidy.models.SearchResult`
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
futures = [b.library.get_distinct(field, query)
|
||||
for b in self.backends.with_library.values()]
|
||||
result = set()
|
||||
for r in pykka.get_all(futures):
|
||||
result.update(r)
|
||||
return result
|
||||
|
||||
def get_images(self, uris):
|
||||
"""Lookup the images for the given URIs
|
||||
|
||||
Backends can use this to return image URIs for any URI they know about
|
||||
be it tracks, albums, playlists... The lookup result is a dictionary
|
||||
mapping the provided URIs to lists of images.
|
||||
|
||||
Unknown URIs or URIs the corresponding backend couldn't find anything
|
||||
for will simply return an empty list for that URI.
|
||||
|
||||
:param list uris: list of URIs to find images for
|
||||
:rtype: {uri: tuple of :class:`mopidy.models.Image`}
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
query = query or kwargs
|
||||
futures = [
|
||||
backend.library.find_exact(query=query, uris=backend_uris)
|
||||
backend.library.get_images(backend_uris)
|
||||
for (backend, backend_uris)
|
||||
in self._get_backends_to_uris(uris).items()]
|
||||
return [result for result in pykka.get_all(futures) if result]
|
||||
in self._get_backends_to_uris(uris).items() if backend_uris]
|
||||
|
||||
def lookup(self, uri):
|
||||
results = {uri: tuple() for uri in uris}
|
||||
for r in pykka.get_all(futures):
|
||||
for uri, images in r.items():
|
||||
results[uri] += tuple(images)
|
||||
return results
|
||||
|
||||
def find_exact(self, query=None, uris=None, **kwargs):
|
||||
"""Search the library for tracks where ``field`` is ``values``.
|
||||
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`search` with ``exact`` set.
|
||||
"""
|
||||
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.
|
||||
|
||||
@ -125,14 +146,48 @@ class LibraryController(object):
|
||||
them all.
|
||||
|
||||
:param uri: track URI
|
||||
:type uri: string
|
||||
:rtype: list of :class:`mopidy.models.Track`
|
||||
:type uri: string or :class:`None`
|
||||
:param uris: track URIs
|
||||
:type uris: list of string or :class:`None`
|
||||
:rtype: list of :class:`mopidy.models.Track` if uri was set or
|
||||
a {uri: list of :class:`mopidy.models.Track`} if uris was set.
|
||||
|
||||
.. versionadded:: 1.0
|
||||
The ``uris`` argument.
|
||||
|
||||
.. deprecated:: 1.0
|
||||
The ``uri`` argument. Use ``uris`` instead.
|
||||
"""
|
||||
backend = self._get_backend(uri)
|
||||
if backend:
|
||||
return backend.library.lookup(uri).get()
|
||||
none_set = uri is None and uris is None
|
||||
both_set = uri is not None and uris is not None
|
||||
|
||||
if none_set or both_set:
|
||||
raise ValueError("One of 'uri' or 'uris' must be set")
|
||||
|
||||
if uri:
|
||||
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:
|
||||
return []
|
||||
result[u] = []
|
||||
|
||||
if uri:
|
||||
return result[uri]
|
||||
return result
|
||||
|
||||
def refresh(self, uri=None):
|
||||
"""
|
||||
@ -150,13 +205,10 @@ class LibraryController(object):
|
||||
for b in self.backends.with_library.values()]
|
||||
pykka.get_all(futures)
|
||||
|
||||
def search(self, query=None, uris=None, **kwargs):
|
||||
def search(self, query=None, uris=None, exact=False, **kwargs):
|
||||
"""
|
||||
Search the library for tracks where ``field`` contains ``values``.
|
||||
|
||||
If the query is empty, and the backend can support it, all available
|
||||
tracks are returned.
|
||||
|
||||
If ``uris`` is given, the search is limited to results from within the
|
||||
URI roots. For example passing ``uris=['file:']`` will limit the search
|
||||
to the local backend.
|
||||
@ -186,10 +238,58 @@ class LibraryController(object):
|
||||
:param uris: zero or more URI roots to limit the search to
|
||||
:type uris: list of strings or :class:`None`
|
||||
:rtype: list of :class:`mopidy.models.SearchResult`
|
||||
|
||||
.. versionadded:: 1.0
|
||||
The ``exact`` keyword argument, which replaces :meth:`find_exact`.
|
||||
|
||||
.. 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
|
||||
futures = [
|
||||
backend.library.search(query=query, uris=backend_uris)
|
||||
for (backend, backend_uris)
|
||||
in self._get_backends_to_uris(uris).items()]
|
||||
return [result for result in pykka.get_all(futures) if result]
|
||||
query = _normalize_query(query or kwargs)
|
||||
|
||||
if kwargs:
|
||||
deprecation.warn('core.library.search:kwargs_query')
|
||||
|
||||
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):
|
||||
|
||||
"""
|
||||
Marker interface for recipients of events sent by the core actor.
|
||||
|
||||
@ -163,3 +164,11 @@ class CoreListener(listener.Listener):
|
||||
:type time_position: int
|
||||
"""
|
||||
pass
|
||||
|
||||
def stream_title_changed(self, title):
|
||||
"""
|
||||
Called whenever the currently playing stream title changes.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
"""
|
||||
pass
|
||||
|
||||
58
mopidy/core/mixer.py
Normal file
58
mopidy/core/mixer.py
Normal file
@ -0,0 +1,58 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MixerController(object):
|
||||
pykka_traversable = True
|
||||
|
||||
def __init__(self, mixer):
|
||||
self._mixer = mixer
|
||||
|
||||
def get_volume(self):
|
||||
"""Get the volume.
|
||||
|
||||
Integer in range [0..100] or :class:`None` if unknown.
|
||||
|
||||
The volume scale is linear.
|
||||
"""
|
||||
if self._mixer is not None:
|
||||
return self._mixer.get_volume().get()
|
||||
|
||||
def set_volume(self, volume):
|
||||
"""Set the volume.
|
||||
|
||||
The volume is defined as an integer in range [0..100].
|
||||
|
||||
The volume scale is linear.
|
||||
|
||||
Returns :class:`True` if call is successful, otherwise :class:`False`.
|
||||
"""
|
||||
if self._mixer is None:
|
||||
return False
|
||||
else:
|
||||
return self._mixer.set_volume(volume).get()
|
||||
|
||||
def get_mute(self):
|
||||
"""Get mute state.
|
||||
|
||||
:class:`True` if muted, :class:`False` unmuted, :class:`None` if
|
||||
unknown.
|
||||
"""
|
||||
if self._mixer is not None:
|
||||
return self._mixer.get_mute().get()
|
||||
|
||||
def set_mute(self, mute):
|
||||
"""Set mute state.
|
||||
|
||||
:class:`True` to mute, :class:`False` to unmute.
|
||||
|
||||
Returns :class:`True` if call is successful, otherwise :class:`False`.
|
||||
"""
|
||||
if self._mixer is None:
|
||||
return False
|
||||
else:
|
||||
return self._mixer.set_mute(bool(mute)).get()
|
||||
@ -5,29 +5,28 @@ import urlparse
|
||||
|
||||
from mopidy.audio import PlaybackState
|
||||
from mopidy.core import listener
|
||||
|
||||
from mopidy.utils import deprecation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# TODO: split mixing out from playback?
|
||||
class PlaybackController(object):
|
||||
pykka_traversable = True
|
||||
|
||||
def __init__(self, audio, mixer, backends, core):
|
||||
def __init__(self, audio, backends, core):
|
||||
# TODO: these should be internal
|
||||
self.mixer = mixer
|
||||
self.backends = backends
|
||||
self.core = core
|
||||
|
||||
self._audio = audio
|
||||
|
||||
self._stream_title = None
|
||||
self._state = PlaybackState.STOPPED
|
||||
self._volume = None
|
||||
self._mute = False
|
||||
|
||||
self._current_tl_track = None
|
||||
self._pending_tl_track = None
|
||||
|
||||
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):
|
||||
if tl_track is None:
|
||||
@ -38,37 +37,56 @@ class PlaybackController(object):
|
||||
# Properties
|
||||
|
||||
def get_current_tl_track(self):
|
||||
return self.current_tl_track
|
||||
"""Get the currently playing or selected track.
|
||||
|
||||
current_tl_track = None
|
||||
Returns a :class:`mopidy.models.TlTrack` or :class:`None`.
|
||||
"""
|
||||
The currently playing or selected :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)
|
||||
"""
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`get_current_tl_track` instead.
|
||||
"""
|
||||
|
||||
def get_current_track(self):
|
||||
return self.current_tl_track and self.current_tl_track.track
|
||||
|
||||
current_track = property(get_current_track)
|
||||
"""
|
||||
The currently playing or selected :class:`mopidy.models.Track`.
|
||||
Get the currently playing or selected track.
|
||||
|
||||
Read-only. Extracted from :attr:`current_tl_track` for convenience.
|
||||
Extracted from :meth:`get_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):
|
||||
"""Get The playback state."""
|
||||
|
||||
return self._state
|
||||
|
||||
def set_state(self, new_state):
|
||||
(old_state, self._state) = (self.state, new_state)
|
||||
logger.debug('Changing state: %s -> %s', old_state, new_state)
|
||||
"""Set the playback state.
|
||||
|
||||
self._trigger_playback_state_changed(old_state, new_state)
|
||||
|
||||
state = property(get_state, set_state)
|
||||
"""
|
||||
The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or
|
||||
:attr:`STOPPED`.
|
||||
Must be :attr:`PLAYING`, :attr:`PAUSED`, or :attr:`STOPPED`.
|
||||
|
||||
Possible states and transitions:
|
||||
|
||||
@ -82,93 +100,125 @@ class PlaybackController(object):
|
||||
"PAUSED" -> "PLAYING" [ label="resume" ]
|
||||
"PAUSED" -> "STOPPED" [ label="stop" ]
|
||||
"""
|
||||
(old_state, self._state) = (self.get_state(), new_state)
|
||||
logger.debug('Changing state: %s -> %s', old_state, new_state)
|
||||
|
||||
self._trigger_playback_state_changed(old_state, new_state)
|
||||
|
||||
state = deprecation.deprecated_property(get_state, set_state)
|
||||
"""
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`get_state` and :meth:`set_state` instead.
|
||||
"""
|
||||
|
||||
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:
|
||||
return backend.playback.get_time_position().get()
|
||||
else:
|
||||
return 0
|
||||
|
||||
time_position = property(get_time_position)
|
||||
"""Time position in milliseconds."""
|
||||
time_position = deprecation.deprecated_property(get_time_position)
|
||||
"""
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`get_time_position` instead.
|
||||
"""
|
||||
|
||||
def get_volume(self):
|
||||
if self.mixer:
|
||||
return self.mixer.get_volume().get()
|
||||
else:
|
||||
# For testing
|
||||
return self._volume
|
||||
"""
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`core.mixer.get_volume()
|
||||
<mopidy.core.MixerController.get_volume>` instead.
|
||||
"""
|
||||
deprecation.warn('core.playback.get_volume')
|
||||
return self.core.mixer.get_volume()
|
||||
|
||||
def set_volume(self, volume):
|
||||
if self.mixer:
|
||||
self.mixer.set_volume(volume)
|
||||
else:
|
||||
# For testing
|
||||
self._volume = volume
|
||||
"""
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`core.mixer.set_volume()
|
||||
<mopidy.core.MixerController.set_volume>` instead.
|
||||
"""
|
||||
deprecation.warn('core.playback.set_volume')
|
||||
return self.core.mixer.set_volume(volume)
|
||||
|
||||
volume = property(get_volume, set_volume)
|
||||
"""Volume as int in range [0..100] or :class:`None` if unknown. The volume
|
||||
scale is linear.
|
||||
volume = deprecation.deprecated_property(get_volume, set_volume)
|
||||
"""
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`core.mixer.get_volume()
|
||||
<mopidy.core.MixerController.get_volume>` and
|
||||
:meth:`core.mixer.set_volume()
|
||||
<mopidy.core.MixerController.set_volume>` instead.
|
||||
"""
|
||||
|
||||
def get_mute(self):
|
||||
if self.mixer:
|
||||
return self.mixer.get_mute().get()
|
||||
else:
|
||||
# For testing
|
||||
return self._mute
|
||||
"""
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`core.mixer.get_mute()
|
||||
<mopidy.core.MixerController.get_mute>` instead.
|
||||
"""
|
||||
deprecation.warn('core.playback.get_mute')
|
||||
return self.core.mixer.get_mute()
|
||||
|
||||
def set_mute(self, value):
|
||||
value = bool(value)
|
||||
if self.mixer:
|
||||
self.mixer.set_mute(value)
|
||||
else:
|
||||
# For testing
|
||||
self._mute = value
|
||||
def set_mute(self, mute):
|
||||
"""
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`core.mixer.set_mute()
|
||||
<mopidy.core.MixerController.set_mute>` instead.
|
||||
"""
|
||||
deprecation.warn('core.playback.set_mute')
|
||||
return self.core.mixer.set_mute(mute)
|
||||
|
||||
mute = property(get_mute, set_mute)
|
||||
"""Mute state as a :class:`True` if muted, :class:`False` otherwise"""
|
||||
mute = deprecation.deprecated_property(get_mute, set_mute)
|
||||
"""
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`core.mixer.get_mute()
|
||||
<mopidy.core.MixerController.get_mute>` and
|
||||
:meth:`core.mixer.set_mute()
|
||||
<mopidy.core.MixerController.set_mute>` instead.
|
||||
"""
|
||||
|
||||
# Methods
|
||||
|
||||
def on_end_of_stream(self):
|
||||
self.state = PlaybackState.STOPPED
|
||||
self.current_tl_track = None
|
||||
def _on_end_of_stream(self):
|
||||
self.set_state(PlaybackState.STOPPED)
|
||||
self._set_current_tl_track(None)
|
||||
# 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:
|
||||
self.current_tl_track = self._pending_tl_track
|
||||
self._set_current_tl_track(self._pending_tl_track)
|
||||
self._pending_tl_track = None
|
||||
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
|
||||
|
||||
original_tl_track = self.current_tl_track
|
||||
original_tl_track = self.get_current_tl_track()
|
||||
next_tl_track = self.core.tracklist.eot_track(original_tl_track)
|
||||
|
||||
# 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
|
||||
backend = self._get_backend(next_tl_track)
|
||||
|
||||
if backend:
|
||||
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.
|
||||
|
||||
Used by :class:`mopidy.core.TracklistController`.
|
||||
"""
|
||||
|
||||
if not self.core.tracklist.tl_tracks:
|
||||
self.stop()
|
||||
self.current_tl_track = None
|
||||
elif self.current_tl_track not in self.core.tracklist.tl_tracks:
|
||||
self.current_tl_track = None
|
||||
self._set_current_tl_track(None)
|
||||
elif self.get_current_tl_track() not in self.core.tracklist.tl_tracks:
|
||||
self._set_current_tl_track(None)
|
||||
|
||||
def next(self):
|
||||
"""
|
||||
@ -177,64 +227,61 @@ class PlaybackController(object):
|
||||
The current playback state will be kept. If it was playing, playing
|
||||
will continue. If it was paused, it will still be paused, etc.
|
||||
"""
|
||||
original_tl_track = self.current_tl_track
|
||||
original_tl_track = self.get_current_tl_track()
|
||||
next_tl_track = self.core.tracklist.next_track(original_tl_track)
|
||||
|
||||
backend = self._get_backend(next_tl_track)
|
||||
self.current_tl_track = next_tl_track
|
||||
self._set_current_tl_track(next_tl_track)
|
||||
|
||||
if backend:
|
||||
backend.playback.prepare_change()
|
||||
backend.playback.change_track(next_tl_track.track)
|
||||
|
||||
if self.state == PlaybackState.PLAYING:
|
||||
if self.get_state() == PlaybackState.PLAYING:
|
||||
result = backend.playback.play().get()
|
||||
elif self.state == PlaybackState.PAUSED:
|
||||
elif self.get_state() == PlaybackState.PAUSED:
|
||||
result = backend.playback.pause().get()
|
||||
else:
|
||||
result = True
|
||||
|
||||
if result and self.state != PlaybackState.PAUSED:
|
||||
if result and self.get_state() != PlaybackState.PAUSED:
|
||||
self._trigger_track_playback_started()
|
||||
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.
|
||||
self.next()
|
||||
else:
|
||||
self.stop()
|
||||
|
||||
self.core.tracklist.mark_played(original_tl_track)
|
||||
self.core.tracklist._mark_played(original_tl_track)
|
||||
|
||||
def pause(self):
|
||||
"""Pause playback."""
|
||||
backend = self._get_backend(self.current_tl_track)
|
||||
backend = self._get_backend(self.get_current_tl_track())
|
||||
if not backend or backend.playback.pause().get():
|
||||
# TODO: switch to:
|
||||
# backend.track(pause)
|
||||
# wait for state change?
|
||||
self.state = PlaybackState.PAUSED
|
||||
self.set_state(PlaybackState.PAUSED)
|
||||
self._trigger_track_playback_paused()
|
||||
|
||||
def play(self, tl_track=None, on_error_step=1):
|
||||
def play(self, tl_track=None):
|
||||
"""
|
||||
Play the given track, or if the given track is :class:`None`, play the
|
||||
currently active track.
|
||||
|
||||
:param tl_track: track to play
|
||||
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
:param on_error_step: direction to step at play error, 1 for next
|
||||
track (default), -1 for previous track. **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 self.state == PlaybackState.PAUSED:
|
||||
if self.get_state() == PlaybackState.PAUSED:
|
||||
return self.resume()
|
||||
|
||||
if self.current_tl_track is not None:
|
||||
tl_track = self.current_tl_track
|
||||
if self.get_current_tl_track() is not None:
|
||||
tl_track = self.get_current_tl_track()
|
||||
else:
|
||||
if on_error_step == 1:
|
||||
tl_track = self.core.tracklist.next_track(tl_track)
|
||||
@ -244,29 +291,37 @@ class PlaybackController(object):
|
||||
if tl_track is None:
|
||||
return
|
||||
|
||||
assert tl_track in self.core.tracklist.tl_tracks
|
||||
assert tl_track in self.core.tracklist.get_tl_tracks()
|
||||
|
||||
# TODO: switch to:
|
||||
# backend.play(track)
|
||||
# wait for state change?
|
||||
|
||||
if self.state == PlaybackState.PLAYING:
|
||||
if self.get_state() == PlaybackState.PLAYING:
|
||||
self.stop()
|
||||
|
||||
self.current_tl_track = tl_track
|
||||
self.state = PlaybackState.PLAYING
|
||||
backend = self._get_backend(self.current_tl_track)
|
||||
self._set_current_tl_track(tl_track)
|
||||
self.set_state(PlaybackState.PLAYING)
|
||||
backend = self._get_backend(tl_track)
|
||||
success = False
|
||||
|
||||
if backend:
|
||||
backend.playback.prepare_change()
|
||||
backend.playback.change_track(tl_track.track)
|
||||
success = backend.playback.play().get()
|
||||
try:
|
||||
success = (
|
||||
backend.playback.change_track(tl_track.track).get() and
|
||||
backend.playback.play().get())
|
||||
except TypeError:
|
||||
logger.error('%s needs to be updated to work with this '
|
||||
'version of Mopidy.', backend)
|
||||
|
||||
if success:
|
||||
self.core.tracklist._mark_playing(tl_track)
|
||||
self.core.history._add_track(tl_track.track)
|
||||
# TODO: replace with stream-changed
|
||||
self._trigger_track_playback_started()
|
||||
else:
|
||||
self.core.tracklist.mark_unplayable(tl_track)
|
||||
self.core.tracklist._mark_unplayable(tl_track)
|
||||
if on_error_step == 1:
|
||||
# TODO: can cause an endless loop for single track repeat.
|
||||
self.next()
|
||||
@ -280,35 +335,38 @@ class PlaybackController(object):
|
||||
The current playback state will be kept. If it was playing, playing
|
||||
will continue. If it was paused, it will still be paused, etc.
|
||||
"""
|
||||
original_tl_track = self.current_tl_track
|
||||
original_tl_track = self.get_current_tl_track()
|
||||
prev_tl_track = self.core.tracklist.previous_track(original_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:
|
||||
backend.playback.prepare_change()
|
||||
# TODO: check return values of change 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()
|
||||
elif self.state == PlaybackState.PAUSED:
|
||||
elif self.get_state() == PlaybackState.PAUSED:
|
||||
result = backend.playback.pause().get()
|
||||
else:
|
||||
result = True
|
||||
|
||||
if result and self.state != PlaybackState.PAUSED:
|
||||
if result and self.get_state() != PlaybackState.PAUSED:
|
||||
self._trigger_track_playback_started()
|
||||
elif not result:
|
||||
self.core.tracklist.mark_unplayable(prev_tl_track)
|
||||
self.core.tracklist._mark_unplayable(prev_tl_track)
|
||||
self.previous()
|
||||
|
||||
# TODO: no return value?
|
||||
|
||||
def resume(self):
|
||||
"""If paused, resume playing the current track."""
|
||||
if self.state != PlaybackState.PAUSED:
|
||||
if self.get_state() != PlaybackState.PAUSED:
|
||||
return
|
||||
backend = self._get_backend(self.current_tl_track)
|
||||
backend = self._get_backend(self.get_current_tl_track())
|
||||
if backend and backend.playback.resume().get():
|
||||
self.state = PlaybackState.PLAYING
|
||||
self.set_state(PlaybackState.PLAYING)
|
||||
# TODO: trigger via gst messages
|
||||
self._trigger_track_playback_resumed()
|
||||
# TODO: switch to:
|
||||
@ -327,18 +385,20 @@ class PlaybackController(object):
|
||||
if not self.core.tracklist.tracks:
|
||||
return False
|
||||
|
||||
if self.state == PlaybackState.STOPPED:
|
||||
if self.current_track and self.current_track.length is None:
|
||||
return False
|
||||
|
||||
if self.get_state() == PlaybackState.STOPPED:
|
||||
self.play()
|
||||
elif self.state == PlaybackState.PAUSED:
|
||||
self.resume()
|
||||
|
||||
if time_position < 0:
|
||||
time_position = 0
|
||||
elif time_position > self.current_track.length:
|
||||
# TODO: gstreamer will trigger a about to finish for us, use that?
|
||||
self.next()
|
||||
return True
|
||||
|
||||
backend = self._get_backend(self.current_tl_track)
|
||||
backend = self._get_backend(self.get_current_tl_track())
|
||||
if not backend:
|
||||
return False
|
||||
|
||||
@ -349,11 +409,11 @@ class PlaybackController(object):
|
||||
|
||||
def stop(self):
|
||||
"""Stop playing."""
|
||||
if self.state != PlaybackState.STOPPED:
|
||||
backend = self._get_backend(self.current_tl_track)
|
||||
time_position_before_stop = self.time_position
|
||||
if self.get_state() != PlaybackState.STOPPED:
|
||||
backend = self._get_backend(self.get_current_tl_track())
|
||||
time_position_before_stop = self.get_time_position()
|
||||
if not backend or backend.playback.stop().get():
|
||||
self.state = PlaybackState.STOPPED
|
||||
self.set_state(PlaybackState.STOPPED)
|
||||
self._trigger_track_playback_ended(time_position_before_stop)
|
||||
|
||||
def _trigger_track_playback_paused(self):
|
||||
@ -362,7 +422,8 @@ class PlaybackController(object):
|
||||
return
|
||||
listener.CoreListener.send(
|
||||
'track_playback_paused',
|
||||
tl_track=self.current_tl_track, time_position=self.time_position)
|
||||
tl_track=self.get_current_tl_track(),
|
||||
time_position=self.get_time_position())
|
||||
|
||||
def _trigger_track_playback_resumed(self):
|
||||
logger.debug('Triggering track playback resumed event')
|
||||
@ -370,27 +431,27 @@ class PlaybackController(object):
|
||||
return
|
||||
listener.CoreListener.send(
|
||||
'track_playback_resumed',
|
||||
tl_track=self.current_tl_track, time_position=self.time_position)
|
||||
tl_track=self.get_current_tl_track(),
|
||||
time_position=self.get_time_position())
|
||||
|
||||
def _trigger_track_playback_started(self):
|
||||
# TODO: replace with stream-changed
|
||||
logger.debug('Triggering track playback started event')
|
||||
if self.current_tl_track is None:
|
||||
if self.get_current_tl_track() is None:
|
||||
return
|
||||
|
||||
self.core.tracklist.mark_playing(self.current_tl_track)
|
||||
self.core.history.add(self.current_tl_track.track)
|
||||
listener.CoreListener.send(
|
||||
'track_playback_started',
|
||||
tl_track=self.current_tl_track)
|
||||
tl_track = self.get_current_tl_track()
|
||||
self.core.tracklist._mark_playing(tl_track)
|
||||
self.core.history._add_track(tl_track.track)
|
||||
listener.CoreListener.send('track_playback_started', tl_track=tl_track)
|
||||
|
||||
def _trigger_track_playback_ended(self, time_position_before_stop):
|
||||
logger.debug('Triggering track playback ended event')
|
||||
if self.current_tl_track is None:
|
||||
if self.get_current_tl_track() is None:
|
||||
return
|
||||
listener.CoreListener.send(
|
||||
'track_playback_ended',
|
||||
tl_track=self.current_tl_track,
|
||||
tl_track=self.get_current_tl_track(),
|
||||
time_position=time_position_before_stop)
|
||||
|
||||
def _trigger_playback_state_changed(self, old_state, new_state):
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
import urlparse
|
||||
|
||||
import pykka
|
||||
|
||||
from . import listener
|
||||
from mopidy.core import listener
|
||||
from mopidy.models import Playlist
|
||||
from mopidy.utils import deprecation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlaylistsController(object):
|
||||
@ -15,20 +19,85 @@ class PlaylistsController(object):
|
||||
self.backends = backends
|
||||
self.core = core
|
||||
|
||||
def get_playlists(self, include_tracks=True):
|
||||
futures = [b.playlists.playlists
|
||||
for b in self.backends.with_playlists.values()]
|
||||
results = pykka.get_all(futures)
|
||||
playlists = list(itertools.chain(*results))
|
||||
if not include_tracks:
|
||||
playlists = [p.copy(tracks=[]) for p in playlists]
|
||||
return playlists
|
||||
|
||||
playlists = property(get_playlists)
|
||||
def as_list(self):
|
||||
"""
|
||||
The available playlists.
|
||||
Get a list of the currently available playlists.
|
||||
|
||||
Read-only. 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
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
Get the available 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)
|
||||
"""
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`as_list` and :meth:`get_items` instead.
|
||||
"""
|
||||
|
||||
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
|
||||
asked to create the playlist.
|
||||
|
||||
All new playlists should be created by calling this method, and **not**
|
||||
All new playlists must be created by calling this method, and **not**
|
||||
by creating new instances of :class:`mopidy.models.Playlist`.
|
||||
|
||||
:param name: name of the new playlist
|
||||
@ -94,7 +163,12 @@ class PlaylistsController(object):
|
||||
:param criteria: one or more criteria to match by
|
||||
:type criteria: dict
|
||||
:rtype: list of :class:`mopidy.models.Playlist`
|
||||
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`as_list` and filter yourself.
|
||||
"""
|
||||
deprecation.warn('core.playlists.filter')
|
||||
|
||||
criteria = criteria or kwargs
|
||||
matches = self.playlists
|
||||
for (key, value) in criteria.iteritems():
|
||||
@ -145,14 +219,14 @@ class PlaylistsController(object):
|
||||
Save the playlist.
|
||||
|
||||
For a playlist to be saveable, it must have the ``uri`` attribute set.
|
||||
You should not set the ``uri`` atribute yourself, but use playlist
|
||||
You must not set the ``uri`` atribute yourself, but use playlist
|
||||
objects returned by :meth:`create` or retrieved from :attr:`playlists`,
|
||||
which will always give you saveable playlists.
|
||||
|
||||
The method returns the saved playlist. The return playlist may differ
|
||||
from the saved playlist. E.g. if the playlist name was changed, the
|
||||
returned playlist may have a different URI. The caller of this method
|
||||
should throw away the playlist sent to this method, and use the
|
||||
must throw away the playlist sent to this method, and use the
|
||||
returned playlist instead.
|
||||
|
||||
If the playlist's URI isn't set or doesn't match the URI scheme of a
|
||||
|
||||
@ -7,7 +7,7 @@ import random
|
||||
from mopidy import compat
|
||||
from mopidy.core import listener
|
||||
from mopidy.models import TlTrack
|
||||
|
||||
from mopidy.utils import deprecation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -26,114 +26,176 @@ class TracklistController(object):
|
||||
# Properties
|
||||
|
||||
def get_tl_tracks(self):
|
||||
"""Get tracklist as list of :class:`mopidy.models.TlTrack`."""
|
||||
return self._tl_tracks[:]
|
||||
|
||||
tl_tracks = property(get_tl_tracks)
|
||||
tl_tracks = deprecation.deprecated_property(get_tl_tracks)
|
||||
"""
|
||||
List of :class:`mopidy.models.TlTrack`.
|
||||
|
||||
Read-only.
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`get_tl_tracks` instead.
|
||||
"""
|
||||
|
||||
def get_tracks(self):
|
||||
"""Get tracklist as list of :class:`mopidy.models.Track`."""
|
||||
return [tl_track.track for tl_track in self._tl_tracks]
|
||||
|
||||
tracks = property(get_tracks)
|
||||
tracks = deprecation.deprecated_property(get_tracks)
|
||||
"""
|
||||
List of :class:`mopidy.models.Track` in the tracklist.
|
||||
|
||||
Read-only.
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`get_tracks` instead.
|
||||
"""
|
||||
|
||||
def get_length(self):
|
||||
"""Get length of the tracklist."""
|
||||
return len(self._tl_tracks)
|
||||
|
||||
length = property(get_length)
|
||||
"""Length of the tracklist."""
|
||||
length = deprecation.deprecated_property(get_length)
|
||||
"""
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`get_length` instead.
|
||||
"""
|
||||
|
||||
def get_version(self):
|
||||
"""
|
||||
Get the tracklist version.
|
||||
|
||||
Integer which is increased every time the tracklist is changed. Is not
|
||||
reset before Mopidy is restarted.
|
||||
"""
|
||||
return self._version
|
||||
|
||||
def _increase_version(self):
|
||||
self._version += 1
|
||||
self.core.playback.on_tracklist_change()
|
||||
self.core.playback._on_tracklist_change()
|
||||
self._trigger_tracklist_changed()
|
||||
|
||||
version = property(get_version)
|
||||
version = deprecation.deprecated_property(get_version)
|
||||
"""
|
||||
The tracklist version.
|
||||
|
||||
Read-only. Integer which is increased every time the tracklist is changed.
|
||||
Is not reset before Mopidy is restarted.
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`get_version` instead.
|
||||
"""
|
||||
|
||||
def get_consume(self):
|
||||
return getattr(self, '_consume', False)
|
||||
"""Get consume mode.
|
||||
|
||||
def set_consume(self, value):
|
||||
if self.get_consume() != value:
|
||||
self._trigger_options_changed()
|
||||
return setattr(self, '_consume', value)
|
||||
|
||||
consume = property(get_consume, set_consume)
|
||||
"""
|
||||
:class:`True`
|
||||
Tracks are removed from the tracklist when they have been played.
|
||||
:class:`False`
|
||||
Tracks are not removed from the tracklist.
|
||||
"""
|
||||
return getattr(self, '_consume', False)
|
||||
|
||||
def set_consume(self, value):
|
||||
"""Set consume mode.
|
||||
|
||||
:class:`True`
|
||||
Tracks are removed from the tracklist when they have been played.
|
||||
:class:`False`
|
||||
Tracks are not removed from the tracklist.
|
||||
"""
|
||||
if self.get_consume() != value:
|
||||
self._trigger_options_changed()
|
||||
return setattr(self, '_consume', value)
|
||||
|
||||
consume = deprecation.deprecated_property(get_consume, set_consume)
|
||||
"""
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`get_consume` and :meth:`set_consume` instead.
|
||||
"""
|
||||
|
||||
def get_random(self):
|
||||
"""Get random mode.
|
||||
|
||||
:class:`True`
|
||||
Tracks are selected at random from the tracklist.
|
||||
:class:`False`
|
||||
Tracks are played in the order of the tracklist.
|
||||
"""
|
||||
return getattr(self, '_random', False)
|
||||
|
||||
def set_random(self, value):
|
||||
if self.get_random() != value:
|
||||
self._trigger_options_changed()
|
||||
if value:
|
||||
self._shuffled = self.tl_tracks
|
||||
random.shuffle(self._shuffled)
|
||||
return setattr(self, '_random', value)
|
||||
"""Set random mode.
|
||||
|
||||
random = property(get_random, set_random)
|
||||
"""
|
||||
:class:`True`
|
||||
Tracks are selected at random from the tracklist.
|
||||
:class:`False`
|
||||
Tracks are played in the order of the tracklist.
|
||||
"""
|
||||
|
||||
if self.get_random() != value:
|
||||
self._trigger_options_changed()
|
||||
if value:
|
||||
self._shuffled = self.get_tl_tracks()
|
||||
random.shuffle(self._shuffled)
|
||||
return setattr(self, '_random', value)
|
||||
|
||||
random = deprecation.deprecated_property(get_random, set_random)
|
||||
"""
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`get_random` and :meth:`set_random` instead.
|
||||
"""
|
||||
|
||||
def get_repeat(self):
|
||||
"""
|
||||
Get repeat mode.
|
||||
|
||||
:class:`True`
|
||||
The tracklist is played repeatedly.
|
||||
:class:`False`
|
||||
The tracklist is played once.
|
||||
"""
|
||||
return getattr(self, '_repeat', False)
|
||||
|
||||
def set_repeat(self, value):
|
||||
if self.get_repeat() != value:
|
||||
self._trigger_options_changed()
|
||||
return setattr(self, '_repeat', value)
|
||||
|
||||
repeat = property(get_repeat, set_repeat)
|
||||
"""
|
||||
Set repeat mode.
|
||||
|
||||
To repeat a single track, set both ``repeat`` and ``single``.
|
||||
|
||||
:class:`True`
|
||||
The tracklist is played repeatedly. To repeat a single track, select
|
||||
both :attr:`repeat` and :attr:`single`.
|
||||
The tracklist is played repeatedly.
|
||||
:class:`False`
|
||||
The tracklist is played once.
|
||||
"""
|
||||
|
||||
if self.get_repeat() != value:
|
||||
self._trigger_options_changed()
|
||||
return setattr(self, '_repeat', value)
|
||||
|
||||
repeat = deprecation.deprecated_property(get_repeat, set_repeat)
|
||||
"""
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`get_repeat` and :meth:`set_repeat` instead.
|
||||
"""
|
||||
|
||||
def get_single(self):
|
||||
"""
|
||||
Get single mode.
|
||||
|
||||
:class:`True`
|
||||
Playback is stopped after current song, unless in ``repeat`` mode.
|
||||
:class:`False`
|
||||
Playback continues after current song.
|
||||
"""
|
||||
return getattr(self, '_single', False)
|
||||
|
||||
def set_single(self, value):
|
||||
"""
|
||||
Set single mode.
|
||||
|
||||
:class:`True`
|
||||
Playback is stopped after current song, unless in ``repeat`` mode.
|
||||
:class:`False`
|
||||
Playback continues after current song.
|
||||
"""
|
||||
if self.get_single() != value:
|
||||
self._trigger_options_changed()
|
||||
return setattr(self, '_single', value)
|
||||
|
||||
single = property(get_single, set_single)
|
||||
single = deprecation.deprecated_property(get_single, set_single)
|
||||
"""
|
||||
:class:`True`
|
||||
Playback is stopped after current song, unless in :attr:`repeat`
|
||||
mode.
|
||||
:class:`False`
|
||||
Playback continues after current song.
|
||||
.. deprecated:: 1.0
|
||||
Use :meth:`get_single` and :meth:`set_single` instead.
|
||||
"""
|
||||
|
||||
# Methods
|
||||
@ -161,9 +223,9 @@ class TracklistController(object):
|
||||
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
:rtype: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
"""
|
||||
if self.single and self.repeat:
|
||||
if self.get_single() and self.get_repeat():
|
||||
return tl_track
|
||||
elif self.single:
|
||||
elif self.get_single():
|
||||
return None
|
||||
|
||||
# Current difference between next and EOT handling is that EOT needs to
|
||||
@ -186,30 +248,30 @@ class TracklistController(object):
|
||||
:rtype: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
"""
|
||||
|
||||
if not self.tl_tracks:
|
||||
if not self.get_tl_tracks():
|
||||
return None
|
||||
|
||||
if self.random and not self._shuffled:
|
||||
if self.repeat or not tl_track:
|
||||
if self.get_random() and not self._shuffled:
|
||||
if self.get_repeat() or not tl_track:
|
||||
logger.debug('Shuffling tracks')
|
||||
self._shuffled = self.tl_tracks
|
||||
self._shuffled = self.get_tl_tracks()
|
||||
random.shuffle(self._shuffled)
|
||||
|
||||
if self.random:
|
||||
if self.get_random():
|
||||
try:
|
||||
return self._shuffled[0]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
if tl_track is None:
|
||||
return self.tl_tracks[0]
|
||||
return self.get_tl_tracks()[0]
|
||||
|
||||
next_index = self.index(tl_track) + 1
|
||||
if self.repeat:
|
||||
next_index %= len(self.tl_tracks)
|
||||
if self.get_repeat():
|
||||
next_index %= len(self.get_tl_tracks())
|
||||
|
||||
try:
|
||||
return self.tl_tracks[next_index]
|
||||
return self.get_tl_tracks()[next_index]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
@ -226,7 +288,7 @@ class TracklistController(object):
|
||||
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
:rtype: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
"""
|
||||
if self.repeat or self.consume or self.random:
|
||||
if self.get_repeat() or self.get_consume() or self.get_random():
|
||||
return tl_track
|
||||
|
||||
position = self.index(tl_track)
|
||||
@ -234,15 +296,18 @@ class TracklistController(object):
|
||||
if position in (None, 0):
|
||||
return None
|
||||
|
||||
return self.tl_tracks[position - 1]
|
||||
return self.get_tl_tracks()[position - 1]
|
||||
|
||||
def add(self, tracks=None, at_position=None, uri=None):
|
||||
def add(self, tracks=None, at_position=None, uri=None, uris=None):
|
||||
"""
|
||||
Add the track or list of tracks to the tracklist.
|
||||
|
||||
If ``uri`` is given instead of ``tracks``, the URI is looked up in the
|
||||
library and the resulting tracks are added to the tracklist.
|
||||
|
||||
If ``uris`` is given instead of ``tracks``, the URIs are looked up in
|
||||
the library and the resulting tracks are added to the tracklist.
|
||||
|
||||
If ``at_position`` is given, the tracks placed at the given position in
|
||||
the tracklist. If ``at_position`` is not given, the tracks are appended
|
||||
to the end of the tracklist.
|
||||
@ -256,12 +321,32 @@ class TracklistController(object):
|
||||
:param uri: URI for tracks to add
|
||||
:type uri: string
|
||||
:rtype: list of :class:`mopidy.models.TlTrack`
|
||||
"""
|
||||
assert tracks is not None or uri is not None, \
|
||||
'tracks or uri must be provided'
|
||||
|
||||
if tracks is None and uri is not None:
|
||||
tracks = self.core.library.lookup(uri)
|
||||
.. versionadded:: 1.0
|
||||
The ``uris`` argument.
|
||||
|
||||
.. deprecated:: 1.0
|
||||
The ``tracks`` and ``uri`` arguments. Use ``uris``.
|
||||
"""
|
||||
assert tracks is not None or uri is not None or uris is not None, \
|
||||
'tracks, uri or uris must be provided'
|
||||
|
||||
# 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 = []
|
||||
|
||||
@ -329,8 +414,8 @@ class TracklistController(object):
|
||||
criteria = criteria or kwargs
|
||||
matches = self._tl_tracks
|
||||
for (key, values) in criteria.items():
|
||||
if (not isinstance(values, collections.Iterable)
|
||||
or isinstance(values, compat.string_types)):
|
||||
if (not isinstance(values, collections.Iterable) or
|
||||
isinstance(values, compat.string_types)):
|
||||
# Fail hard if anyone is using the <0.17 calling style
|
||||
raise ValueError('Filter values must be iterable: %r' % values)
|
||||
if key == 'tlid':
|
||||
@ -436,27 +521,27 @@ class TracklistController(object):
|
||||
"""
|
||||
return self._tl_tracks[start:end]
|
||||
|
||||
def mark_playing(self, tl_track):
|
||||
"""Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**"""
|
||||
if self.random and tl_track in self._shuffled:
|
||||
def _mark_playing(self, tl_track):
|
||||
"""Internal method for :class:`mopidy.core.PlaybackController`."""
|
||||
if self.get_random() and tl_track in self._shuffled:
|
||||
self._shuffled.remove(tl_track)
|
||||
|
||||
def mark_unplayable(self, tl_track):
|
||||
"""Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**"""
|
||||
def _mark_unplayable(self, tl_track):
|
||||
"""Internal method for :class:`mopidy.core.PlaybackController`."""
|
||||
logger.warning('Track is not playable: %s', tl_track.track.uri)
|
||||
if self.random and tl_track in self._shuffled:
|
||||
if self.get_random() and tl_track in self._shuffled:
|
||||
self._shuffled.remove(tl_track)
|
||||
|
||||
def mark_played(self, tl_track):
|
||||
"""Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**"""
|
||||
def _mark_played(self, tl_track):
|
||||
"""Internal method for :class:`mopidy.core.PlaybackController`."""
|
||||
if self.consume and tl_track is not None:
|
||||
self.remove(tlid=[tl_track.tlid])
|
||||
return True
|
||||
return False
|
||||
|
||||
def _trigger_tracklist_changed(self):
|
||||
if self.random:
|
||||
self._shuffled = self.tl_tracks
|
||||
if self.get_random():
|
||||
self._shuffled = self.get_tl_tracks()
|
||||
random.shuffle(self._shuffled)
|
||||
else:
|
||||
self._shuffled = []
|
||||
|
||||
@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals
|
||||
|
||||
|
||||
class MopidyException(Exception):
|
||||
|
||||
def __init__(self, message, *args, **kwargs):
|
||||
super(MopidyException, self).__init__(message, *args, **kwargs)
|
||||
self._message = message
|
||||
@ -25,6 +26,7 @@ class ExtensionError(MopidyException):
|
||||
|
||||
|
||||
class FindError(MopidyException):
|
||||
|
||||
def __init__(self, message, errno=None):
|
||||
super(FindError, self).__init__(message, errno)
|
||||
self.errno = errno
|
||||
|
||||
@ -12,6 +12,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Extension(object):
|
||||
|
||||
"""Base class for Mopidy extensions"""
|
||||
|
||||
dist_name = None
|
||||
@ -104,6 +105,7 @@ class Extension(object):
|
||||
|
||||
|
||||
class Registry(collections.Mapping):
|
||||
|
||||
"""Registry of components provided by Mopidy extensions.
|
||||
|
||||
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
|
||||
from mopidy import core, models
|
||||
from mopidy.utils import jsonrpc
|
||||
from mopidy.utils import encoding, jsonrpc
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -43,6 +43,7 @@ def make_jsonrpc_wrapper(core_actor):
|
||||
'core.get_version': core.Core.get_version,
|
||||
'core.history': core.HistoryController,
|
||||
'core.library': core.LibraryController,
|
||||
'core.mixer': core.MixerController,
|
||||
'core.playback': core.PlaybackController,
|
||||
'core.playlists': core.PlaylistsController,
|
||||
'core.tracklist': core.TracklistController,
|
||||
@ -54,6 +55,7 @@ def make_jsonrpc_wrapper(core_actor):
|
||||
'core.get_version': core_actor.get_version,
|
||||
'core.history': core_actor.history,
|
||||
'core.library': core_actor.library,
|
||||
'core.mixer': core_actor.mixer,
|
||||
'core.playback': core_actor.playback,
|
||||
'core.playlists': core_actor.playlists,
|
||||
'core.tracklist': core_actor.tracklist,
|
||||
@ -73,7 +75,16 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
|
||||
@classmethod
|
||||
def broadcast(cls, msg):
|
||||
for client in cls.clients:
|
||||
# We could check for client.ws_connection, but we don't really
|
||||
# care why the broadcast failed, we just want the rest of them
|
||||
# to succeed, so catch everything.
|
||||
try:
|
||||
client.write_message(msg)
|
||||
except Exception as e:
|
||||
error_msg = encoding.locale_decode(e)
|
||||
logger.debug('Broadcast of WebSocket message to %s failed: %s',
|
||||
client.request.remote_ip, error_msg)
|
||||
# TODO: should this do the same cleanup as the on_message code?
|
||||
|
||||
def initialize(self, core):
|
||||
self.jsonrpc = make_jsonrpc_wrapper(core)
|
||||
@ -111,7 +122,8 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
|
||||
'Sent WebSocket message to %s: %r',
|
||||
self.request.remote_ip, response)
|
||||
except Exception as e:
|
||||
logger.error('WebSocket request error: %s', e)
|
||||
error_msg = encoding.locale_decode(e)
|
||||
logger.error('WebSocket request error: %s', error_msg)
|
||||
if self.ws_connection:
|
||||
# Tornado 3.2+ checks if self.ws_connection is None before
|
||||
# using it, but not older versions.
|
||||
@ -130,6 +142,7 @@ def set_mopidy_headers(request_handler):
|
||||
|
||||
|
||||
class JsonRpcHandler(tornado.web.RequestHandler):
|
||||
|
||||
def initialize(self, core):
|
||||
self.jsonrpc = make_jsonrpc_wrapper(core)
|
||||
|
||||
@ -164,6 +177,7 @@ class JsonRpcHandler(tornado.web.RequestHandler):
|
||||
|
||||
|
||||
class ClientListHandler(tornado.web.RequestHandler):
|
||||
|
||||
def initialize(self, apps, statics):
|
||||
self.apps = apps
|
||||
self.statics = statics
|
||||
@ -185,6 +199,7 @@ class ClientListHandler(tornado.web.RequestHandler):
|
||||
|
||||
|
||||
class StaticFileHandler(tornado.web.StaticFileHandler):
|
||||
|
||||
def set_extra_headers(self, path):
|
||||
set_mopidy_headers(self)
|
||||
|
||||
|
||||
@ -17,10 +17,25 @@ def send(cls, event, **kwargs):
|
||||
listeners = pykka.ActorRegistry.get_by_class(cls)
|
||||
logger.debug('Sending %s to %s: %s', event, cls.__name__, kwargs)
|
||||
for listener in listeners:
|
||||
listener.proxy().on_event(event, **kwargs)
|
||||
# Save time by calling methods on Pykka actor without creating a
|
||||
# throwaway actor proxy.
|
||||
#
|
||||
# Because we use `.tell()` there is no return channel for any errors,
|
||||
# so Pykka logs them immediately. The alternative would be to use
|
||||
# `.ask()` and `.get()` the returned futures to block for the listeners
|
||||
# to react and return their exceptions to us. Since emitting events in
|
||||
# practise is making calls upwards in the stack, blocking here would
|
||||
# quickly deadlock.
|
||||
listener.tell({
|
||||
'command': 'pykka_call',
|
||||
'attr_path': ('on_event',),
|
||||
'args': (event,),
|
||||
'kwargs': kwargs,
|
||||
})
|
||||
|
||||
|
||||
class Listener(object):
|
||||
|
||||
def on_event(self, event, **kwargs):
|
||||
"""
|
||||
Called on all events.
|
||||
|
||||
@ -4,7 +4,7 @@ import logging
|
||||
import os
|
||||
|
||||
import mopidy
|
||||
from mopidy import config, ext
|
||||
from mopidy import config, ext, models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -24,7 +24,7 @@ class Extension(ext.Extension):
|
||||
schema['library'] = config.String()
|
||||
schema['media_dir'] = config.Path()
|
||||
schema['data_dir'] = config.Path()
|
||||
schema['playlists_dir'] = config.Path()
|
||||
schema['playlists_dir'] = config.Deprecated()
|
||||
schema['tag_cache_file'] = config.Deprecated()
|
||||
schema['scan_timeout'] = config.Integer(
|
||||
minimum=1000, maximum=1000 * 60 * 60)
|
||||
@ -48,6 +48,7 @@ class Extension(ext.Extension):
|
||||
|
||||
|
||||
class Library(object):
|
||||
|
||||
"""
|
||||
Local library interface.
|
||||
|
||||
@ -70,6 +71,10 @@ class Library(object):
|
||||
#: Name of the local library implementation, must be overriden.
|
||||
name = None
|
||||
|
||||
#: Feature marker to indicate that you want :meth:`add()` calls to be
|
||||
#: called with optional arguments tags and duration.
|
||||
add_supports_tags_and_duration = False
|
||||
|
||||
def __init__(self, config):
|
||||
self._config = config
|
||||
|
||||
@ -85,6 +90,43 @@ class Library(object):
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_distinct(self, field, query=None):
|
||||
"""
|
||||
List distinct values for a given field from the library.
|
||||
|
||||
:param string field: One of ``artist``, ``albumartist``, ``album``,
|
||||
``composer``, ``performer``, ``date``or ``genre``.
|
||||
:param dict query: Query to use for limiting results, see
|
||||
:meth:`search` for details about the query format.
|
||||
:rtype: set of values corresponding to the requested field type.
|
||||
"""
|
||||
return set()
|
||||
|
||||
def get_images(self, uris):
|
||||
"""
|
||||
Lookup the images for the given URIs.
|
||||
|
||||
The default implementation will simply call :meth:`lookup` and
|
||||
try and use the album art for any tracks returned. Most local
|
||||
libraries should replace this with something smarter or simply
|
||||
return an empty dictionary.
|
||||
|
||||
:param list uris: list of URIs to find images for
|
||||
:rtype: {uri: tuple of :class:`mopidy.models.Image`}
|
||||
"""
|
||||
result = {}
|
||||
for uri in uris:
|
||||
image_uris = set()
|
||||
tracks = self.lookup(uri)
|
||||
# local libraries may return single track
|
||||
if isinstance(tracks, models.Track):
|
||||
tracks = [tracks]
|
||||
for track in tracks:
|
||||
if track.album and track.album.images:
|
||||
image_uris.update(track.album.images)
|
||||
result[uri] = [models.Image(uri=u) for u in image_uris]
|
||||
return result
|
||||
|
||||
def load(self):
|
||||
"""
|
||||
(Re)load any tracks stored in memory, if any, otherwise just return
|
||||
@ -135,12 +177,19 @@ class Library(object):
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def add(self, track):
|
||||
def add(self, track, tags=None, duration=None):
|
||||
"""
|
||||
Add the given track to library.
|
||||
Add the given track to library. Optional args will only be added if
|
||||
:attr:`add_supports_tags_and_duration` has been set.
|
||||
|
||||
:param track: Track to add to the library
|
||||
:type track: :class:`~mopidy.models.Track`
|
||||
:param tags: All the tags the scanner found for the media. See
|
||||
:mod:`mopidy.audio.utils` for details about the tags.
|
||||
:type tags: dictionary of tag keys with a list of values.
|
||||
:param duration: Duration of media in milliseconds or :class:`None` if
|
||||
unknown
|
||||
:type duration: :class:`int` or :class:`None`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@ -8,7 +8,6 @@ from mopidy import backend
|
||||
from mopidy.local import storage
|
||||
from mopidy.local.library import LocalLibraryProvider
|
||||
from mopidy.local.playback import LocalPlaybackProvider
|
||||
from mopidy.local.playlists import LocalPlaylistsProvider
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -36,5 +35,4 @@ class LocalBackend(pykka.ThreadingActor, backend.Backend):
|
||||
logger.warning('Local library %s not found', library_name)
|
||||
|
||||
self.playback = LocalPlaybackProvider(audio=audio, backend=self)
|
||||
self.playlists = LocalPlaylistsProvider(backend=self)
|
||||
self.library = LocalLibraryProvider(backend=self, library=library)
|
||||
|
||||
@ -29,6 +29,7 @@ def _get_library(args, config):
|
||||
|
||||
|
||||
class LocalCommand(commands.Command):
|
||||
|
||||
def __init__(self):
|
||||
super(LocalCommand, self).__init__()
|
||||
self.add_child('scan', ScanCommand())
|
||||
@ -61,7 +62,10 @@ class ScanCommand(commands.Command):
|
||||
super(ScanCommand, self).__init__()
|
||||
self.add_argument('--limit',
|
||||
action='store', type=int, dest='limit', default=None,
|
||||
help='Maxmimum number of tracks to scan')
|
||||
help='Maximum number of tracks to scan')
|
||||
self.add_argument('--force',
|
||||
action='store_true', dest='force', default=False,
|
||||
help='Force rescan of all media files')
|
||||
|
||||
def run(self, args, config):
|
||||
media_dir = config['local']['media_dir']
|
||||
@ -97,7 +101,7 @@ class ScanCommand(commands.Command):
|
||||
if mtime is None:
|
||||
logger.debug('Missing file %s', track.uri)
|
||||
uris_to_remove.add(track.uri)
|
||||
elif mtime > track.last_modified:
|
||||
elif mtime > track.last_modified or args.force:
|
||||
uris_to_update.add(track.uri)
|
||||
uris_in_library.add(track.uri)
|
||||
|
||||
@ -130,7 +134,8 @@ class ScanCommand(commands.Command):
|
||||
try:
|
||||
relpath = translator.local_track_uri_to_path(uri, media_dir)
|
||||
file_uri = path.path_to_uri(os.path.join(media_dir, relpath))
|
||||
tags, duration = scanner.scan(file_uri)
|
||||
result = scanner.scan(file_uri)
|
||||
tags, duration = result.tags, result.duration
|
||||
if duration < MIN_DURATION_MS:
|
||||
logger.warning('Failed %s: Track shorter than %dms',
|
||||
uri, MIN_DURATION_MS)
|
||||
@ -138,8 +143,9 @@ class ScanCommand(commands.Command):
|
||||
mtime = file_mtimes.get(os.path.join(media_dir, relpath))
|
||||
track = utils.convert_tags_to_track(tags).copy(
|
||||
uri=uri, length=duration, last_modified=mtime)
|
||||
track = translator.add_musicbrainz_coverart_to_track(track)
|
||||
# TODO: add tags to call if library supports it.
|
||||
if library.add_supports_tags_and_duration:
|
||||
library.add(track, tags=tags, duration=duration)
|
||||
else:
|
||||
library.add(track)
|
||||
logger.debug('Added %s', track.uri)
|
||||
except exceptions.ScannerError as error:
|
||||
@ -157,6 +163,7 @@ class ScanCommand(commands.Command):
|
||||
|
||||
|
||||
class _Progress(object):
|
||||
|
||||
def __init__(self, batch_size, total):
|
||||
self.count = 0
|
||||
self.batch_size = batch_size
|
||||
@ -173,6 +180,6 @@ class _Progress(object):
|
||||
logger.info('Scanned %d of %d files in %ds.',
|
||||
self.count, self.total, duration)
|
||||
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.',
|
||||
self.count, self.total, duration, remainder)
|
||||
|
||||
@ -3,7 +3,6 @@ enabled = true
|
||||
library = json
|
||||
media_dir = $XDG_MUSIC_DIR
|
||||
data_dir = $XDG_DATA_DIR/mopidy/local
|
||||
playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists
|
||||
scan_timeout = 1000
|
||||
scan_flush_threshold = 1000
|
||||
scan_follow_symlinks = false
|
||||
|
||||
@ -8,12 +8,11 @@ import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import mopidy
|
||||
from mopidy import compat, local, models
|
||||
from mopidy.local import search, storage, translator
|
||||
from mopidy.utils import encoding
|
||||
from mopidy.utils import encoding, timer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -109,20 +108,6 @@ class _BrowseCache(object):
|
||||
return self._cache.get(uri, {}).values()
|
||||
|
||||
|
||||
# TODO: make this available to other code?
|
||||
class DebugTimer(object):
|
||||
def __init__(self, msg):
|
||||
self.msg = msg
|
||||
self.start = None
|
||||
|
||||
def __enter__(self):
|
||||
self.start = time.time()
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
duration = (time.time() - self.start) * 1000
|
||||
logger.debug('%s: %dms', self.msg, duration)
|
||||
|
||||
|
||||
class JsonLibrary(local.Library):
|
||||
name = 'json'
|
||||
|
||||
@ -142,10 +127,10 @@ class JsonLibrary(local.Library):
|
||||
|
||||
def load(self):
|
||||
logger.debug('Loading library: %s', self._json_file)
|
||||
with DebugTimer('Loading tracks'):
|
||||
with timer.time_logger('Loading tracks'):
|
||||
library = load_library(self._json_file)
|
||||
self._tracks = dict((t.uri, t) for t in library.get('tracks', []))
|
||||
with DebugTimer('Building browse cache'):
|
||||
with timer.time_logger('Building browse cache'):
|
||||
self._browse_cache = _BrowseCache(sorted(self._tracks.keys()))
|
||||
return len(self._tracks)
|
||||
|
||||
@ -155,13 +140,47 @@ class JsonLibrary(local.Library):
|
||||
except KeyError:
|
||||
return []
|
||||
|
||||
def get_distinct(self, field, query=None):
|
||||
if field == 'artist':
|
||||
def distinct(track):
|
||||
return {a.name for a in track.artists}
|
||||
elif field == 'albumartist':
|
||||
def distinct(track):
|
||||
album = track.album or models.Album()
|
||||
return {a.name for a in album.artists}
|
||||
elif field == 'album':
|
||||
def distinct(track):
|
||||
album = track.album or models.Album()
|
||||
return {album.name}
|
||||
elif field == 'composer':
|
||||
def distinct(track):
|
||||
return {a.name for a in track.composers}
|
||||
elif field == 'performer':
|
||||
def distinct(track):
|
||||
return {a.name for a in track.performers}
|
||||
elif field == 'date':
|
||||
def distinct(track):
|
||||
return {track.date}
|
||||
elif field == 'genre':
|
||||
def distinct(track):
|
||||
return {track.genre}
|
||||
else:
|
||||
return set()
|
||||
|
||||
distinct_result = set()
|
||||
search_result = search.search(self._tracks.values(), query, limit=None)
|
||||
for track in search_result.tracks:
|
||||
distinct_result.update(distinct(track))
|
||||
return distinct_result
|
||||
|
||||
def search(self, query=None, limit=100, offset=0, uris=None, exact=False):
|
||||
tracks = self._tracks.values()
|
||||
# TODO: pass limit and offset into search helpers
|
||||
if exact:
|
||||
return search.find_exact(tracks, query=query, uris=uris)
|
||||
return search.find_exact(
|
||||
tracks, query=query, limit=limit, offset=offset, uris=uris)
|
||||
else:
|
||||
return search.search(tracks, query=query, uris=uris)
|
||||
return search.search(
|
||||
tracks, query=query, limit=limit, offset=offset, uris=uris)
|
||||
|
||||
def begin(self):
|
||||
return compat.itervalues(self._tracks)
|
||||
|
||||
@ -8,6 +8,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LocalLibraryProvider(backend.LibraryProvider):
|
||||
|
||||
"""Proxy library that delegates work to our active local library."""
|
||||
|
||||
root_directory = models.Ref.directory(
|
||||
@ -23,6 +24,16 @@ class LocalLibraryProvider(backend.LibraryProvider):
|
||||
return []
|
||||
return self._library.browse(uri)
|
||||
|
||||
def get_distinct(self, field, query=None):
|
||||
if not self._library:
|
||||
return set()
|
||||
return self._library.get_distinct(field, query)
|
||||
|
||||
def get_images(self, uris):
|
||||
if not self._library:
|
||||
return {}
|
||||
return self._library.get_images(uris)
|
||||
|
||||
def refresh(self, uri=None):
|
||||
if not self._library:
|
||||
return 0
|
||||
@ -41,12 +52,7 @@ class LocalLibraryProvider(backend.LibraryProvider):
|
||||
tracks = [tracks]
|
||||
return tracks
|
||||
|
||||
def find_exact(self, query=None, uris=None):
|
||||
def search(self, query=None, uris=None, exact=False):
|
||||
if not self._library:
|
||||
return None
|
||||
return self._library.search(query=query, uris=uris, exact=True)
|
||||
|
||||
def search(self, query=None, uris=None):
|
||||
if not self._library:
|
||||
return None
|
||||
return self._library.search(query=query, uris=uris, exact=False)
|
||||
return self._library.search(query=query, uris=uris, exact=exact)
|
||||
|
||||
@ -1,16 +1,11 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
from mopidy import backend
|
||||
from mopidy.local import translator
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LocalPlaybackProvider(backend.PlaybackProvider):
|
||||
def change_track(self, track):
|
||||
track = track.copy(uri=translator.local_track_uri_to_file_uri(
|
||||
track.uri, self.backend.config['local']['media_dir']))
|
||||
return super(LocalPlaybackProvider, self).change_track(track)
|
||||
|
||||
def translate_uri(self, uri):
|
||||
return translator.local_track_uri_to_file_uri(
|
||||
uri, self.backend.config['local']['media_dir'])
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
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``
|
||||
|
||||
if query is None:
|
||||
@ -12,8 +23,6 @@ def find_exact(tracks, query=None, uris=None):
|
||||
_validate_query(query)
|
||||
|
||||
for (field, values) in query.items():
|
||||
if not hasattr(values, '__iter__'):
|
||||
values = [values]
|
||||
# FIXME this is bound to be slow for large libraries
|
||||
for value in values:
|
||||
if field == 'track_no':
|
||||
@ -21,27 +30,42 @@ def find_exact(tracks, query=None, uris=None):
|
||||
else:
|
||||
q = value.strip()
|
||||
|
||||
uri_filter = lambda t: q == t.uri
|
||||
track_name_filter = lambda t: q == t.name
|
||||
album_filter = lambda t: q == getattr(
|
||||
getattr(t, 'album', None), 'name', None)
|
||||
artist_filter = lambda t: filter(
|
||||
lambda a: q == a.name, t.artists)
|
||||
albumartist_filter = lambda t: any([
|
||||
q == a.name
|
||||
for a in getattr(t.album, 'artists', [])])
|
||||
composer_filter = lambda t: any([
|
||||
q == a.name
|
||||
for a in getattr(t, 'composers', [])])
|
||||
performer_filter = lambda t: any([
|
||||
q == a.name
|
||||
for a in getattr(t, 'performers', [])])
|
||||
track_no_filter = lambda t: q == t.track_no
|
||||
genre_filter = lambda t: t.genre and q == t.genre
|
||||
date_filter = lambda t: q == t.date
|
||||
comment_filter = lambda t: q == t.comment
|
||||
any_filter = lambda t: (
|
||||
uri_filter(t) or
|
||||
def uri_filter(t):
|
||||
return q == t.uri
|
||||
|
||||
def track_name_filter(t):
|
||||
return q == t.name
|
||||
|
||||
def album_filter(t):
|
||||
return q == getattr(getattr(t, 'album', None), 'name', None)
|
||||
|
||||
def artist_filter(t):
|
||||
return filter(lambda a: q == a.name, t.artists)
|
||||
|
||||
def albumartist_filter(t):
|
||||
return any([
|
||||
q == a.name for a in getattr(t.album, 'artists', [])])
|
||||
|
||||
def composer_filter(t):
|
||||
return any([q == a.name for a in getattr(t, 'composers', [])])
|
||||
|
||||
def performer_filter(t):
|
||||
return any([q == a.name for a in getattr(t, 'performers', [])])
|
||||
|
||||
def track_no_filter(t):
|
||||
return q == t.track_no
|
||||
|
||||
def genre_filter(t):
|
||||
return (t.genre and q == t.genre)
|
||||
|
||||
def date_filter(t):
|
||||
return q == t.date
|
||||
|
||||
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
|
||||
@ -80,11 +104,26 @@ def find_exact(tracks, query=None, uris=None):
|
||||
else:
|
||||
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>
|
||||
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``
|
||||
|
||||
if query is None:
|
||||
@ -93,8 +132,6 @@ def search(tracks, query=None, uris=None):
|
||||
_validate_query(query)
|
||||
|
||||
for (field, values) in query.items():
|
||||
if not hasattr(values, '__iter__'):
|
||||
values = [values]
|
||||
# FIXME this is bound to be slow for large libraries
|
||||
for value in values:
|
||||
if field == 'track_no':
|
||||
@ -102,28 +139,46 @@ def search(tracks, query=None, uris=None):
|
||||
else:
|
||||
q = value.strip().lower()
|
||||
|
||||
uri_filter = lambda t: bool(t.uri and q in t.uri.lower())
|
||||
track_name_filter = lambda t: bool(t.name and q in t.name.lower())
|
||||
album_filter = lambda t: bool(
|
||||
t.album and t.album.name and q in t.album.name.lower())
|
||||
artist_filter = lambda t: bool(filter(
|
||||
def uri_filter(t):
|
||||
return bool(t.uri and q in t.uri.lower())
|
||||
|
||||
def track_name_filter(t):
|
||||
return bool(t.name and q in t.name.lower())
|
||||
|
||||
def album_filter(t):
|
||||
return bool(t.album and t.album.name and
|
||||
q in t.album.name.lower())
|
||||
|
||||
def artist_filter(t):
|
||||
return bool(filter(
|
||||
lambda a: bool(a.name and q in a.name.lower()), t.artists))
|
||||
albumartist_filter = lambda t: any([
|
||||
a.name and q in a.name.lower()
|
||||
|
||||
def albumartist_filter(t):
|
||||
return any([a.name and q in a.name.lower()
|
||||
for a in getattr(t.album, 'artists', [])])
|
||||
composer_filter = lambda t: any([
|
||||
a.name and q in a.name.lower()
|
||||
|
||||
def composer_filter(t):
|
||||
return any([a.name and q in a.name.lower()
|
||||
for a in getattr(t, 'composers', [])])
|
||||
performer_filter = lambda t: any([
|
||||
a.name and q in a.name.lower()
|
||||
|
||||
def performer_filter(t):
|
||||
return any([a.name and q in a.name.lower()
|
||||
for a in getattr(t, 'performers', [])])
|
||||
track_no_filter = lambda t: q == t.track_no
|
||||
genre_filter = lambda t: bool(t.genre and q in t.genre.lower())
|
||||
date_filter = lambda t: bool(t.date and t.date.startswith(q))
|
||||
comment_filter = lambda t: bool(
|
||||
t.comment and q in t.comment.lower())
|
||||
any_filter = lambda t: (
|
||||
uri_filter(t) or
|
||||
|
||||
def track_no_filter(t):
|
||||
return q == t.track_no
|
||||
|
||||
def genre_filter(t):
|
||||
return bool(t.genre and q in t.genre.lower())
|
||||
|
||||
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
|
||||
@ -161,6 +216,11 @@ def search(tracks, query=None, uris=None):
|
||||
tracks = filter(any_filter, tracks)
|
||||
else:
|
||||
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>
|
||||
return SearchResult(uri='local:search', tracks=tracks)
|
||||
|
||||
|
||||
@ -20,11 +20,3 @@ def check_dirs_and_files(config):
|
||||
logger.warning(
|
||||
'Could not create local data dir: %s',
|
||||
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 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+),(.*)')
|
||||
COVERART_BASE = 'http://coverartarchive.org/release/%s/front'
|
||||
|
||||
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):
|
||||
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):
|
||||
"""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):
|
||||
relpath = relpath.encode('utf-8')
|
||||
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):
|
||||
relpath = relpath.encode('utf-8')
|
||||
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):
|
||||
|
||||
"""
|
||||
Audio mixer API
|
||||
|
||||
@ -111,6 +112,7 @@ class Mixer(object):
|
||||
|
||||
|
||||
class MixerListener(listener.Listener):
|
||||
|
||||
"""
|
||||
Marker interface for recipients of events sent by the mixer actor.
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import json
|
||||
|
||||
|
||||
class ImmutableObject(object):
|
||||
|
||||
"""
|
||||
Superclass for immutable objects whose fields can only be modified via the
|
||||
constructor.
|
||||
@ -102,6 +103,7 @@ class ImmutableObject(object):
|
||||
|
||||
|
||||
class ModelJSONEncoder(json.JSONEncoder):
|
||||
|
||||
"""
|
||||
Automatically serialize Mopidy models to JSON.
|
||||
|
||||
@ -112,6 +114,7 @@ class ModelJSONEncoder(json.JSONEncoder):
|
||||
'{"a_track": {"__model__": "Track", "name": "name"}}'
|
||||
|
||||
"""
|
||||
|
||||
def default(self, obj):
|
||||
if isinstance(obj, ImmutableObject):
|
||||
return obj.serialize()
|
||||
@ -143,6 +146,7 @@ def model_json_decoder(dct):
|
||||
|
||||
|
||||
class Ref(ImmutableObject):
|
||||
|
||||
"""
|
||||
Model to represent URI references with a human friendly name and type
|
||||
attached. This is intended for use a lightweight object "free" of metadata
|
||||
@ -153,7 +157,7 @@ class Ref(ImmutableObject):
|
||||
:param name: object name
|
||||
:type name: string
|
||||
:param type: object type
|
||||
:type name: string
|
||||
:type type: string
|
||||
"""
|
||||
|
||||
#: The object URI. Read-only.
|
||||
@ -212,7 +216,26 @@ class Ref(ImmutableObject):
|
||||
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):
|
||||
|
||||
"""
|
||||
:param uri: artist URI
|
||||
:type uri: string
|
||||
@ -233,6 +256,7 @@ class Artist(ImmutableObject):
|
||||
|
||||
|
||||
class Album(ImmutableObject):
|
||||
|
||||
"""
|
||||
:param uri: album URI
|
||||
:type uri: string
|
||||
@ -286,6 +310,7 @@ class Album(ImmutableObject):
|
||||
|
||||
|
||||
class Track(ImmutableObject):
|
||||
|
||||
"""
|
||||
:param uri: track URI
|
||||
:type uri: string
|
||||
@ -308,7 +333,7 @@ class Track(ImmutableObject):
|
||||
:param date: track release date (YYYY or YYYY-MM-DD)
|
||||
:type date: string
|
||||
: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
|
||||
:type bitrate: integer
|
||||
:param comment: track comment
|
||||
@ -361,13 +386,16 @@ class Track(ImmutableObject):
|
||||
#: The MusicBrainz ID of the track. Read-only.
|
||||
musicbrainz_id = None
|
||||
|
||||
#: Integer representing when the track was last modified, exact meaning
|
||||
#: depends on source of track. For local files this is the mtime, for other
|
||||
#: backends it could be a timestamp or simply a version counter.
|
||||
#: Integer representing when the track was last modified. Exact meaning
|
||||
#: depends on source of track. For local files this is the modification
|
||||
#: time in milliseconds since Unix epoch. For other backends it could be an
|
||||
#: equivalent timestamp or simply a version counter.
|
||||
last_modified = None
|
||||
|
||||
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__['composers'] = get('composers')
|
||||
self.__dict__['performers'] = get('performers')
|
||||
@ -375,6 +403,7 @@ class Track(ImmutableObject):
|
||||
|
||||
|
||||
class TlTrack(ImmutableObject):
|
||||
|
||||
"""
|
||||
A tracklist track. Wraps a regular track and it's tracklist ID.
|
||||
|
||||
@ -413,6 +442,7 @@ class TlTrack(ImmutableObject):
|
||||
|
||||
|
||||
class Playlist(ImmutableObject):
|
||||
|
||||
"""
|
||||
:param uri: playlist URI
|
||||
:type uri: string
|
||||
@ -453,6 +483,7 @@ class Playlist(ImmutableObject):
|
||||
|
||||
|
||||
class SearchResult(ImmutableObject):
|
||||
|
||||
"""
|
||||
:param uri: search result URI
|
||||
:type uri: string
|
||||
|
||||
@ -24,6 +24,7 @@ class Extension(ext.Extension):
|
||||
schema['max_connections'] = config.Integer(minimum=1)
|
||||
schema['connection_timeout'] = config.Integer(minimum=1)
|
||||
schema['zeroconf'] = config.String(optional=True)
|
||||
schema['command_blacklist'] = config.List(optional=True)
|
||||
return schema
|
||||
|
||||
def validate_environment(self):
|
||||
|
||||
@ -6,18 +6,20 @@ import pykka
|
||||
|
||||
from mopidy import exceptions, zeroconf
|
||||
from mopidy.core import CoreListener
|
||||
from mopidy.mpd import session
|
||||
from mopidy.mpd import session, uri_mapper
|
||||
from mopidy.utils import encoding, network, process
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MpdFrontend(pykka.ThreadingActor, CoreListener):
|
||||
|
||||
def __init__(self, config, core):
|
||||
super(MpdFrontend, self).__init__()
|
||||
|
||||
self.hostname = network.format_hostname(config['mpd']['hostname'])
|
||||
self.port = config['mpd']['port']
|
||||
self.uri_map = uri_mapper.MpdUriMapper(core)
|
||||
|
||||
self.zeroconf_name = config['mpd']['zeroconf']
|
||||
self.zeroconf_service = None
|
||||
@ -29,6 +31,7 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
|
||||
protocol_kwargs={
|
||||
'config': config,
|
||||
'core': core,
|
||||
'uri_map': self.uri_map,
|
||||
},
|
||||
max_connections=config['mpd']['max_connections'],
|
||||
timeout=config['mpd']['connection_timeout'])
|
||||
@ -71,3 +74,6 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
|
||||
|
||||
def mute_changed(self, mute):
|
||||
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):
|
||||
|
||||
"""
|
||||
The MPD session feeds the MPD dispatcher with requests. The dispatcher
|
||||
finds the correct handler, processes the request and sends the response
|
||||
@ -21,7 +22,7 @@ class MpdDispatcher(object):
|
||||
|
||||
_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.authenticated = False
|
||||
self.command_list_receiving = False
|
||||
@ -29,7 +30,7 @@ class MpdDispatcher(object):
|
||||
self.command_list = []
|
||||
self.command_list_index = None
|
||||
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):
|
||||
"""Dispatch incoming requests to the correct handler."""
|
||||
@ -163,6 +164,11 @@ class MpdDispatcher(object):
|
||||
|
||||
def _call_handler(self, 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:
|
||||
return protocol.commands.call(tokens, context=self.context)
|
||||
except exceptions.MpdAckError as exc:
|
||||
@ -204,6 +210,7 @@ class MpdDispatcher(object):
|
||||
|
||||
|
||||
class MpdContext(object):
|
||||
|
||||
"""
|
||||
This object is passed as the first argument to all MPD command handlers to
|
||||
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.
|
||||
subscriptions = None
|
||||
|
||||
_invalid_browse_chars = re.compile(r'[\n\r]')
|
||||
_invalid_playlist_chars = re.compile(r'[/]')
|
||||
_uri_map = None
|
||||
|
||||
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.session = session
|
||||
if config is not None:
|
||||
@ -238,58 +245,19 @@ class MpdContext(object):
|
||||
self.core = core
|
||||
self.events = set()
|
||||
self.subscriptions = set()
|
||||
self._uri_from_name = {}
|
||||
self._name_from_uri = {}
|
||||
self.refresh_playlists_mapping()
|
||||
self._uri_map = uri_map
|
||||
|
||||
def create_unique_name(self, name, uri):
|
||||
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):
|
||||
def lookup_playlist_uri_from_name(self, name):
|
||||
"""
|
||||
Helper function to retrieve a playlist from its unique MPD name.
|
||||
"""
|
||||
if not self._uri_from_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()
|
||||
return self._uri_map.playlist_uri_from_name(name)
|
||||
|
||||
def lookup_playlist_name_from_uri(self, uri):
|
||||
"""
|
||||
Helper function to retrieve the unique MPD playlist name from its uri.
|
||||
"""
|
||||
if uri not in self._name_from_uri:
|
||||
self.refresh_playlists_mapping()
|
||||
return self._name_from_uri[uri]
|
||||
return self._uri_map.playlist_name_from_uri(uri)
|
||||
|
||||
def browse(self, path, recursive=True, lookup=True):
|
||||
"""
|
||||
@ -301,10 +269,10 @@ class MpdContext(object):
|
||||
given path.
|
||||
|
||||
If ``lookup`` is true and the ``path`` is to a track, the returned
|
||||
``data`` is a future which will contain the
|
||||
:class:`mopidy.models.Track` model. If ``lookup`` is false and the
|
||||
``path`` is to a track, the returned ``data`` will be a
|
||||
:class:`mopidy.models.Ref` for the track.
|
||||
``data`` is a future which will contain the results from looking up
|
||||
the URI with :meth:`mopidy.core.LibraryController.lookup` If ``lookup``
|
||||
is false and the ``path`` is to a track, the returned ``data`` will be
|
||||
a :class:`mopidy.models.Ref` for the track.
|
||||
|
||||
For all entries that are not tracks, the returned ``data`` will be
|
||||
:class:`None`.
|
||||
@ -313,8 +281,8 @@ class MpdContext(object):
|
||||
path_parts = re.findall(r'[^/]+', path or '')
|
||||
root_path = '/'.join([''] + path_parts)
|
||||
|
||||
if root_path not in self._uri_from_name:
|
||||
uri = None
|
||||
uri = self._uri_map.uri_from_name(root_path)
|
||||
if uri is None:
|
||||
for part in path_parts:
|
||||
for ref in self.core.library.browse(uri).get():
|
||||
if ref.type != ref.TRACK and ref.name == part:
|
||||
@ -322,10 +290,7 @@ class MpdContext(object):
|
||||
break
|
||||
else:
|
||||
raise exceptions.MpdNoExistError('Not found')
|
||||
root_path = self.insert_name_uri_mapping(root_path, uri)
|
||||
|
||||
else:
|
||||
uri = self._uri_from_name[root_path]
|
||||
root_path = self._uri_map.insert(root_path, uri)
|
||||
|
||||
if recursive:
|
||||
yield (root_path, None)
|
||||
@ -335,11 +300,12 @@ class MpdContext(object):
|
||||
base_path, future = path_and_futures.pop()
|
||||
for ref in future.get():
|
||||
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 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:
|
||||
yield (path, ref)
|
||||
else:
|
||||
|
||||
@ -4,6 +4,7 @@ from mopidy.exceptions import MopidyException
|
||||
|
||||
|
||||
class MpdAckError(MopidyException):
|
||||
|
||||
"""See fields on this class for available MPD error codes"""
|
||||
|
||||
ACK_ERROR_NOT_LIST = 1
|
||||
@ -59,6 +60,7 @@ class MpdUnknownError(MpdAckError):
|
||||
|
||||
|
||||
class MpdUnknownCommand(MpdUnknownError):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(MpdUnknownCommand, self).__init__(*args, **kwargs)
|
||||
assert self.command is not None, 'command must be given explicitly'
|
||||
@ -67,6 +69,7 @@ class MpdUnknownCommand(MpdUnknownError):
|
||||
|
||||
|
||||
class MpdNoCommand(MpdUnknownCommand):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs['command'] = ''
|
||||
super(MpdNoCommand, self).__init__(*args, **kwargs)
|
||||
@ -87,3 +90,13 @@ class MpdNotImplemented(MpdAckError):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(MpdNotImplemented, self).__init__(*args, **kwargs)
|
||||
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
|
||||
connection_timeout = 60
|
||||
zeroconf = Mopidy MPD server on $hostname
|
||||
command_blacklist = listall,listallinfo
|
||||
|
||||
@ -83,6 +83,7 @@ def RANGE(value): # noqa: N802
|
||||
|
||||
|
||||
class Commands(object):
|
||||
|
||||
"""Collection of MPD commands to expose to users.
|
||||
|
||||
Normally used through the global instance which command handlers have been
|
||||
|
||||
@ -13,7 +13,9 @@ def disableoutput(context, outputid):
|
||||
Turns an output off.
|
||||
"""
|
||||
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:
|
||||
raise exceptions.MpdNoExistError('No such audio output')
|
||||
|
||||
@ -28,13 +30,14 @@ def enableoutput(context, outputid):
|
||||
Turns an output on.
|
||||
"""
|
||||
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:
|
||||
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):
|
||||
"""
|
||||
*musicpd.org, audio output section:*
|
||||
@ -43,7 +46,13 @@ def toggleoutput(context, outputid):
|
||||
|
||||
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')
|
||||
@ -55,7 +64,7 @@ def outputs(context):
|
||||
|
||||
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 [
|
||||
('outputid', 0),
|
||||
('outputname', 'Mute'),
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import warnings
|
||||
|
||||
from mopidy.mpd import exceptions, protocol, translator
|
||||
from mopidy.utils import deprecation
|
||||
|
||||
|
||||
@protocol.commands.add('add')
|
||||
@ -22,21 +21,21 @@ def add(context, uri):
|
||||
if not uri.strip('/'):
|
||||
return
|
||||
|
||||
if context.core.tracklist.add(uri=uri).get():
|
||||
if context.core.tracklist.add(uris=[uri]).get():
|
||||
return
|
||||
|
||||
try:
|
||||
tracks = []
|
||||
for path, lookup_future in context.browse(uri):
|
||||
if lookup_future:
|
||||
tracks.extend(lookup_future.get())
|
||||
uris = []
|
||||
for path, ref in context.browse(uri, lookup=False):
|
||||
if ref:
|
||||
uris.append(ref.uri)
|
||||
except exceptions.MpdNoExistError as e:
|
||||
e.message = 'directory or file not found'
|
||||
raise
|
||||
|
||||
if not tracks:
|
||||
if not uris:
|
||||
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)
|
||||
@ -62,7 +61,8 @@ def addid(context, uri, songpos=None):
|
||||
raise exceptions.MpdNoExistError('No such song')
|
||||
if songpos is not None and songpos > context.core.tracklist.length.get():
|
||||
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:
|
||||
raise exceptions.MpdNoExistError('No such song')
|
||||
return ('Id', tl_tracks[0].tlid)
|
||||
@ -162,8 +162,7 @@ def playlist(context):
|
||||
|
||||
Do not use this, instead use ``playlistinfo``.
|
||||
"""
|
||||
warnings.warn(
|
||||
'Do not use this, instead use playlistinfo', DeprecationWarning)
|
||||
deprecation.warn('mpd.protocol.current_playlist.playlist')
|
||||
return playlistinfo(context)
|
||||
|
||||
|
||||
@ -275,9 +274,21 @@ def plchanges(context, version):
|
||||
- Calls ``plchanges "-1"`` two times per second to get the entire playlist.
|
||||
"""
|
||||
# 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(
|
||||
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)
|
||||
@ -337,8 +348,12 @@ def swap(context, songpos1, songpos2):
|
||||
tracks.insert(songpos1, song2)
|
||||
del tracks[songpos2]
|
||||
tracks.insert(songpos2, song1)
|
||||
|
||||
# TODO: do we need a tracklist.replace()
|
||||
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)
|
||||
|
||||
@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import functools
|
||||
import itertools
|
||||
import warnings
|
||||
|
||||
from mopidy.models import Track
|
||||
from mopidy.mpd import exceptions, protocol, translator
|
||||
@ -30,6 +31,15 @@ _LIST_MAPPING = {
|
||||
'genre': 'genre',
|
||||
'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):
|
||||
query = {}
|
||||
@ -91,7 +101,7 @@ def count(context, *args):
|
||||
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
|
||||
except ValueError:
|
||||
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)
|
||||
return [
|
||||
('songs', len(result_tracks)),
|
||||
@ -132,7 +142,7 @@ def find(context, *args):
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
results = context.core.library.find_exact(**query).get()
|
||||
results = context.core.library.search(query=query, exact=True).get()
|
||||
result_tracks = []
|
||||
if ('artist' 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)
|
||||
except ValueError:
|
||||
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')
|
||||
@ -246,109 +262,30 @@ def list_(context, *args):
|
||||
- does not add quotes around the field argument.
|
||||
- capitalizes the field argument.
|
||||
"""
|
||||
parameters = list(args)
|
||||
if not parameters:
|
||||
params = list(args)
|
||||
if not params:
|
||||
raise exceptions.MpdArgError('incorrect arguments')
|
||||
field = parameters.pop(0).lower()
|
||||
field = params.pop(0).lower()
|
||||
|
||||
if field not in _LIST_MAPPING:
|
||||
raise exceptions.MpdArgError('incorrect arguments')
|
||||
|
||||
if len(parameters) == 1:
|
||||
if len(params) == 1:
|
||||
if field != 'album':
|
||||
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(parameters, _LIST_MAPPING)
|
||||
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
|
||||
|
||||
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
|
||||
name = _LIST_NAME_MAPPING[field]
|
||||
result = context.core.library.get_distinct(field, query)
|
||||
return [(name, value) for value in result.get()]
|
||||
|
||||
|
||||
@protocol.commands.add('listall')
|
||||
@ -359,6 +296,13 @@ def listall(context, uri=None):
|
||||
``listall [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 = []
|
||||
for path, track_ref in context.browse(uri, lookup=False):
|
||||
@ -381,13 +325,21 @@ def listallinfo(context, uri=None):
|
||||
|
||||
Same as ``listall``, except it also returns metadata info in the
|
||||
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 = []
|
||||
for path, lookup_future in context.browse(uri):
|
||||
if not lookup_future:
|
||||
result.append(('directory', path))
|
||||
else:
|
||||
for track in lookup_future.get():
|
||||
for tracks in lookup_future.get().values():
|
||||
for track in tracks:
|
||||
result.extend(translator.track_to_mpd_format(track))
|
||||
return result
|
||||
|
||||
@ -414,7 +366,7 @@ def lsinfo(context, uri=None):
|
||||
if not lookup_future:
|
||||
result.append(('directory', path.lstrip('/')))
|
||||
else:
|
||||
tracks = lookup_future.get()
|
||||
for tracks in lookup_future.get().values():
|
||||
if tracks:
|
||||
result.extend(translator.track_to_mpd_format(tracks[0]))
|
||||
|
||||
@ -468,7 +420,7 @@ def search(context, *args):
|
||||
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
|
||||
except ValueError:
|
||||
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)]
|
||||
albums = [_album_as_track(a) for a in _get_albums(results)]
|
||||
tracks = _get_tracks(results)
|
||||
@ -492,8 +444,14 @@ def searchadd(context, *args):
|
||||
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
|
||||
except ValueError:
|
||||
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')
|
||||
@ -519,9 +477,10 @@ def searchaddpl(context, *args):
|
||||
query = _query_from_mpd_search_parameters(parameters, _SEARCH_MAPPING)
|
||||
except ValueError:
|
||||
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:
|
||||
playlist = context.core.playlists.create(playlist_name).get()
|
||||
tracks = list(playlist.tracks) + _get_tracks(results)
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import warnings
|
||||
|
||||
from mopidy.core import PlaybackState
|
||||
from mopidy.mpd import exceptions, protocol
|
||||
from mopidy.utils import deprecation
|
||||
|
||||
|
||||
@protocol.commands.add('consume', state=protocol.BOOL)
|
||||
@ -32,8 +31,7 @@ def crossfade(context, seconds):
|
||||
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):
|
||||
"""
|
||||
*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
|
||||
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):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
@ -61,7 +58,7 @@ def mixrampdelay(context, seconds):
|
||||
value of "nan" disables MixRamp overlapping and falls back to
|
||||
crossfading.
|
||||
"""
|
||||
pass
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
|
||||
@protocol.commands.add('next')
|
||||
@ -136,9 +133,7 @@ def pause(context, state=None):
|
||||
- Calls ``pause`` without any arguments to toogle pause.
|
||||
"""
|
||||
if state is None:
|
||||
warnings.warn(
|
||||
'The use of pause command w/o the PAUSE argument is deprecated.',
|
||||
DeprecationWarning)
|
||||
deprecation.warn('mpd.protocol.playback.pause:state_arg')
|
||||
|
||||
if (context.core.playback.state.get() == PlaybackState.PLAYING):
|
||||
context.core.playback.pause()
|
||||
@ -397,7 +392,10 @@ def setvol(context, volume):
|
||||
- issues ``setvol 50`` without quotes around the argument.
|
||||
"""
|
||||
# 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)
|
||||
|
||||
@ -35,9 +35,11 @@ def currentsong(context):
|
||||
identified in status).
|
||||
"""
|
||||
tl_track = context.core.playback.current_tl_track.get()
|
||||
stream_title = context.core.playback.get_stream_title().get()
|
||||
if tl_track is not None:
|
||||
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)
|
||||
@ -173,7 +175,7 @@ def status(context):
|
||||
futures = {
|
||||
'tracklist.length': context.core.tracklist.length,
|
||||
'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.random': context.core.tracklist.random,
|
||||
'tracklist.repeat': context.core.tracklist.repeat,
|
||||
@ -287,7 +289,7 @@ def _status_time_total(futures):
|
||||
|
||||
|
||||
def _status_volume(futures):
|
||||
volume = futures['playback.volume'].get()
|
||||
volume = futures['mixer.volume'].get()
|
||||
if volume is not None:
|
||||
return volume
|
||||
else:
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
|
||||
import datetime
|
||||
import warnings
|
||||
|
||||
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/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:
|
||||
raise exceptions.MpdNoExistError('No such playlist')
|
||||
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,
|
||||
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:
|
||||
raise exceptions.MpdNoExistError('No such 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.
|
||||
"""
|
||||
result = []
|
||||
for playlist in context.core.playlists.playlists.get():
|
||||
for playlist in context.core.playlists.get_playlists().get():
|
||||
if not playlist.name:
|
||||
continue
|
||||
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,
|
||||
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:
|
||||
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')
|
||||
|
||||
@ -9,6 +9,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MpdSession(network.LineProtocol):
|
||||
|
||||
"""
|
||||
The MPD client session. Keeps track of a single client session. Any
|
||||
requests from the client is passed on to the MPD request dispatcher.
|
||||
@ -18,10 +19,10 @@ class MpdSession(network.LineProtocol):
|
||||
encoding = protocol.ENCODING
|
||||
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)
|
||||
self.dispatcher = dispatcher.MpdDispatcher(
|
||||
session=self, config=config, core=core)
|
||||
session=self, config=config, core=core, uri_map=uri_map)
|
||||
|
||||
def on_start(self):
|
||||
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)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@ -23,24 +23,28 @@ def track_to_mpd_format(track, position=None):
|
||||
:type track: :class:`mopidy.models.Track` or :class:`mopidy.models.TlTrack`
|
||||
:param position: track's position in playlist
|
||||
:type position: integer
|
||||
:param key: if we should set key
|
||||
:type key: boolean
|
||||
:param mtime: if we should set mtime
|
||||
:type mtime: boolean
|
||||
:param stream_title: the current streams title
|
||||
:type position: string
|
||||
:rtype: list of two-tuples
|
||||
"""
|
||||
if isinstance(track, TlTrack):
|
||||
(tlid, track) = track
|
||||
else:
|
||||
(tlid, track) = (None, track)
|
||||
|
||||
result = [
|
||||
('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),
|
||||
('Artist', artists_to_mpd_format(track.artists)),
|
||||
('Title', track.name or ''),
|
||||
('Album', track.album and track.album.name or ''),
|
||||
]
|
||||
|
||||
if stream_title:
|
||||
result.append(('Name', stream_title))
|
||||
|
||||
if 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