Merge branch 'develop' into feature/implement-gapless
Conflicts: mopidy/commands.py mopidy/core/playback.py tests/core/test_playback.py tests/local/test_playback.py
This commit is contained in:
commit
3fe9f7b3a7
3
.mailmap
3
.mailmap
@ -23,4 +23,5 @@ 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>
|
||||
Ronald Zielaznicki <zielaznickizm@g.cofc.edu> <zielaznickiz@g.cofc.edu>
|
||||
Tom Roth <rawdlite@googlemail.com>
|
||||
|
||||
8
AUTHORS
8
AUTHORS
@ -52,4 +52,10 @@
|
||||
- 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>
|
||||
- Ronald Zielaznicki <zielaznickizm@g.cofc.edu>
|
||||
- Wojciech Wnętrzak <w.wnetrzak@gmail.com>
|
||||
- Camilo Nova <camilo.nova@gmail.com>
|
||||
- Dražen Lučanin <kermit666@gmail.com>
|
||||
- Naglis Jonaitis <njonaitis@gmail.com>
|
||||
- Tom Roth <rawdlite@googlemail.com>
|
||||
- Mark Greenwood <fatgerman@gmail.com>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
.. _concepts:
|
||||
|
||||
*************************
|
||||
Architecture and concepts
|
||||
*************************
|
||||
************
|
||||
Architecture
|
||||
************
|
||||
|
||||
The overall architecture of Mopidy is organized around multiple frontends and
|
||||
backends. The frontends use the core API. The core actor makes multiple backends
|
||||
@ -1,8 +1,8 @@
|
||||
.. _audio-api:
|
||||
|
||||
*********
|
||||
Audio API
|
||||
*********
|
||||
*********************************
|
||||
:mod:`mopidy.audio` --- Audio API
|
||||
*********************************
|
||||
|
||||
.. module:: mopidy.audio
|
||||
:synopsis: Thin wrapper around the parts of GStreamer we use
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
.. _backend-api:
|
||||
|
||||
***********
|
||||
Backend API
|
||||
***********
|
||||
*************************************
|
||||
:mod:`mopidy.backend` --- Backend API
|
||||
*************************************
|
||||
|
||||
.. module:: mopidy.backend
|
||||
:synopsis: The API implemented by backends
|
||||
@ -1,8 +1,8 @@
|
||||
.. _commands-api:
|
||||
|
||||
************
|
||||
Commands API
|
||||
************
|
||||
***************************************
|
||||
:mod:`mopidy.commands` --- Commands API
|
||||
***************************************
|
||||
|
||||
.. automodule:: mopidy.commands
|
||||
:synopsis: Commands API for Mopidy CLI.
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
.. _config-api:
|
||||
|
||||
**********
|
||||
Config API
|
||||
**********
|
||||
***********************************
|
||||
:mod:`mopidy.config` --- Config API
|
||||
***********************************
|
||||
|
||||
.. automodule:: mopidy.config
|
||||
:synopsis: Config API for config loading and validation
|
||||
|
||||
@ -1,79 +1,253 @@
|
||||
.. _core-api:
|
||||
|
||||
********
|
||||
Core API
|
||||
********
|
||||
*******************************
|
||||
:mod:`mopidy.core` --- Core API
|
||||
*******************************
|
||||
|
||||
.. module:: mopidy.core
|
||||
:synopsis: Core API for use by frontends
|
||||
|
||||
The core API is the interface that is used by frontends like
|
||||
:mod:`mopidy.http` and :mod:`mopidy.mpd`. The core layer is inbetween the
|
||||
frontends and the backends.
|
||||
:mod:`mopidy.http` and :mod:`mopidy.mpd`. The core layer is in between the
|
||||
frontends and the backends. Don't forget that you will be accessing core
|
||||
as a Pykka actor. If you are only interested in being notified about changes
|
||||
in core see :class:`~mopidy.core.CoreListener`.
|
||||
|
||||
.. versionchanged:: 1.1
|
||||
All core API calls are now type checked.
|
||||
|
||||
.. versionchanged:: 1.1
|
||||
All backend return values are now type checked.
|
||||
|
||||
.. autoclass:: mopidy.core.Core
|
||||
:members:
|
||||
|
||||
.. attribute:: tracklist
|
||||
|
||||
Playback controller
|
||||
===================
|
||||
Manages everything related to the list of tracks we will play.
|
||||
See :class:`~mopidy.core.TracklistController`.
|
||||
|
||||
Manages playback, with actions like play, pause, stop, next, previous,
|
||||
seek, and volume control.
|
||||
.. attribute:: playback
|
||||
|
||||
.. autoclass:: mopidy.core.PlaybackState
|
||||
:members:
|
||||
Manages playback state and the current playing track.
|
||||
See :class:`~mopidy.core.PlaybackController`.
|
||||
|
||||
.. autoclass:: mopidy.core.PlaybackController
|
||||
:members:
|
||||
.. attribute:: library
|
||||
|
||||
Manages the music library, e.g. searching and browsing for music.
|
||||
See :class:`~mopidy.core.LibraryController`.
|
||||
|
||||
.. attribute:: playlists
|
||||
|
||||
Manages stored playlists. See :class:`~mopidy.core.PlaylistsController`.
|
||||
|
||||
.. attribute:: mixer
|
||||
|
||||
Manages volume and muting. See :class:`~mopidy.core.MixerController`.
|
||||
|
||||
.. attribute:: history
|
||||
|
||||
Keeps record of what tracks have been played.
|
||||
See :class:`~mopidy.core.HistoryController`.
|
||||
|
||||
.. automethod:: get_uri_schemes
|
||||
|
||||
.. automethod:: get_version
|
||||
|
||||
|
||||
Tracklist controller
|
||||
====================
|
||||
|
||||
Manages everything related to the tracks we are currently playing.
|
||||
|
||||
.. autoclass:: mopidy.core.TracklistController
|
||||
:members:
|
||||
|
||||
Manipulating
|
||||
------------
|
||||
|
||||
.. automethod:: mopidy.core.TracklistController.add
|
||||
.. automethod:: mopidy.core.TracklistController.remove
|
||||
.. automethod:: mopidy.core.TracklistController.clear
|
||||
.. automethod:: mopidy.core.TracklistController.move
|
||||
.. automethod:: mopidy.core.TracklistController.shuffle
|
||||
|
||||
Current state
|
||||
-------------
|
||||
|
||||
.. automethod:: mopidy.core.TracklistController.get_tl_tracks
|
||||
.. automethod:: mopidy.core.TracklistController.index
|
||||
.. automethod:: mopidy.core.TracklistController.get_version
|
||||
|
||||
.. automethod:: mopidy.core.TracklistController.get_length
|
||||
.. automethod:: mopidy.core.TracklistController.get_tracks
|
||||
|
||||
.. automethod:: mopidy.core.TracklistController.slice
|
||||
.. automethod:: mopidy.core.TracklistController.filter
|
||||
|
||||
Future state
|
||||
------------
|
||||
|
||||
.. automethod:: mopidy.core.TracklistController.get_eot_tlid
|
||||
.. automethod:: mopidy.core.TracklistController.get_next_tlid
|
||||
.. automethod:: mopidy.core.TracklistController.get_previous_tlid
|
||||
|
||||
.. automethod:: mopidy.core.TracklistController.eot_track
|
||||
.. automethod:: mopidy.core.TracklistController.next_track
|
||||
.. automethod:: mopidy.core.TracklistController.previous_track
|
||||
|
||||
Options
|
||||
-------
|
||||
|
||||
.. automethod:: mopidy.core.TracklistController.get_consume
|
||||
.. automethod:: mopidy.core.TracklistController.set_consume
|
||||
.. automethod:: mopidy.core.TracklistController.get_random
|
||||
.. automethod:: mopidy.core.TracklistController.set_random
|
||||
.. automethod:: mopidy.core.TracklistController.get_repeat
|
||||
.. automethod:: mopidy.core.TracklistController.set_repeat
|
||||
.. automethod:: mopidy.core.TracklistController.get_single
|
||||
.. automethod:: mopidy.core.TracklistController.set_single
|
||||
|
||||
|
||||
History controller
|
||||
==================
|
||||
Playback controller
|
||||
===================
|
||||
|
||||
Keeps record of what tracks have been played.
|
||||
.. autoclass:: mopidy.core.PlaybackController
|
||||
|
||||
.. autoclass:: mopidy.core.HistoryController
|
||||
:members:
|
||||
Playback control
|
||||
----------------
|
||||
|
||||
.. automethod:: mopidy.core.PlaybackController.play
|
||||
.. automethod:: mopidy.core.PlaybackController.next
|
||||
.. automethod:: mopidy.core.PlaybackController.previous
|
||||
.. automethod:: mopidy.core.PlaybackController.stop
|
||||
.. automethod:: mopidy.core.PlaybackController.pause
|
||||
.. automethod:: mopidy.core.PlaybackController.resume
|
||||
.. automethod:: mopidy.core.PlaybackController.seek
|
||||
|
||||
Playlists controller
|
||||
====================
|
||||
Current track
|
||||
-------------
|
||||
|
||||
Manages persistence of playlists.
|
||||
.. automethod:: mopidy.core.PlaybackController.get_current_tl_track
|
||||
.. automethod:: mopidy.core.PlaybackController.get_current_track
|
||||
.. automethod:: mopidy.core.PlaybackController.get_stream_title
|
||||
.. automethod:: mopidy.core.PlaybackController.get_time_position
|
||||
|
||||
.. autoclass:: mopidy.core.PlaylistsController
|
||||
:members:
|
||||
Playback states
|
||||
---------------
|
||||
|
||||
.. automethod:: mopidy.core.PlaybackController.get_state
|
||||
.. automethod:: mopidy.core.PlaybackController.set_state
|
||||
|
||||
.. class:: mopidy.core.PlaybackState
|
||||
|
||||
.. attribute:: STOPPED
|
||||
:annotation: = 'stopped'
|
||||
.. attribute:: PLAYING
|
||||
:annotation: = 'playing'
|
||||
.. attribute:: PAUSED
|
||||
:annotation: = 'paused'
|
||||
|
||||
Library controller
|
||||
==================
|
||||
|
||||
Manages the music library, e.g. searching for tracks to be added to a playlist.
|
||||
.. class:: mopidy.core.LibraryController
|
||||
|
||||
.. autoclass:: mopidy.core.LibraryController
|
||||
:members:
|
||||
.. automethod:: mopidy.core.LibraryController.browse
|
||||
.. automethod:: mopidy.core.LibraryController.search
|
||||
.. automethod:: mopidy.core.LibraryController.lookup
|
||||
.. automethod:: mopidy.core.LibraryController.refresh
|
||||
.. automethod:: mopidy.core.LibraryController.get_images
|
||||
.. automethod:: mopidy.core.LibraryController.get_distinct
|
||||
|
||||
Playlists controller
|
||||
====================
|
||||
|
||||
.. class:: mopidy.core.PlaylistsController
|
||||
|
||||
Fetching
|
||||
--------
|
||||
|
||||
.. automethod:: mopidy.core.PlaylistsController.as_list
|
||||
.. automethod:: mopidy.core.PlaylistsController.get_items
|
||||
.. automethod:: mopidy.core.PlaylistsController.lookup
|
||||
.. automethod:: mopidy.core.PlaylistsController.refresh
|
||||
|
||||
Manipulating
|
||||
------------
|
||||
|
||||
.. automethod:: mopidy.core.PlaylistsController.create
|
||||
.. automethod:: mopidy.core.PlaylistsController.save
|
||||
.. automethod:: mopidy.core.PlaylistsController.delete
|
||||
|
||||
Mixer controller
|
||||
================
|
||||
|
||||
Manages volume and muting.
|
||||
.. class:: mopidy.core.MixerController
|
||||
|
||||
.. autoclass:: mopidy.core.MixerController
|
||||
:members:
|
||||
.. automethod:: mopidy.core.MixerController.get_mute
|
||||
.. automethod:: mopidy.core.MixerController.set_mute
|
||||
.. automethod:: mopidy.core.MixerController.get_volume
|
||||
.. automethod:: mopidy.core.MixerController.set_volume
|
||||
|
||||
Core listener
|
||||
=============
|
||||
History controller
|
||||
==================
|
||||
|
||||
.. class:: mopidy.core.HistoryController
|
||||
|
||||
.. automethod:: mopidy.core.HistoryController.get_history
|
||||
.. automethod:: mopidy.core.HistoryController.get_length
|
||||
|
||||
Core events
|
||||
===========
|
||||
|
||||
.. autoclass:: mopidy.core.CoreListener
|
||||
:members:
|
||||
|
||||
Deprecated API features
|
||||
=======================
|
||||
|
||||
.. warning::
|
||||
Though these features still work, they are slated to go away in the next
|
||||
major Mopidy release.
|
||||
|
||||
Core
|
||||
----
|
||||
|
||||
.. autoattribute:: mopidy.core.Core.version
|
||||
.. autoattribute:: mopidy.core.Core.uri_schemes
|
||||
|
||||
TracklistController
|
||||
-------------------
|
||||
|
||||
.. autoattribute:: mopidy.core.TracklistController.tl_tracks
|
||||
.. autoattribute:: mopidy.core.TracklistController.tracks
|
||||
.. autoattribute:: mopidy.core.TracklistController.version
|
||||
.. autoattribute:: mopidy.core.TracklistController.length
|
||||
|
||||
.. autoattribute:: mopidy.core.TracklistController.consume
|
||||
.. autoattribute:: mopidy.core.TracklistController.random
|
||||
.. autoattribute:: mopidy.core.TracklistController.repeat
|
||||
.. autoattribute:: mopidy.core.TracklistController.single
|
||||
|
||||
PlaylistsController
|
||||
-------------------
|
||||
|
||||
.. automethod:: mopidy.core.PlaybackController.get_mute
|
||||
.. automethod:: mopidy.core.PlaybackController.get_volume
|
||||
|
||||
.. autoattribute:: mopidy.core.PlaybackController.current_tl_track
|
||||
.. autoattribute:: mopidy.core.PlaybackController.current_track
|
||||
.. autoattribute:: mopidy.core.PlaybackController.state
|
||||
.. autoattribute:: mopidy.core.PlaybackController.time_position
|
||||
.. autoattribute:: mopidy.core.PlaybackController.mute
|
||||
.. autoattribute:: mopidy.core.PlaybackController.volume
|
||||
|
||||
LibraryController
|
||||
-----------------
|
||||
|
||||
.. automethod:: mopidy.core.LibraryController.find_exact
|
||||
|
||||
PlaybackController
|
||||
------------------
|
||||
|
||||
.. automethod:: mopidy.core.PlaylistsController.filter
|
||||
.. automethod:: mopidy.core.PlaylistsController.get_playlists
|
||||
|
||||
.. autoattribute:: mopidy.core.PlaylistsController.playlists
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
.. _ext-api:
|
||||
|
||||
*************
|
||||
Extension API
|
||||
*************
|
||||
**********************************
|
||||
:mod:`mopidy.ext` -- Extension API
|
||||
**********************************
|
||||
|
||||
If you want to learn how to make Mopidy extensions, read :ref:`extensiondev`.
|
||||
|
||||
|
||||
@ -25,6 +25,8 @@ For details on how to make a Mopidy extension, see the :ref:`extensiondev`
|
||||
guide.
|
||||
|
||||
|
||||
.. _static-web-client:
|
||||
|
||||
Static web client example
|
||||
=========================
|
||||
|
||||
|
||||
@ -4,9 +4,6 @@
|
||||
HTTP JSON-RPC API
|
||||
*****************
|
||||
|
||||
.. module:: mopidy.http
|
||||
:synopsis: The HTTP frontend APIs
|
||||
|
||||
The :ref:`ext-http` extension makes Mopidy's :ref:`core-api` available using
|
||||
JSON-RPC over HTTP using HTTP POST and WebSockets. We also provide a JavaScript
|
||||
wrapper, called :ref:`Mopidy.js <mopidy-js>`, around the JSON-RPC over
|
||||
@ -65,14 +62,9 @@ JSON-RPC 2.0 messages can be recognized by checking for the key named
|
||||
please refer to the `JSON-RPC 2.0 spec
|
||||
<http://www.jsonrpc.org/specification>`_.
|
||||
|
||||
All methods (not attributes) in the :ref:`core-api` is made available through
|
||||
JSON-RPC calls over the WebSocket. For example,
|
||||
:meth:`mopidy.core.PlaybackController.play` is available as the JSON-RPC method
|
||||
``core.playback.play``.
|
||||
|
||||
The core API's attributes is made available through setters and getters. For
|
||||
example, the attribute :attr:`mopidy.core.PlaybackController.current_track` is
|
||||
available as the JSON-RPC method ``core.playback.get_current_track``.
|
||||
All methods in the :ref:`core-api` is made available through JSON-RPC calls
|
||||
over the WebSocket. For example, :meth:`mopidy.core.PlaybackController.play` is
|
||||
available as the JSON-RPC method ``core.playback.play``.
|
||||
|
||||
Example JSON-RPC request::
|
||||
|
||||
|
||||
9
docs/api/httpclient.rst
Normal file
9
docs/api/httpclient.rst
Normal file
@ -0,0 +1,9 @@
|
||||
.. _httpclient-helper:
|
||||
|
||||
************************************************
|
||||
:mod:`mopidy.httpclient` --- HTTP Client helpers
|
||||
************************************************
|
||||
|
||||
.. automodule:: mopidy.httpclient
|
||||
:synopsis: HTTP Client helpers for Mopidy its Extensions.
|
||||
:members:
|
||||
@ -4,26 +4,57 @@
|
||||
API reference
|
||||
*************
|
||||
|
||||
.. note:: What is public?
|
||||
.. note::
|
||||
|
||||
Only APIs documented here are public and open for use by Mopidy
|
||||
extensions.
|
||||
|
||||
|
||||
.. toctree::
|
||||
:glob:
|
||||
Concepts
|
||||
========
|
||||
|
||||
concepts
|
||||
.. toctree::
|
||||
|
||||
architecture
|
||||
models
|
||||
backends
|
||||
|
||||
|
||||
Basics
|
||||
======
|
||||
|
||||
.. toctree::
|
||||
|
||||
core
|
||||
audio
|
||||
mixer
|
||||
frontends
|
||||
commands
|
||||
frontend
|
||||
backend
|
||||
ext
|
||||
config
|
||||
zeroconf
|
||||
|
||||
|
||||
Web/JavaScript
|
||||
==============
|
||||
|
||||
.. toctree::
|
||||
|
||||
http-server
|
||||
http
|
||||
js
|
||||
|
||||
|
||||
Audio
|
||||
=====
|
||||
|
||||
.. toctree::
|
||||
|
||||
audio
|
||||
mixer
|
||||
|
||||
|
||||
Utilities
|
||||
=========
|
||||
|
||||
.. toctree::
|
||||
|
||||
commands
|
||||
config
|
||||
httpclient
|
||||
zeroconf
|
||||
|
||||
@ -21,9 +21,9 @@ available at:
|
||||
|
||||
You may need to adjust hostname and port for your local setup.
|
||||
|
||||
Thus, if you use Mopidy to host your web client, like described above, you can
|
||||
load the latest version of Mopidy.js by adding the following script tag to your
|
||||
HTML file:
|
||||
Thus, if you use Mopidy to host your web client, like described in
|
||||
:ref:`static-web-client`, you can load the latest version of Mopidy.js by
|
||||
adding the following script tag to your HTML file:
|
||||
|
||||
.. code-block:: html
|
||||
|
||||
@ -189,13 +189,10 @@ you've hooked up an errback (more on that a bit later) to the promise returned
|
||||
from the call, the errback will be called with a ``Mopidy.ConnectionError``
|
||||
instance.
|
||||
|
||||
All methods in Mopidy's :ref:`core-api` is available via Mopidy.js. The core
|
||||
API attributes is *not* available, but that shouldn't be a problem as we've
|
||||
added (undocumented) getters and setters for all of them, so you can access the
|
||||
attributes as well from JavaScript. For example, the
|
||||
:attr:`mopidy.core.PlaybackController.state` attribute is available in
|
||||
JSON-RPC as the method ``core.playback.get_state`` and in Mopidy.js as
|
||||
``mopidy.playback.getState()``.
|
||||
All methods in Mopidy's :ref:`core-api` is available via Mopidy.js. For
|
||||
example, the :meth:`mopidy.core.PlaybackController.get_state` method is
|
||||
available in JSON-RPC as the method ``core.playback.get_state`` and in
|
||||
Mopidy.js as ``mopidy.playback.getState()``.
|
||||
|
||||
Both the WebSocket API and the JavaScript API are based on introspection of the
|
||||
core Python API. Thus, they will always be up to date and immediately reflect
|
||||
@ -218,8 +215,7 @@ by looking at the method's ``description`` and ``params`` attributes:
|
||||
|
||||
JSON-RPC 2.0 limits method parameters to be sent *either* by-position or
|
||||
by-name. Combinations of both, like we're used to from Python, isn't supported
|
||||
by JSON-RPC 2.0. To further limit this, Mopidy.js currently only supports
|
||||
passing parameters by-position.
|
||||
by JSON-RPC 2.0.
|
||||
|
||||
Obviously, you'll want to get a return value from many of your method calls.
|
||||
Since everything is happening across the WebSocket and maybe even across the
|
||||
@ -272,8 +268,9 @@ passing it as the second argument to ``done()``:
|
||||
.done(printCurrentTrack, console.error.bind(console));
|
||||
|
||||
If you don't hook up an error handler function and never call ``done()`` on the
|
||||
promise object, when.js will log warnings to the console that you have
|
||||
unhandled errors. In general, unhandled errors will not go silently missing.
|
||||
promise object, warnings will be logged to the console complaining that you
|
||||
have 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
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
.. _mixer-api:
|
||||
|
||||
***************
|
||||
Audio mixer API
|
||||
***************
|
||||
***************************************
|
||||
:mod:`mopidy.mixer` --- Audio mixer API
|
||||
***************************************
|
||||
|
||||
.. module:: mopidy.mixer
|
||||
:synopsis: The audio mixer API
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
***********
|
||||
Data models
|
||||
***********
|
||||
************************************
|
||||
:mod:`mopidy.models` --- Data models
|
||||
************************************
|
||||
|
||||
These immutable data models are used for all data transfer within the Mopidy
|
||||
backends and between the backends and the MPD frontend. All fields are optional
|
||||
and immutable. In other words, they can only be set through the class
|
||||
constructor during instance creation.
|
||||
constructor during instance creation. Additionally fields are type checked.
|
||||
|
||||
If you want to modify a model, use the
|
||||
:meth:`~mopidy.models.ImmutableObject.copy` method. It accepts keyword
|
||||
:meth:`~mopidy.models.ImmutableObject.replace` method. It accepts keyword
|
||||
arguments for the parts of the model you want to change, and copies the rest of
|
||||
the data from the model you call it on. Example::
|
||||
|
||||
@ -16,7 +16,7 @@ the data from the model you call it on. Example::
|
||||
>>> track1 = Track(name='Christmas Carol', length=171)
|
||||
>>> track1
|
||||
Track(artists=[], length=171, name='Christmas Carol')
|
||||
>>> track2 = track1.copy(length=37)
|
||||
>>> track2 = track1.replace(length=37)
|
||||
>>> track2
|
||||
Track(artists=[], length=37, name='Christmas Carol')
|
||||
>>> track1
|
||||
@ -75,7 +75,31 @@ Data model helpers
|
||||
==================
|
||||
|
||||
.. autoclass:: mopidy.models.ImmutableObject
|
||||
:members:
|
||||
|
||||
.. autoclass:: mopidy.models.ValidatedImmutableObject
|
||||
:members: replace
|
||||
|
||||
Data model (de)serialization
|
||||
----------------------------
|
||||
|
||||
.. autofunction:: mopidy.models.model_json_decoder
|
||||
|
||||
.. autoclass:: mopidy.models.ModelJSONEncoder
|
||||
|
||||
.. autofunction:: mopidy.models.model_json_decoder
|
||||
Data model field types
|
||||
----------------------
|
||||
|
||||
.. autoclass:: mopidy.models.fields.Field
|
||||
|
||||
.. autoclass:: mopidy.models.fields.String
|
||||
|
||||
.. autoclass:: mopidy.models.fields.Identifier
|
||||
|
||||
.. autoclass:: mopidy.models.fields.URI
|
||||
|
||||
.. autoclass:: mopidy.models.fields.Date
|
||||
|
||||
.. autoclass:: mopidy.models.fields.Integer
|
||||
|
||||
.. autoclass:: mopidy.models.fields.Collection
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
.. _zeroconf-api:
|
||||
|
||||
************
|
||||
Zeroconf API
|
||||
************
|
||||
***************************************
|
||||
:mod:`mopidy.zeroconf` --- Zeroconf API
|
||||
***************************************
|
||||
|
||||
.. module:: mopidy.zeroconf
|
||||
:synopsis: Helper for publishing of services on Zeroconf
|
||||
|
||||
@ -4,15 +4,118 @@ Changelog
|
||||
|
||||
This changelog is used to track all major changes to Mopidy.
|
||||
|
||||
|
||||
v1.1.0 (UNRELEASED)
|
||||
===================
|
||||
|
||||
Core API
|
||||
--------
|
||||
|
||||
- Calling :meth:`mopidy.core.library.LibraryController.search`` with ``kwargs``
|
||||
as the query is no longer supported (PR: :issue:`1090`)
|
||||
- Calling the following methods with ``kwargs`` is being deprecated.
|
||||
(PR: :issue:`1090`)
|
||||
|
||||
- :meth:`mopidy.core.library.LibraryController.search`
|
||||
- :meth:`mopidy.core.library.PlaylistsController.filter`
|
||||
- :meth:`mopidy.core.library.TracklistController.filter`
|
||||
- :meth:`mopidy.core.library.TracklistController.remove`
|
||||
|
||||
- Updated core controllers to handle backend exceptions in all calls that rely
|
||||
on multiple backends. (Issue: :issue:`667`)
|
||||
|
||||
- Update core methods to do strict input checking. (Fixes: :issue:`700`)
|
||||
|
||||
- Add ``tlid`` alternatives to methods that take ``tl_track`` and also add
|
||||
``get_{eot,next,previous}_tlid`` methods as light weight alternatives to the
|
||||
``tl_track`` versions of the calls. (Fixes: :issue:`1131` PR: :issue:`1136`,
|
||||
:issue:`1140`)
|
||||
|
||||
- Add :meth:`mopidy.core.playback.PlaybackController.get_current_tlid`.
|
||||
(Part of: :issue:`1137`)
|
||||
|
||||
- Update core to handle backend crashes and bad data. (Fixes: :issue:`1161`)
|
||||
|
||||
- Add `max_tracklist_length` config and limitation. (Fixes: :issue:`997`
|
||||
PR: :issue:`1225`)
|
||||
|
||||
Models
|
||||
------
|
||||
|
||||
- Added type checks and other sanity checks to model construction and
|
||||
serialization. (Fixes: :issue:`865`)
|
||||
|
||||
- Memory usage for models has been greatly improved. We now have a lower
|
||||
overhead per instance by using slots, intern identifiers and automatically
|
||||
reuse instances. For the test data set this was developed against, a library
|
||||
of ~14000 tracks, went from needing ~75MB to ~17MB. (Fixes: :issue:`348`)
|
||||
|
||||
MPD frontend
|
||||
------------
|
||||
|
||||
- The MPD command ``count`` now ignores tracks with no length, which would
|
||||
previously cause a :exc:`TypeError`. (PR: :issue:`1192`)
|
||||
|
||||
- Concatenate multiple artists, composers and performers using the "A;B" format
|
||||
instead of "A, B". This is a part of updating our protocol implementation to
|
||||
match MPD 0.19. (PR: :issue:`1213`)
|
||||
|
||||
- Add "not implemented" skeletons of new commands in the MPD protocol version
|
||||
0.19:
|
||||
|
||||
- Current playlist:
|
||||
|
||||
- ``rangeid``
|
||||
- ``addtagid``
|
||||
- ``cleartagid``
|
||||
|
||||
- Mounts and neighbors:
|
||||
|
||||
- ``mount``
|
||||
- ``unmount``
|
||||
- ``listmounts``
|
||||
- ``listneighbors``
|
||||
|
||||
- Music DB:
|
||||
|
||||
- ``listfiles``
|
||||
|
||||
- Track data now include the ``Last-Modified`` field if set on the track model.
|
||||
(Fixes: :issue:`1218`, PR: :issue:`1219`)
|
||||
|
||||
Local backend
|
||||
-------------
|
||||
|
||||
- Filter out :class:`None` from
|
||||
:meth:`~mopidy.backend.LibraryProvider.get_distinct` results. All returned
|
||||
results should be strings. (Fixes: :issue:`1202`)
|
||||
|
||||
File backend
|
||||
------------
|
||||
|
||||
The :ref:`Mopidy-File <ext-file>` backend is a new bundled backend. It is
|
||||
similar to Mopidy-Local since it works with local files, but it differs in a
|
||||
few key ways:
|
||||
|
||||
- Mopidy-File lets you browse your media files by their file hierarchy.
|
||||
|
||||
- It supports multiple media directories, all exposed under the "Files"
|
||||
directory when you browse your library with e.g. an MPD client.
|
||||
|
||||
- There is no index of the media files, like the JSON or SQLite files used by
|
||||
Mopidy-Local. Thus no need to scan the music collection before starting
|
||||
Mopidy. Everything is read from the file system when needed and changes to
|
||||
the file system is thus immediately visible in Mopidy clients.
|
||||
|
||||
- Because there is no index, there is no support for search.
|
||||
|
||||
Our long term plan is to keep this very simple file backend in Mopidy, as it
|
||||
has a well defined and limited scope, while splitting the more feature rich
|
||||
Mopidy-Local extension out to an independent project. (Fixes: :issue:`1004`,
|
||||
PR: :issue:`1207`)
|
||||
|
||||
Utils
|
||||
-----
|
||||
|
||||
- Add :func:`mopidy.httpclient.format_proxy` and
|
||||
:func:`mopidy.httpclient.format_user_agent`. (Part of: :issue:`1156`)
|
||||
|
||||
Internal changes
|
||||
----------------
|
||||
@ -20,19 +123,130 @@ Internal changes
|
||||
- Tests have been cleaned up to stop using deprecated APIs where feasible.
|
||||
(Partial fix: :issue:`1083`, PR: :issue:`1090`)
|
||||
|
||||
- It is now possible to import :mod:`mopidy.backends` without having GObject or
|
||||
GStreamer installed. In other words, a lot of backend extensions should now
|
||||
be able to run tests in a virtualenv with global site-packages disabled. This
|
||||
removes a lot of potential error sources. (Fixes: :issue:`1068`, PR:
|
||||
:issue:`1115`)
|
||||
|
||||
v1.0.1 (UNRELEASED)
|
||||
|
||||
v1.0.8 (2015-07-22)
|
||||
===================
|
||||
|
||||
Bug fix release.
|
||||
|
||||
- Fix reversal of ``Title`` and ``Name`` in MPD protocol (Fixes: :issue:`1212`
|
||||
PR: :issue:`1214`)
|
||||
|
||||
- Fix crash if an M3U file in the :confval:`m3u/playlist_dir` directory has a
|
||||
file name not decodable with the current file system encoding. (Fixes:
|
||||
:issue:`1209`)
|
||||
|
||||
|
||||
v1.0.7 (2015-06-26)
|
||||
===================
|
||||
|
||||
Bug fix release.
|
||||
|
||||
- Fix error in the MPD command ``list title ...``. The error was introduced in
|
||||
v1.0.6.
|
||||
|
||||
|
||||
v1.0.6 (2015-06-25)
|
||||
===================
|
||||
|
||||
Bug fix release.
|
||||
|
||||
- Core/MPD/Local: Add support for ``title`` in
|
||||
:meth:`mopidy.core.LibraryController.get_distinct`. (Fixes: :issue:`1181`,
|
||||
PR: :issue:`1183`)
|
||||
|
||||
- Core: Make sure track changes make it to audio while paused.
|
||||
(Fixes: :issue:`1177`, PR: :issue:`1185`)
|
||||
|
||||
|
||||
v1.0.5 (2015-05-19)
|
||||
===================
|
||||
|
||||
Bug fix release.
|
||||
|
||||
- Core: Add workaround for playlist providers that do not support
|
||||
creating playlists. (Fixes: :issue:`1162`, PR :issue:`1165`)
|
||||
|
||||
- M3U: Fix encoding error when saving playlists with non-ASCII track
|
||||
titles. (Fixes: :issue:`1175`, PR :issue:`1176`)
|
||||
|
||||
|
||||
v1.0.4 (2015-04-30)
|
||||
===================
|
||||
|
||||
Bug fix release.
|
||||
|
||||
- Audio: Since all previous attempts at tweaking the queuing for :issue:`1097`
|
||||
seems to break things in subtle ways for different users. We are giving up
|
||||
on tweaking the defaults and just going to live with a bit more lag on
|
||||
software volume changes. (Fixes: :issue:`1147`)
|
||||
|
||||
|
||||
v1.0.3 (2015-04-28)
|
||||
===================
|
||||
|
||||
Bug fix release.
|
||||
|
||||
- HTTP: Another follow-up to the Tornado <3.0 fixing. Since the tests aren't
|
||||
run for Tornado 2.3 we didn't catch that our previous fix wasn't sufficient.
|
||||
(Fixes: :issue:`1153`, PR: :issue:`1154`)
|
||||
|
||||
- Audio: Follow-up fix for :issue:`1097` still exhibits issues for certain
|
||||
setups. We are giving this get an other go by setting the buffer size to
|
||||
maximum 100ms instead of a fixed number of buffers. (Addresses: :issue:`1147`,
|
||||
PR: :issue:`1154`)
|
||||
|
||||
|
||||
v1.0.2 (2015-04-27)
|
||||
===================
|
||||
|
||||
Bug fix release.
|
||||
|
||||
- HTTP: Make event broadcasts work with Tornado 2.3 again. The threading fix
|
||||
in v1.0.1 broke this.
|
||||
|
||||
- Audio: Fix for :issue:`1097` tuned down the buffer size in the queue. Turns
|
||||
out this can cause distortions in certain cases. Give this an other go with
|
||||
a more generous buffer size. (Addresses: :issue:`1147`, PR: :issue:`1152`)
|
||||
|
||||
- Audio: Make sure mute events get emitted by software mixer.
|
||||
(Fixes: :issue:`1146`, PR: :issue:`1152`)
|
||||
|
||||
|
||||
v1.0.1 (2015-04-23)
|
||||
===================
|
||||
|
||||
Bug fix release.
|
||||
|
||||
- Core: Make the new history controller available for use. (Fixes: :js:`6`)
|
||||
|
||||
- Audio: Software volume control has been reworked to greatly reduce the delay
|
||||
between changing the volume and the change taking effect. (Fixes:
|
||||
:issue:`1097`)
|
||||
:issue:`1097`, PR: :issue:`1101`)
|
||||
|
||||
- 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.
|
||||
|
||||
- Audio: Update scanner to decode all media it finds. This should fix cases
|
||||
where the scanner hangs on non-audio files like video. The scanner will now
|
||||
also let us know if we found any decodeable audio. (Fixes: :issue:`726`, PR:
|
||||
issue:`1124`)
|
||||
|
||||
- HTTP: Fix threading bug that would cause duplicate delivery of WS messages.
|
||||
(PR: :issue:`1127`)
|
||||
|
||||
- MPD: Fix case where a playlist that is present in both browse and as a listed
|
||||
playlist breaks the MPD frontend protocol output. (Fixes :issue:`1120`, PR:
|
||||
:issue:`1142`)
|
||||
|
||||
|
||||
v1.0.0 (2015-03-25)
|
||||
===================
|
||||
|
||||
@ -12,38 +12,8 @@ http://mpd.wikia.com/wiki/Clients.
|
||||
:local:
|
||||
|
||||
|
||||
Test procedure
|
||||
==============
|
||||
|
||||
In some cases, we've used the following test procedure to compare the feature
|
||||
completeness of clients:
|
||||
|
||||
#. Connect to Mopidy
|
||||
#. Search for "foo", with search type "any" if it can be selected
|
||||
#. Add "The Pretender" from the search results to the current playlist
|
||||
#. Start playback
|
||||
#. Pause and resume playback
|
||||
#. Adjust volume
|
||||
#. Find a playlist and append it to the current playlist
|
||||
#. Skip to next track
|
||||
#. Skip to previous track
|
||||
#. Select the last track from the current playlist
|
||||
#. Turn on repeat mode
|
||||
#. Seek to 10 seconds or so before the end of the track
|
||||
#. Wait for the end of the track and confirm that playback continues at the
|
||||
start of the playlist
|
||||
#. Turn off repeat mode
|
||||
#. Turn on random mode
|
||||
#. Skip to next track and confirm that it random mode works
|
||||
#. Turn off random mode
|
||||
#. Stop playback
|
||||
#. Check if the app got support for single mode and consume mode
|
||||
#. Kill Mopidy and confirm that the app handles it without crashing
|
||||
|
||||
|
||||
|
||||
Console clients
|
||||
===============
|
||||
MPD console clients
|
||||
===================
|
||||
|
||||
ncmpcpp
|
||||
-------
|
||||
@ -83,8 +53,8 @@ A command line client. Version 0.16 and upwards seems to work nicely with
|
||||
Mopidy.
|
||||
|
||||
|
||||
Graphical clients
|
||||
=================
|
||||
MPD graphical clients
|
||||
=====================
|
||||
|
||||
GMPC
|
||||
----
|
||||
@ -132,22 +102,12 @@ client for OS X. It is unmaintained, but generally works well with Mopidy.
|
||||
|
||||
.. _android_mpd_clients:
|
||||
|
||||
Android clients
|
||||
===============
|
||||
|
||||
We've tested all five MPD clients we could find for Android with Mopidy 0.8.1
|
||||
on a Samsung Galaxy Nexus with Android 4.1.2, using our standard test
|
||||
procedure.
|
||||
|
||||
MPD Android clients
|
||||
===================
|
||||
|
||||
MPDroid
|
||||
-------
|
||||
|
||||
Test date:
|
||||
2012-11-06
|
||||
Tested version:
|
||||
1.03.1 (released 2012-10-16)
|
||||
|
||||
.. image:: mpd-client-mpdroid.jpg
|
||||
:width: 288
|
||||
:height: 512
|
||||
@ -155,128 +115,17 @@ Tested version:
|
||||
You can get `MPDroid from Google Play
|
||||
<https://play.google.com/store/apps/details?id=com.namelessdev.mpdroid>`_.
|
||||
|
||||
- MPDroid started out as a fork of PMix, and is now much better.
|
||||
|
||||
- MPDroid's user interface looks nice.
|
||||
|
||||
- Everything in the test procedure works.
|
||||
|
||||
- In contrast to all other Android clients, MPDroid does support single mode or
|
||||
consume mode.
|
||||
|
||||
- When Mopidy is killed, MPDroid handles it gracefully and asks if you want to
|
||||
try to reconnect.
|
||||
|
||||
MPDroid is a good MPD client, and really the only one we can recommend.
|
||||
|
||||
|
||||
BitMPC
|
||||
------
|
||||
|
||||
Test date:
|
||||
2012-11-06
|
||||
Tested version:
|
||||
1.0.0 (released 2010-04-12)
|
||||
|
||||
You can get `BitMPC from Google Play
|
||||
<https://play.google.com/store/apps/details?id=bitendian.bitmpc>`_.
|
||||
|
||||
- The user interface lacks some finishing touches. E.g. you can't enter a
|
||||
hostname for the server. Only IPv4 addresses are allowed.
|
||||
|
||||
- When we last tested the same version of BitMPC using Android 2.1:
|
||||
|
||||
- All features exercised in the test procedure worked.
|
||||
|
||||
- BitMPC lacked support for single mode and consume mode.
|
||||
|
||||
- BitMPC crashed if Mopidy was killed or crashed.
|
||||
|
||||
- When we tried to test using Android 4.1.1, BitMPC started and connected to
|
||||
Mopidy without problems, but the app crashed as soon as we fired off our
|
||||
search, and continued to crash on startup after that.
|
||||
|
||||
In conclusion, BitMPC is usable if you got an older Android phone and don't
|
||||
care about looks. For newer Android versions, BitMPC will probably not work as
|
||||
it hasn't been maintained for 2.5 years.
|
||||
|
||||
|
||||
Droid MPD Client
|
||||
----------------
|
||||
|
||||
Test date:
|
||||
2012-11-06
|
||||
Tested version:
|
||||
1.4.0 (released 2011-12-20)
|
||||
|
||||
You can get `Droid MPD Client from Google Play
|
||||
<https://play.google.com/store/apps/details?id=com.soreha.droidmpdclient>`_.
|
||||
|
||||
- No intutive way to ask the app to connect to the server after adding the
|
||||
server hostname to the settings.
|
||||
|
||||
- To find the search functionality, you have to select the menu,
|
||||
then "Playlist manager", then the search tab. I do not understand why search
|
||||
is hidden inside "Playlist manager".
|
||||
|
||||
- The tabs "Artists" and "Albums" did not contain anything, and did not cause
|
||||
any requests.
|
||||
|
||||
- The tab "Folders" showed a spinner and said "Updating data..." but did not
|
||||
send any requests.
|
||||
|
||||
- Searching for "foo" did nothing. No request was sent to the server.
|
||||
|
||||
- Droid MPD client does not support single mode or consume mode.
|
||||
|
||||
- Not able to complete the test procedure, due to the above problems.
|
||||
|
||||
In conclusion, not a client we can recommend.
|
||||
|
||||
|
||||
PMix
|
||||
----
|
||||
|
||||
Test date:
|
||||
2012-11-06
|
||||
Tested version:
|
||||
0.4.0 (released 2010-03-06)
|
||||
|
||||
You can get `PMix from Google Play
|
||||
<https://play.google.com/store/apps/details?id=org.pmix.ui>`_.
|
||||
|
||||
PMix haven't been updated for 2.5 years, and has less working features than
|
||||
it's fork MPDroid. Ignore PMix and use MPDroid instead.
|
||||
|
||||
|
||||
MPD Remote
|
||||
----------
|
||||
|
||||
Test date:
|
||||
2012-11-06
|
||||
Tested version:
|
||||
1.0 (released 2012-05-01)
|
||||
|
||||
You can get `MPD Remote from Google Play
|
||||
<https://play.google.com/store/apps/details?id=fr.mildlyusefulsoftware.mpdremote>`_.
|
||||
|
||||
This app looks terrible in the screen shots, got just 100+ downloads, and got a
|
||||
terrible rating. I honestly didn't take the time to test it.
|
||||
|
||||
|
||||
.. _ios_mpd_clients:
|
||||
|
||||
iOS clients
|
||||
===========
|
||||
MPD iOS clients
|
||||
===============
|
||||
|
||||
MPoD
|
||||
----
|
||||
|
||||
Test date:
|
||||
2012-11-06
|
||||
Tested version:
|
||||
1.7.1
|
||||
|
||||
.. image:: mpd-client-mpod.jpg
|
||||
:width: 320
|
||||
:height: 480
|
||||
@ -285,26 +134,10 @@ The `MPoD <http://www.katoemba.net/makesnosenseatall/mpod/>`_ iPhone/iPod Touch
|
||||
app can be installed from `MPoD at iTunes Store
|
||||
<https://itunes.apple.com/us/app/mpod/id285063020>`_.
|
||||
|
||||
- The user interface looks nice.
|
||||
|
||||
- All features exercised in the test procedure worked with MPaD, except seek,
|
||||
which I didn't figure out to do.
|
||||
|
||||
- Search only works in the "Browse" tab, and not under in the "Artist",
|
||||
"Album", or "Song" tabs. For the tabs where search doesn't work, no queries
|
||||
are sent to Mopidy when searching.
|
||||
|
||||
- Single mode and consume mode is supported.
|
||||
|
||||
|
||||
MPaD
|
||||
----
|
||||
|
||||
Test date:
|
||||
2012-11-06
|
||||
Tested version:
|
||||
1.7.1
|
||||
|
||||
.. image:: mpd-client-mpad.jpg
|
||||
:width: 480
|
||||
:height: 360
|
||||
@ -313,25 +146,11 @@ The `MPaD <http://www.katoemba.net/makesnosenseatall/mpad/>`_ iPad app can be
|
||||
purchased from `MPaD at iTunes Store
|
||||
<https://itunes.apple.com/us/app/mpad/id423097706>`_
|
||||
|
||||
- The user interface looks nice, though I would like to be able to view the
|
||||
current playlist in the large part of the split view.
|
||||
|
||||
- All features exercised in the test procedure worked with MPaD.
|
||||
|
||||
- Search only works in the "Browse" tab, and not under in the "Artist",
|
||||
"Album", or "Song" tabs. For the tabs where search doesn't work, no queries
|
||||
are sent to Mopidy when searching.
|
||||
|
||||
- Single mode and consume mode is supported.
|
||||
|
||||
- The server menu can be very slow top open, and there is no visible feedback
|
||||
when waiting for the connection to a server to succeed.
|
||||
|
||||
|
||||
.. _mpd-web-clients:
|
||||
|
||||
Web clients
|
||||
===========
|
||||
MPD web clients
|
||||
===============
|
||||
|
||||
The following web clients use the MPD protocol to communicate with Mopidy. For
|
||||
other web clients, see :ref:`http-clients`.
|
||||
|
||||
12
docs/conf.py
12
docs/conf.py
@ -47,7 +47,6 @@ class Mock(object):
|
||||
return Mock()
|
||||
|
||||
MOCK_MODULES = [
|
||||
'cherrypy',
|
||||
'dbus',
|
||||
'dbus.mainloop',
|
||||
'dbus.mainloop.glib',
|
||||
@ -61,12 +60,6 @@ MOCK_MODULES = [
|
||||
'pykka.actor',
|
||||
'pykka.future',
|
||||
'pykka.registry',
|
||||
'pylast',
|
||||
'ws4py',
|
||||
'ws4py.messaging',
|
||||
'ws4py.server',
|
||||
'ws4py.server.cherrypyserver',
|
||||
'ws4py.websocket',
|
||||
]
|
||||
for mod_name in MOCK_MODULES:
|
||||
sys.modules[mod_name] = Mock()
|
||||
@ -102,10 +95,13 @@ master_doc = 'index'
|
||||
project = 'Mopidy'
|
||||
copyright = '2009-2015, Stein Magnus Jodal and contributors'
|
||||
|
||||
from mopidy.utils.versioning import get_version
|
||||
from mopidy.internal.versioning import get_version
|
||||
release = get_version()
|
||||
version = '.'.join(release.split('.')[:2])
|
||||
|
||||
# To make the build reproducible, avoid using today's date in the manpages
|
||||
today = '2015'
|
||||
|
||||
exclude_trees = ['_build']
|
||||
|
||||
pygments_style = 'sphinx'
|
||||
|
||||
@ -325,13 +325,6 @@ 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:
|
||||
|
||||
|
||||
@ -57,6 +57,28 @@ Provides a backend for browsing the Internet radio channels from the `Dirble
|
||||
<http://dirble.com/>`_ directory.
|
||||
|
||||
|
||||
Mopidy-dLeyna
|
||||
=============
|
||||
|
||||
https://github.com/tkem/mopidy-dleyna
|
||||
|
||||
Provides a backend for playing music from Digital Media Servers using
|
||||
the `dLeyna <http://01.org/dleyna>`_ D-Bus interface.
|
||||
|
||||
Mopidy-File
|
||||
===========
|
||||
|
||||
Bundled with Mopidy. See :ref:`ext-file`.
|
||||
|
||||
Mopidy-Grooveshark
|
||||
==================
|
||||
|
||||
https://github.com/camilonova/mopidy-grooveshark
|
||||
|
||||
Provides a backend for playing music from `Grooveshark
|
||||
<http://grooveshark.com/>`_.
|
||||
|
||||
|
||||
Mopidy-GMusic
|
||||
=============
|
||||
|
||||
|
||||
47
docs/ext/file.rst
Normal file
47
docs/ext/file.rst
Normal file
@ -0,0 +1,47 @@
|
||||
.. _ext-file:
|
||||
|
||||
************
|
||||
Mopidy-File
|
||||
************
|
||||
|
||||
Mopidy-File is an extension for playing music from your local music archive.
|
||||
It is bundled with Mopidy and enabled by default.
|
||||
It allows you to browse through your local file system.
|
||||
Only files that are considered playable will be shown.
|
||||
|
||||
This backend handles URIs starting with ``file:``.
|
||||
|
||||
|
||||
Configuration
|
||||
=============
|
||||
|
||||
See :ref:`config` for general help on configuring Mopidy.
|
||||
|
||||
.. literalinclude:: ../../mopidy/file/ext.conf
|
||||
:language: ini
|
||||
|
||||
.. confval:: file/enabled
|
||||
|
||||
If the file extension should be enabled or not.
|
||||
|
||||
.. confval:: file/media_dirs
|
||||
|
||||
A list of directories to be browsable.
|
||||
Optionally the path can be followed by ``|`` and a name that will be shown for that path.
|
||||
|
||||
.. confval:: file/show_dotfiles
|
||||
|
||||
Whether to show hidden files and directories that start with a dot.
|
||||
Default is false.
|
||||
|
||||
.. confval:: file/follow_symlinks
|
||||
|
||||
Whether to follow symbolic links found in :confval:`files/media_dir`.
|
||||
Directories and files that are outside the configured directories will not be shown.
|
||||
Default is false.
|
||||
|
||||
.. confval:: file/metadata_timeout
|
||||
|
||||
Number of milliseconds before giving up scanning a file and moving on to
|
||||
the next file. Reducing the value might speed up the directory listing,
|
||||
but can lead to some tracks not being shown.
|
||||
BIN
docs/ext/material_webclient.png
Normal file
BIN
docs/ext/material_webclient.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@ -29,6 +29,14 @@ Extension for controlling volume using an external Arcam amplifier. Developed
|
||||
and tested with an Arcam AVR-300.
|
||||
|
||||
|
||||
Mopidy-dam1021
|
||||
==============
|
||||
|
||||
https://github.com/fortaa/mopidy-dam1021
|
||||
|
||||
Extension for controlling volume using a dam1021 DAC device.
|
||||
|
||||
|
||||
Mopidy-NAD
|
||||
==========
|
||||
|
||||
|
||||
BIN
docs/ext/mopster.png
Normal file
BIN
docs/ext/mopster.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
@ -48,6 +48,22 @@ To install, run::
|
||||
pip install Mopidy-Local-Images
|
||||
|
||||
|
||||
Mopidy-Material-Webclient
|
||||
=========================
|
||||
|
||||
https://github.com/matgallacher/mopidy-material-webclient
|
||||
|
||||
A Mopidy web client with an Android Material design feel.
|
||||
|
||||
.. image:: /ext/material_webclient.png
|
||||
:width: 960
|
||||
:height: 520
|
||||
|
||||
To install, run::
|
||||
|
||||
pip install Mopidy-Material-Webclient
|
||||
|
||||
|
||||
Mopidy-Mobile
|
||||
=============
|
||||
|
||||
@ -143,6 +159,21 @@ A web extension for changing settings. Used by the Pi MusicBox distribution
|
||||
for Raspberry Pi, but also usable for other projects.
|
||||
|
||||
|
||||
Mopster
|
||||
=======
|
||||
|
||||
https://github.com/cowbell/mopster
|
||||
|
||||
Simple web client hosted online written in Ember.js and styled using basic
|
||||
Bootstrap by Wojciech Wnętrzak.
|
||||
|
||||
.. image:: /ext/mopster.png
|
||||
:width: 1275
|
||||
:height: 628
|
||||
|
||||
To use, just visit http://mopster.cowbell-labs.com/.
|
||||
|
||||
|
||||
Other web clients
|
||||
=================
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ Extension development
|
||||
|
||||
Mopidy started as simply an MPD server that could play music from Spotify.
|
||||
Early on, Mopidy got multiple "frontends" to expose Mopidy to more than just MPD
|
||||
clients: for example the scrobbler frontend that scrobbles your listening
|
||||
clients: for example the scrobbler frontend that scrobbles your listening
|
||||
history to your Last.fm account, the MPRIS frontend that integrates Mopidy into the
|
||||
Ubuntu Sound Menu, and the HTTP server and JavaScript player API making web
|
||||
based Mopidy clients possible. In Mopidy 0.9 we added support for multiple
|
||||
@ -75,10 +75,10 @@ the readme of `cookiecutter-mopidy-ext
|
||||
Example README.rst
|
||||
==================
|
||||
|
||||
The README file should quickly explain what the extension does, how to install
|
||||
it, and how to configure it. It should also contain a link to a tarball of the
|
||||
latest development version of the extension. It's important that this link ends
|
||||
with ``#egg=Mopidy-Something-dev`` for installation using
|
||||
The README file should quickly explain what the extension does, how to install
|
||||
it, and how to configure it. It should also contain a link to a tarball of the
|
||||
latest development version of the extension. It's important that this link ends
|
||||
with ``#egg=Mopidy-Something-dev`` for installation using
|
||||
``pip install Mopidy-Something==dev`` to work.
|
||||
|
||||
.. code-block:: rst
|
||||
@ -230,8 +230,8 @@ The root of your Python package should have an ``__version__`` attribute with a
|
||||
class named ``Extension`` which inherits from Mopidy's extension base class,
|
||||
:class:`mopidy.ext.Extension`. This is the class referred to in the
|
||||
``entry_points`` part of ``setup.py``. Any imports of other files in your
|
||||
extension, outside of Mopidy and it's core requirements, should be kept inside
|
||||
methods. This ensures that this file can be imported without raising
|
||||
extension, outside of Mopidy and it's core requirements, should be kept inside
|
||||
methods. This ensures that this file can be imported without raising
|
||||
:exc:`ImportError` exceptions for missing dependencies, etc.
|
||||
|
||||
The default configuration for the extension is defined by the
|
||||
@ -245,7 +245,7 @@ change them. The exception is if the config value has security implications; in
|
||||
that case you should default to the most secure configuration. Leave any
|
||||
configurations that don't have meaningful defaults blank, like ``username``
|
||||
and ``password``. In the example below, we've chosen to maintain the default
|
||||
config as a separate file named ``ext.conf``. This makes it easy to include the
|
||||
config as a separate file named ``ext.conf``. This makes it easy to include the
|
||||
default config in documentation without duplicating it.
|
||||
|
||||
This is ``mopidy_soundspot/__init__.py``::
|
||||
@ -413,11 +413,11 @@ examples, see the :ref:`http-server-api` docs or explore with
|
||||
Running an extension
|
||||
====================
|
||||
|
||||
Once your extension is ready to go, to see it in action you'll need to register
|
||||
it with Mopidy. Typically this is done by running ``python setup.py install``
|
||||
from your extension's Git repo root directory. While developing your extension
|
||||
and to avoid doing this every time you make a change, you can instead run
|
||||
``python setup.py develop`` to effectively link Mopidy directly with your
|
||||
Once your extension is ready to go, to see it in action you'll need to register
|
||||
it with Mopidy. Typically this is done by running ``python setup.py install``
|
||||
from your extension's Git repo root directory. While developing your extension
|
||||
and to avoid doing this every time you make a change, you can instead run
|
||||
``python setup.py develop`` to effectively link Mopidy directly with your
|
||||
development files.
|
||||
|
||||
|
||||
@ -434,9 +434,12 @@ Use of Mopidy APIs
|
||||
==================
|
||||
|
||||
When writing an extension, you should only use APIs documented at
|
||||
:ref:`api-ref`. Other parts of Mopidy, like :mod:`mopidy.utils`, may change at
|
||||
any time and are not something extensions should use.
|
||||
:ref:`api-ref`. Other parts of Mopidy, like :mod:`mopidy.internal`, may change
|
||||
at any time and are not something extensions should use.
|
||||
|
||||
Mopidy performs type checking to help catch extension bugs. This applies to
|
||||
both to frontend calls into core and return values from backends. Additionally
|
||||
model fields always get validated to further guard against bad data.
|
||||
|
||||
Logging in extensions
|
||||
=====================
|
||||
@ -471,3 +474,76 @@ Is much better than::
|
||||
If you want to turn on debug logging for your own extension, but not for
|
||||
everything else due to the amount of noise, see the docs for the
|
||||
:confval:`loglevels/*` config section.
|
||||
|
||||
|
||||
Making HTTP requests from extensions
|
||||
====================================
|
||||
|
||||
Many Mopidy extensions need to make HTTP requests to use some web API. Here's a
|
||||
few recommendations to those extensions.
|
||||
|
||||
Proxies
|
||||
-------
|
||||
|
||||
If you make HTTP requests please make sure to respect the :ref:`proxy configs
|
||||
<proxy-config>`, so that all the requests you make go through the proxy
|
||||
configured by the Mopidy user. To make this easier for extension developers,
|
||||
the helper function :func:`mopidy.httpclient.format_proxy` was added in Mopidy
|
||||
1.1. This function returns the proxy settings `formatted the way Requests
|
||||
expects <http://www.python-requests.org/en/latest/user/advanced/#proxies>`__.
|
||||
|
||||
User-Agent strings
|
||||
------------------
|
||||
|
||||
When you make HTTP requests, it's helpful for debugging and usage analysis if
|
||||
the client identifies itself with a proper User-Agent string. In Mopidy 1.1, we
|
||||
added the helper function :func:`mopidy.httpclient.format_user_agent`. Here's
|
||||
an example of how to use it::
|
||||
|
||||
>>> from mopidy import httpclient
|
||||
>>> import mopidy_soundspot
|
||||
>>> httpclient.format_user_agent('%s/%s' % (
|
||||
... mopidy_soundspot.Extension.dist_name, mopidy_soundspot.__version__))
|
||||
u'Mopidy-SoundSpot/2.0.0 Mopidy/1.0.7 Python/2.7.10'
|
||||
|
||||
Example using Requests sessions
|
||||
-------------------------------
|
||||
|
||||
Most Mopidy extensions that make HTTP requests use the `Requests
|
||||
<http://www.python-requests.org/>`_ library to do so. When using Requests, the
|
||||
most convenient way to make sure the proxy and User-Agent header is set
|
||||
properly is to create a Requests session object and use that object to make all
|
||||
your HTTP requests::
|
||||
|
||||
from mopidy import httpclient
|
||||
|
||||
import requests
|
||||
|
||||
import mopidy_soundspot
|
||||
|
||||
|
||||
def get_requests_session(proxy_config, user_agent):
|
||||
proxy = httpclient.format_proxy(proxy_config)
|
||||
full_user_agent = httpclient.format_user_agent(user_agent)
|
||||
|
||||
session = requests.Session()
|
||||
session.proxies.update({'http': proxy, 'https': proxy})
|
||||
session.headers.update({'user-agent': full_user_agent})
|
||||
|
||||
return session
|
||||
|
||||
|
||||
# ``mopidy_config`` is the config object passed to your frontend/backend
|
||||
# constructor
|
||||
session = get_requests_session(
|
||||
proxy_config=mopidy_config['proxy'],
|
||||
user_agent='%s/%s' % (
|
||||
mopidy_soundspot.Extension.dist_name,
|
||||
mopidy_soundspot.__version__))
|
||||
|
||||
response = session.get('http://example.com')
|
||||
# Now do something with ``response`` and/or make further requests using the
|
||||
# ``session`` object.
|
||||
|
||||
For further details, see Requests' docs on `session objects
|
||||
<http://www.python-requests.org/en/latest/user/advanced/#session-objects>`__.
|
||||
|
||||
@ -6,7 +6,7 @@ Glossary
|
||||
|
||||
backend
|
||||
A part of Mopidy providing music library, playlist storage and/or
|
||||
playback capability to the :term:`core`. Mopidy have a backend for each
|
||||
playback capability to the :term:`core`. Mopidy has a backend for each
|
||||
music store or music service it supports. See :ref:`backend-api` for
|
||||
details.
|
||||
|
||||
|
||||
@ -54,6 +54,8 @@ extension. The cassettes have NFC tags used to select playlists from Spotify.
|
||||
|
||||
To get started with Mopidy, start by reading :ref:`installation`.
|
||||
|
||||
.. _getting-help:
|
||||
|
||||
**Getting help**
|
||||
|
||||
If you get stuck, you can get help at the `Mopidy discussion forum
|
||||
@ -94,6 +96,7 @@ Extensions
|
||||
:maxdepth: 2
|
||||
|
||||
ext/local
|
||||
ext/file
|
||||
ext/m3u
|
||||
ext/stream
|
||||
ext/http
|
||||
|
||||
@ -1,20 +1,19 @@
|
||||
.. _arch-install:
|
||||
|
||||
****************************
|
||||
Arch Linux: Install from AUR
|
||||
****************************
|
||||
**********************************
|
||||
Arch Linux: Install from community
|
||||
**********************************
|
||||
|
||||
If you are running Arch Linux, you can install Mopidy using the
|
||||
`mopidy <https://aur.archlinux.org/packages/mopidy/>`_ package found in AUR.
|
||||
`mopidy <https://www.archlinux.org/packages/community/any/mopidy/>`_ package found in ``community``.
|
||||
|
||||
#. To install Mopidy with all dependencies, you can use
|
||||
for example `yaourt <https://wiki.archlinux.org/index.php/yaourt>`_::
|
||||
#. To install Mopidy with all dependencies, you can use::
|
||||
|
||||
yaourt -S mopidy
|
||||
pacman -S mopidy
|
||||
|
||||
To upgrade Mopidy to future releases, just upgrade your system using::
|
||||
|
||||
yaourt -Syua
|
||||
pacman -Syu
|
||||
|
||||
#. Finally, you need to set a couple of :doc:`config values </config>`, and
|
||||
then you're ready to :doc:`run Mopidy </running>`.
|
||||
@ -24,7 +23,7 @@ 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
|
||||
scrobbling, AUR 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
|
||||
|
||||
@ -18,12 +18,12 @@ from scratch, we have a guide for installing Debian/Raspbian and Mopidy. See
|
||||
|
||||
The packages should work with:
|
||||
|
||||
- Debian stable and testing,
|
||||
- Raspbian stable and testing,
|
||||
- Debian stable ("jessie") and testing ("stretch"),
|
||||
- Raspbian stable ("jessie") and testing ("stretch"),
|
||||
- Ubuntu 14.04 LTS and later.
|
||||
|
||||
Some of the packages, including the core "mopidy" packages, does *not* work
|
||||
on Ubuntu 12.04 LTS.
|
||||
Some of the packages *do not* work with Ubuntu 12.04 LTS or Debian 7
|
||||
"wheezy".
|
||||
|
||||
This is just what we currently support, not a promise to continue to
|
||||
support the same in the future. We *will* drop support for older
|
||||
@ -47,6 +47,13 @@ from scratch, we have a guide for installing Debian/Raspbian and Mopidy. See
|
||||
|
||||
sudo wget -q -O /etc/apt/sources.list.d/mopidy.list https://apt.mopidy.com/mopidy.list
|
||||
|
||||
.. note::
|
||||
|
||||
If you're still running Debian 7 "wheezy" or Raspbian "wheezy", you
|
||||
should edit :file:`/etc/apt/sources.list.d/mopidy.list` and replace
|
||||
"stable" with "wheezy". This will give you the latest set of packages
|
||||
that is compatible with Debian "wheezy".
|
||||
|
||||
#. Install Mopidy and all dependencies::
|
||||
|
||||
sudo apt-get update
|
||||
|
||||
@ -18,7 +18,7 @@ If you are running OS X, you can install everything needed with Homebrew.
|
||||
date before you continue::
|
||||
|
||||
brew update
|
||||
brew upgrade
|
||||
brew upgrade --all
|
||||
|
||||
Notice that this will upgrade all software on your system that have been
|
||||
installed with Homebrew.
|
||||
|
||||
@ -173,3 +173,20 @@ More info about this issue can be found in `this post
|
||||
|
||||
Please note that if you're running Xbian or another XBMC distribution these
|
||||
instructions might vary for your system.
|
||||
|
||||
|
||||
Appendix C: Installation on XBian
|
||||
=================================
|
||||
|
||||
Similar to the Raspbmc issue outlined in Appendix B, it's not possible to
|
||||
install Mopidy on XBian without first resolving a dependency problem between
|
||||
``gstreamer0.10-plugins-good`` and ``libtag1c2a``. More information can be
|
||||
found in `this post
|
||||
<https://github.com/xbianonpi/xbian/issues/378#issuecomment-37723392>`_.
|
||||
|
||||
Run the following commands to remedy this and then install Mopidy as normal::
|
||||
|
||||
cd /tmp
|
||||
wget http://apt.xbian.org/pool/stable/rpi-wheezy/l/libtag1c2a/libtag1c2a_1.7.2-1_armhf.deb
|
||||
sudo dpkg -i libtag1c2a_1.7.2-1_armhf.deb
|
||||
rm libtag1c2a_1.7.2-1_armhf.deb
|
||||
|
||||
@ -5,7 +5,7 @@ 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
|
||||
<debian-install>` or :ref:`from the Arch Linux repository <arch-install>`, you can install Mopidy
|
||||
from PyPI using the ``pip`` installer.
|
||||
|
||||
If you are looking to contribute or wish to install from source using ``git``
|
||||
|
||||
@ -1,9 +1,23 @@
|
||||
************************************
|
||||
:mod:`mopidy.local` -- Local backend
|
||||
************************************
|
||||
*************************************
|
||||
:mod:`mopidy.local` --- Local backend
|
||||
*************************************
|
||||
|
||||
For details on how to use Mopidy's local backend, see :ref:`ext-local`.
|
||||
|
||||
.. automodule:: mopidy.local
|
||||
:synopsis: Local backend
|
||||
|
||||
|
||||
Local library API
|
||||
=================
|
||||
|
||||
.. autoclass:: mopidy.local.Library
|
||||
:members:
|
||||
|
||||
|
||||
Translation utils
|
||||
=================
|
||||
|
||||
.. automodule:: mopidy.local.translator
|
||||
:synopsis: Translators for local library extensions
|
||||
:members:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
*******************************
|
||||
:mod:`mopidy.mpd` -- MPD server
|
||||
*******************************
|
||||
********************************
|
||||
:mod:`mopidy.mpd` --- MPD server
|
||||
********************************
|
||||
|
||||
For details on how to use Mopidy's MPD server, see :ref:`ext-mpd`.
|
||||
|
||||
@ -71,6 +71,14 @@ Current playlist
|
||||
:members:
|
||||
|
||||
|
||||
Mounts and neighbors
|
||||
--------------------
|
||||
|
||||
.. automodule:: mopidy.mpd.protocol.mount
|
||||
:synopsis: MPD protocol: mounts and neighbors
|
||||
:members:
|
||||
|
||||
|
||||
Music database
|
||||
--------------
|
||||
|
||||
|
||||
@ -47,8 +47,7 @@ Creating releases
|
||||
|
||||
#. Push to GitHub::
|
||||
|
||||
git push
|
||||
git push --tags
|
||||
git push --follow-tags
|
||||
|
||||
#. Upload the previously built and tested sdist and bdist_wheel packages to
|
||||
PyPI::
|
||||
|
||||
@ -13,6 +13,20 @@ When Mopidy says ``MPD server running at [127.0.0.1]:6600`` it's ready to
|
||||
accept connections by any MPD client. Check out our non-exhaustive
|
||||
:doc:`/clients/mpd` list to find recommended clients.
|
||||
|
||||
Updating the library
|
||||
====================
|
||||
|
||||
To update the library, e.g. after audio files have changed, run::
|
||||
|
||||
mopidy local scan
|
||||
|
||||
Afterwards, to refresh the library (which is for now only available
|
||||
through the API) it is necessary to run::
|
||||
|
||||
curl -d '{"jsonrpc": "2.0", "id": 1, "method": "core.library.refresh"}' http://localhost:6680/mopidy/rpc
|
||||
|
||||
This makes the changes in the library visible to the clients.
|
||||
|
||||
|
||||
Stopping Mopidy
|
||||
===============
|
||||
@ -33,8 +47,8 @@ Init scripts
|
||||
<https://github.com/mopidy/mopidy/blob/debian/debian/mopidy.init>`_. For
|
||||
more details, see the :ref:`debian` section of the docs.
|
||||
|
||||
- The ``mopidy`` package in `Arch Linux AUR
|
||||
<https://aur.archlinux.org/packages/mopidy>`__ comes with a systemd init
|
||||
- The ``mopidy`` package in `Arch Linux
|
||||
<https://www.archlinux.org/packages/community/any/mopidy/>`__ comes with a systemd init
|
||||
script.
|
||||
|
||||
- A blog post by Benjamin Guillet explains how to `Daemonize Mopidy and Launch
|
||||
|
||||
@ -20,12 +20,6 @@ for free. We use their services for the following sites:
|
||||
|
||||
- Mailgun for sending emails from the Discourse forum.
|
||||
|
||||
- Hosting of the Jenkins CI server at https://ci.mopidy.com.
|
||||
|
||||
- Hosting of a Linux worker for https://ci.mopidy.com.
|
||||
|
||||
- Hosting of a Windows worker for https://ci.mopidy.com.
|
||||
|
||||
- CDN hosting at http://dl.mopidy.com, which is used to distribute Pi Musicbox
|
||||
images.
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@ from __future__ import absolute_import, print_function, unicode_literals
|
||||
|
||||
import platform
|
||||
import sys
|
||||
import textwrap
|
||||
import warnings
|
||||
|
||||
|
||||
@ -11,23 +10,8 @@ if not (2, 7) <= sys.version_info < (3,):
|
||||
'ERROR: Mopidy requires Python 2.7, but found %s.' %
|
||||
platform.python_version())
|
||||
|
||||
try:
|
||||
import gobject # noqa
|
||||
except ImportError:
|
||||
print(textwrap.dedent("""
|
||||
ERROR: The gobject Python package was not found.
|
||||
|
||||
Mopidy requires GStreamer (and GObject) to work. These are C libraries
|
||||
with a number of dependencies themselves, and cannot be installed with
|
||||
the regular Python tools like pip.
|
||||
|
||||
Please see http://docs.mopidy.com/en/latest/installation/ for
|
||||
instructions on how to install the required dependencies.
|
||||
"""))
|
||||
raise
|
||||
|
||||
|
||||
warnings.filterwarnings('ignore', 'could not open display')
|
||||
|
||||
|
||||
__version__ = '1.0.0'
|
||||
__version__ = '1.0.8'
|
||||
|
||||
@ -4,8 +4,23 @@ import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
try:
|
||||
import gobject # noqa
|
||||
except ImportError:
|
||||
print(textwrap.dedent("""
|
||||
ERROR: The gobject Python package was not found.
|
||||
|
||||
Mopidy requires GStreamer (and GObject) to work. These are C libraries
|
||||
with a number of dependencies themselves, and cannot be installed with
|
||||
the regular Python tools like pip.
|
||||
|
||||
Please see http://docs.mopidy.com/en/latest/installation/ for
|
||||
instructions on how to install the required dependencies.
|
||||
"""))
|
||||
raise
|
||||
|
||||
import gobject
|
||||
gobject.threads_init()
|
||||
|
||||
try:
|
||||
@ -26,7 +41,7 @@ sys.argv[1:] = []
|
||||
|
||||
|
||||
from mopidy import commands, config as config_lib, ext
|
||||
from mopidy.utils import encoding, log, path, process, versioning
|
||||
from mopidy.internal import encoding, log, path, process, versioning
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -51,21 +66,23 @@ def main():
|
||||
root_cmd.add_child('config', config_cmd)
|
||||
root_cmd.add_child('deps', deps_cmd)
|
||||
|
||||
installed_extensions = ext.load_extensions()
|
||||
extensions_data = ext.load_extensions()
|
||||
|
||||
for extension in installed_extensions:
|
||||
ext_cmd = extension.get_command()
|
||||
if ext_cmd:
|
||||
ext_cmd.set(extension=extension)
|
||||
root_cmd.add_child(extension.ext_name, ext_cmd)
|
||||
for data in extensions_data:
|
||||
if data.command: # TODO: check isinstance?
|
||||
data.command.set(extension=data.extension)
|
||||
root_cmd.add_child(data.extension.ext_name, data.command)
|
||||
|
||||
args = root_cmd.parse(mopidy_args)
|
||||
|
||||
create_file_structures_and_config(args, installed_extensions)
|
||||
create_file_structures_and_config(args, extensions_data)
|
||||
check_old_locations()
|
||||
|
||||
config, config_errors = config_lib.load(
|
||||
args.config_files, installed_extensions, args.config_overrides)
|
||||
args.config_files,
|
||||
[d.config_schema for d in extensions_data],
|
||||
[d.config_defaults for d in extensions_data],
|
||||
args.config_overrides)
|
||||
|
||||
verbosity_level = args.base_verbosity_level
|
||||
if args.verbosity_level:
|
||||
@ -75,8 +92,11 @@ def main():
|
||||
|
||||
extensions = {
|
||||
'validate': [], 'config': [], 'disabled': [], 'enabled': []}
|
||||
for extension in installed_extensions:
|
||||
if not ext.validate_extension(extension):
|
||||
for data in extensions_data:
|
||||
extension = data.extension
|
||||
|
||||
# TODO: factor out all of this to a helper that can be tested
|
||||
if not ext.validate_extension_data(data):
|
||||
config[extension.ext_name] = {'enabled': False}
|
||||
config_errors[extension.ext_name] = {
|
||||
'enabled': 'extension disabled by self check.'}
|
||||
@ -94,12 +114,13 @@ def main():
|
||||
else:
|
||||
extensions['enabled'].append(extension)
|
||||
|
||||
log_extension_info(installed_extensions, extensions['enabled'])
|
||||
log_extension_info([d.extension for d in extensions_data],
|
||||
extensions['enabled'])
|
||||
|
||||
# Config and deps commands are simply special cased for now.
|
||||
if args.command == config_cmd:
|
||||
return args.command.run(
|
||||
config, config_errors, installed_extensions)
|
||||
schemas = [d.config_schema for d in extensions_data]
|
||||
return args.command.run(config, config_errors, schemas)
|
||||
elif args.command == deps_cmd:
|
||||
return args.command.run()
|
||||
|
||||
@ -119,10 +140,19 @@ def main():
|
||||
return 1
|
||||
|
||||
for extension in extensions['enabled']:
|
||||
extension.setup(registry)
|
||||
try:
|
||||
extension.setup(registry)
|
||||
except Exception:
|
||||
# TODO: would be nice a transactional registry. But sadly this
|
||||
# is a bit tricky since our current API is giving out a mutable
|
||||
# list. We might however be able to replace this with a
|
||||
# collections.Sequence to provide a RO view.
|
||||
logger.exception('Extension %s failed during setup, this might'
|
||||
' have left the registry in a bad state.',
|
||||
extension.ext_name)
|
||||
|
||||
# Anything that wants to exit after this point must use
|
||||
# mopidy.utils.process.exit_process as actors can have been started.
|
||||
# mopidy.internal.process.exit_process as actors can have been started.
|
||||
try:
|
||||
return args.command.run(args, proxied_config)
|
||||
except NotImplementedError:
|
||||
|
||||
@ -8,7 +8,7 @@ import gobject
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst # noqa
|
||||
import gst.pbutils
|
||||
import gst.pbutils # noqa
|
||||
|
||||
import pykka
|
||||
|
||||
@ -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 deprecation, process
|
||||
from mopidy.internal import deprecation, process
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -166,10 +166,7 @@ class _Outputs(gst.Bin):
|
||||
logger.info('Audio output set to "%s"', description)
|
||||
|
||||
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)
|
||||
@ -199,16 +196,14 @@ class SoftwareMixer(object):
|
||||
|
||||
def set_volume(self, volume):
|
||||
self._element.set_property('volume', volume / 100.0)
|
||||
self._mixer.trigger_volume_changed(volume)
|
||||
self._mixer.trigger_volume_changed(self.get_volume())
|
||||
|
||||
def get_mute(self):
|
||||
return self._element.get_property('mute')
|
||||
|
||||
def set_mute(self, mute):
|
||||
result = self._element.set_property('mute', bool(mute))
|
||||
if result:
|
||||
self._mixer.trigger_mute_changed(bool(mute))
|
||||
return result
|
||||
self._element.set_property('mute', bool(mute))
|
||||
self._mixer.trigger_mute_changed(self.get_mute())
|
||||
|
||||
|
||||
class _Handler(object):
|
||||
|
||||
@ -1,20 +1,21 @@
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
from __future__ import (
|
||||
absolute_import, division, print_function, unicode_literals)
|
||||
|
||||
import collections
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst # noqa
|
||||
import gst.pbutils
|
||||
import gst.pbutils # noqa
|
||||
|
||||
from mopidy import exceptions
|
||||
from mopidy.audio import utils
|
||||
from mopidy.utils import encoding
|
||||
from mopidy.internal import encoding
|
||||
|
||||
_missing_plugin_desc = gst.pbutils.missing_plugin_message_get_description
|
||||
|
||||
_Result = collections.namedtuple(
|
||||
'Result', ('uri', 'tags', 'duration', 'seekable', 'mime'))
|
||||
'Result', ('uri', 'tags', 'duration', 'seekable', 'mime', 'playable'))
|
||||
|
||||
_RAW_AUDIO = gst.Caps(b'audio/x-raw-int; audio/x-raw-float')
|
||||
|
||||
@ -52,14 +53,14 @@ class Scanner(object):
|
||||
|
||||
try:
|
||||
_start_pipeline(pipeline)
|
||||
tags, mime = _process(pipeline, self._timeout_ms)
|
||||
tags, mime, have_audio = _process(pipeline, self._timeout_ms)
|
||||
duration = _query_duration(pipeline)
|
||||
seekable = _query_seekable(pipeline)
|
||||
finally:
|
||||
pipeline.set_state(gst.STATE_NULL)
|
||||
del pipeline
|
||||
|
||||
return _Result(uri, tags, duration, seekable, mime)
|
||||
return _Result(uri, tags, duration, seekable, mime, have_audio)
|
||||
|
||||
|
||||
# Turns out it's _much_ faster to just create a new pipeline for every as
|
||||
@ -71,31 +72,39 @@ def _setup_pipeline(uri, proxy_config=None):
|
||||
|
||||
typefind = gst.element_factory_make('typefind')
|
||||
decodebin = gst.element_factory_make('decodebin2')
|
||||
sink = gst.element_factory_make('fakesink')
|
||||
|
||||
pipeline = gst.element_factory_make('pipeline')
|
||||
for e in (src, typefind, decodebin, sink):
|
||||
for e in (src, typefind, decodebin):
|
||||
pipeline.add(e)
|
||||
gst.element_link_many(src, typefind, decodebin)
|
||||
|
||||
if proxy_config:
|
||||
utils.setup_proxy(src, proxy_config)
|
||||
|
||||
decodebin.set_property('caps', _RAW_AUDIO)
|
||||
decodebin.connect('pad-added', _pad_added, sink)
|
||||
typefind.connect('have-type', _have_type, decodebin)
|
||||
decodebin.connect('pad-added', _pad_added, pipeline)
|
||||
|
||||
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)
|
||||
struct = gst.Structure('have-type')
|
||||
struct['caps'] = caps.get_structure(0)
|
||||
element.get_bus().post(gst.message_new_application(element, struct))
|
||||
|
||||
|
||||
def _pad_added(element, pad, sink):
|
||||
return pad.link(sink.get_pad('sink'))
|
||||
def _pad_added(element, pad, pipeline):
|
||||
sink = gst.element_factory_make('fakesink')
|
||||
sink.set_property('sync', False)
|
||||
|
||||
pipeline.add(sink)
|
||||
sink.sync_state_with_parent()
|
||||
pad.link(sink.get_pad('sink'))
|
||||
|
||||
if pad.get_caps().is_subset(_RAW_AUDIO):
|
||||
struct = gst.Structure('have-audio')
|
||||
element.get_bus().post(gst.message_new_application(element, struct))
|
||||
|
||||
|
||||
def _start_pipeline(pipeline):
|
||||
@ -125,7 +134,7 @@ def _process(pipeline, timeout_ms):
|
||||
clock = pipeline.get_clock()
|
||||
bus = pipeline.get_bus()
|
||||
timeout = timeout_ms * gst.MSECOND
|
||||
tags, mime, missing_description = {}, None, None
|
||||
tags, mime, have_audio, missing_description = {}, None, False, None
|
||||
|
||||
types = (gst.MESSAGE_ELEMENT | gst.MESSAGE_APPLICATION | gst.MESSAGE_ERROR
|
||||
| gst.MESSAGE_EOS | gst.MESSAGE_ASYNC_DONE | gst.MESSAGE_TAG)
|
||||
@ -141,19 +150,22 @@ def _process(pipeline, timeout_ms):
|
||||
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
|
||||
if message.structure.get_name() == 'have-type':
|
||||
mime = message.structure['caps'].get_name()
|
||||
if mime.startswith('text/') or mime == 'application/xml':
|
||||
return tags, mime, have_audio
|
||||
elif message.structure.get_name() == 'have-audio':
|
||||
have_audio = True
|
||||
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
|
||||
return tags, mime, have_audio
|
||||
elif message.type == gst.MESSAGE_ASYNC_DONE:
|
||||
if message.src == pipeline:
|
||||
return tags, mime
|
||||
return tags, mime, have_audio
|
||||
elif message.type == gst.MESSAGE_TAG:
|
||||
taglist = message.parse_tag()
|
||||
# Note that this will only keep the last tag.
|
||||
@ -162,3 +174,28 @@ def _process(pipeline, timeout_ms):
|
||||
timeout -= clock.get_time() - start
|
||||
|
||||
raise exceptions.ScannerError('Timeout after %dms' % timeout_ms)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import os
|
||||
import sys
|
||||
|
||||
import gobject
|
||||
|
||||
from mopidy.internal import path
|
||||
|
||||
gobject.threads_init()
|
||||
|
||||
scanner = Scanner(5000)
|
||||
for uri in sys.argv[1:]:
|
||||
if not gst.uri_is_valid(uri):
|
||||
uri = path.path_to_uri(os.path.abspath(uri))
|
||||
try:
|
||||
result = scanner.scan(uri)
|
||||
for key in ('uri', 'mime', 'duration', 'playable', 'seekable'):
|
||||
print('%-20s %s' % (key, getattr(result, key)))
|
||||
print('tags')
|
||||
for tag, value in result.tags.items():
|
||||
print('%-20s %s' % (tag, value))
|
||||
except exceptions.ScannerError as error:
|
||||
print('%s: %s' % (uri, error))
|
||||
|
||||
@ -8,7 +8,7 @@ import pygst
|
||||
pygst.require('0.10')
|
||||
import gst # noqa
|
||||
|
||||
from mopidy import compat
|
||||
from mopidy import compat, httpclient
|
||||
from mopidy.models import Album, Artist, Track
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -142,11 +142,7 @@ def setup_proxy(element, config):
|
||||
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', httpclient.format_proxy(config, auth=False))
|
||||
element.set_property('proxy-id', config.get('username'))
|
||||
element.set_property('proxy-pw', config.get('password'))
|
||||
|
||||
|
||||
@ -58,6 +58,10 @@ class Backend(object):
|
||||
def has_playlists(self):
|
||||
return self.playlists is not None
|
||||
|
||||
def ping(self):
|
||||
"""Called to check if the actor is still alive."""
|
||||
return True
|
||||
|
||||
|
||||
class LibraryProvider(object):
|
||||
|
||||
@ -99,6 +103,9 @@ class LibraryProvider(object):
|
||||
*MAY be implemented by subclass.*
|
||||
|
||||
Default implementation will simply return an empty set.
|
||||
|
||||
Note that backends should always return an empty set for unexpected
|
||||
field types.
|
||||
"""
|
||||
return set()
|
||||
|
||||
@ -400,7 +407,7 @@ class BackendListener(listener.Listener):
|
||||
Marker interface for recipients of events sent by the backend actors.
|
||||
|
||||
Any Pykka actor that mixes in this class will receive calls to the methods
|
||||
defined here when the corresponding events happen in the core actor. This
|
||||
defined here when the corresponding events happen in a backend actor. This
|
||||
interface is used both for looking up what actors to notify of the events,
|
||||
and for providing default implementations for those listeners that are not
|
||||
interested in all events.
|
||||
|
||||
@ -2,6 +2,7 @@ from __future__ import absolute_import, print_function, unicode_literals
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
@ -10,10 +11,12 @@ import glib
|
||||
|
||||
import gobject
|
||||
|
||||
import pykka
|
||||
|
||||
from mopidy import config as config_lib, exceptions
|
||||
from mopidy.audio import Audio
|
||||
from mopidy.core import Core
|
||||
from mopidy.utils import deps, process, timer, versioning
|
||||
from mopidy.internal import deps, process, timer, versioning
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -230,7 +233,24 @@ class Command(object):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
# TODO: move out of this file
|
||||
@contextlib.contextmanager
|
||||
def _actor_error_handling(name):
|
||||
try:
|
||||
yield
|
||||
except exceptions.BackendError as exc:
|
||||
logger.error(
|
||||
'Backend (%s) initialization error: %s', name, exc.message)
|
||||
except exceptions.FrontendError as exc:
|
||||
logger.error(
|
||||
'Frontend (%s) initialization error: %s', name, exc.message)
|
||||
except exceptions.MixerError as exc:
|
||||
logger.error(
|
||||
'Mixer (%s) initialization error: %s', name, exc.message)
|
||||
except Exception:
|
||||
logger.exception('Got un-handled exception from %s', name)
|
||||
|
||||
|
||||
# TODO: move out of this utility class
|
||||
class RootCommand(Command):
|
||||
|
||||
def __init__(self):
|
||||
@ -277,9 +297,11 @@ class RootCommand(Command):
|
||||
mixer = None
|
||||
if mixer_class is not None:
|
||||
mixer = self.start_mixer(config, mixer_class)
|
||||
if mixer:
|
||||
self.configure_mixer(config, mixer)
|
||||
audio = self.start_audio(config, mixer)
|
||||
backends = self.start_backends(config, backend_classes, audio)
|
||||
core = self.start_core(mixer, backends, audio)
|
||||
core = self.start_core(config, mixer, backends, audio)
|
||||
self.start_frontends(config, frontend_classes, core)
|
||||
loop.run()
|
||||
except (exceptions.BackendError,
|
||||
@ -323,16 +345,15 @@ class RootCommand(Command):
|
||||
return selected_mixers[0]
|
||||
|
||||
def start_mixer(self, config, mixer_class):
|
||||
try:
|
||||
logger.info('Starting Mopidy mixer: %s', mixer_class.__name__)
|
||||
logger.info('Starting Mopidy mixer: %s', mixer_class.__name__)
|
||||
with _actor_error_handling(mixer_class.__name__):
|
||||
mixer = mixer_class.start(config=config).proxy()
|
||||
self.configure_mixer(config, mixer)
|
||||
return mixer
|
||||
except exceptions.MixerError as exc:
|
||||
logger.error(
|
||||
'Mixer (%s) initialization error: %s',
|
||||
mixer_class.__name__, exc.message)
|
||||
raise
|
||||
try:
|
||||
mixer.ping().get()
|
||||
return mixer
|
||||
except pykka.ActorDeadError as exc:
|
||||
logger.error('Actor died: %s', exc)
|
||||
return None
|
||||
|
||||
def configure_mixer(self, config, mixer):
|
||||
volume = config['audio']['mixer_volume']
|
||||
@ -353,22 +374,26 @@ class RootCommand(Command):
|
||||
|
||||
backends = []
|
||||
for backend_class in backend_classes:
|
||||
try:
|
||||
with _actor_error_handling(backend_class.__name__):
|
||||
with timer.time_logger(backend_class.__name__):
|
||||
backend = backend_class.start(
|
||||
config=config, audio=audio).proxy()
|
||||
backends.append(backend)
|
||||
except exceptions.BackendError as exc:
|
||||
logger.error(
|
||||
'Backend (%s) initialization error: %s',
|
||||
backend_class.__name__, exc.message)
|
||||
raise
|
||||
backends.append(backend)
|
||||
|
||||
# Block until all on_starts have finished, letting them run in parallel
|
||||
for backend in backends[:]:
|
||||
try:
|
||||
backend.ping().get()
|
||||
except pykka.ActorDeadError as exc:
|
||||
backends.remove(backend)
|
||||
logger.error('Actor died: %s', exc)
|
||||
|
||||
return backends
|
||||
|
||||
def start_core(self, mixer, backends, audio):
|
||||
def start_core(self, config, mixer, backends, audio):
|
||||
logger.info('Starting Mopidy core')
|
||||
return Core.start(mixer=mixer, backends=backends, audio=audio).proxy()
|
||||
return Core.start(
|
||||
config=config, mixer=mixer, backends=backends, audio=audio).proxy()
|
||||
|
||||
def start_frontends(self, config, frontend_classes, core):
|
||||
logger.info(
|
||||
@ -376,14 +401,9 @@ class RootCommand(Command):
|
||||
', '.join(f.__name__ for f in frontend_classes) or 'none')
|
||||
|
||||
for frontend_class in frontend_classes:
|
||||
try:
|
||||
with _actor_error_handling(frontend_class.__name__):
|
||||
with timer.time_logger(frontend_class.__name__):
|
||||
frontend_class.start(config=config, core=core)
|
||||
except exceptions.FrontendError as exc:
|
||||
logger.error(
|
||||
'Frontend (%s) initialization error: %s',
|
||||
frontend_class.__name__, exc.message)
|
||||
raise
|
||||
|
||||
def stop_frontends(self, frontend_classes):
|
||||
logger.info('Stopping Mopidy frontends')
|
||||
@ -415,8 +435,8 @@ class ConfigCommand(Command):
|
||||
super(ConfigCommand, self).__init__()
|
||||
self.set(base_verbosity_level=-1)
|
||||
|
||||
def run(self, config, errors, extensions):
|
||||
print(config_lib.format(config, extensions, errors))
|
||||
def run(self, config, errors, schemas):
|
||||
print(config_lib.format(config, schemas, errors))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@ -11,10 +11,14 @@ from mopidy.compat import configparser
|
||||
from mopidy.config import keyring
|
||||
from mopidy.config.schemas import * # noqa
|
||||
from mopidy.config.types import * # noqa
|
||||
from mopidy.utils import path, versioning
|
||||
from mopidy.internal import path, versioning
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_core_schema = ConfigSchema('core')
|
||||
# MPD supports at most 10k tracks, some clients segfault when this is exceeded.
|
||||
_core_schema['max_tracklist_length'] = Integer(minimum=1, maximum=10000)
|
||||
|
||||
_logging_schema = ConfigSchema('logging')
|
||||
_logging_schema['color'] = Boolean()
|
||||
_logging_schema['console_format'] = String()
|
||||
@ -43,8 +47,9 @@ _proxy_schema['password'] = Secret(optional=True)
|
||||
# NOTE: if multiple outputs ever comes something like LogLevelConfigSchema
|
||||
# _outputs_schema = config.AudioOutputConfigSchema()
|
||||
|
||||
_schemas = [_logging_schema, _loglevels_schema, _logcolors_schema,
|
||||
_audio_schema, _proxy_schema]
|
||||
_schemas = [
|
||||
_core_schema, _logging_schema, _loglevels_schema, _logcolors_schema,
|
||||
_audio_schema, _proxy_schema]
|
||||
|
||||
_INITIAL_HELP = """
|
||||
# For further information about options in this file see:
|
||||
@ -65,24 +70,20 @@ def read(config_file):
|
||||
return filehandle.read()
|
||||
|
||||
|
||||
def load(files, extensions, overrides):
|
||||
# Helper to get configs, as the rest of our config system should not need
|
||||
# to know about extensions.
|
||||
def load(files, ext_schemas, ext_defaults, overrides):
|
||||
config_dir = os.path.dirname(__file__)
|
||||
defaults = [read(os.path.join(config_dir, 'default.conf'))]
|
||||
defaults.extend(e.get_default_config() for e in extensions)
|
||||
defaults.extend(ext_defaults)
|
||||
raw_config = _load(files, defaults, keyring.fetch() + (overrides or []))
|
||||
|
||||
schemas = _schemas[:]
|
||||
schemas.extend(e.get_config_schema() for e in extensions)
|
||||
schemas.extend(ext_schemas)
|
||||
return _validate(raw_config, schemas)
|
||||
|
||||
|
||||
def format(config, extensions, comments=None, display=True):
|
||||
# Helper to format configs, as the rest of our config system should not
|
||||
# need to know about extensions.
|
||||
def format(config, ext_schemas, comments=None, display=True):
|
||||
schemas = _schemas[:]
|
||||
schemas.extend(e.get_config_schema() for e in extensions)
|
||||
schemas.extend(ext_schemas)
|
||||
return _format(config, comments or {}, schemas, display, False)
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
[core]
|
||||
max_tracklist_length = 10000
|
||||
|
||||
[logging]
|
||||
color = true
|
||||
console_format = %(levelname)-8s %(message)s
|
||||
|
||||
@ -6,7 +6,7 @@ import socket
|
||||
|
||||
from mopidy import compat
|
||||
from mopidy.config import validators
|
||||
from mopidy.utils import log, path
|
||||
from mopidy.internal import log, path
|
||||
|
||||
|
||||
def decode(value):
|
||||
|
||||
@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import collections
|
||||
import itertools
|
||||
import logging
|
||||
|
||||
import pykka
|
||||
|
||||
@ -14,8 +15,11 @@ 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
|
||||
from mopidy.internal import versioning
|
||||
from mopidy.internal.deprecation import deprecated_property
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Core(
|
||||
@ -23,32 +27,28 @@ class Core(
|
||||
mixer.MixerListener):
|
||||
|
||||
library = None
|
||||
"""The library controller. An instance of
|
||||
:class:`mopidy.core.LibraryController`."""
|
||||
"""An instance of :class:`~mopidy.core.LibraryController`"""
|
||||
|
||||
history = None
|
||||
"""The playback history controller. An instance of
|
||||
:class:`mopidy.core.HistoryController`."""
|
||||
"""An instance of :class:`~mopidy.core.HistoryController`"""
|
||||
|
||||
mixer = None
|
||||
"""The mixer controller. An instance of
|
||||
:class:`mopidy.core.MixerController`."""
|
||||
"""An instance of :class:`~mopidy.core.MixerController`"""
|
||||
|
||||
playback = None
|
||||
"""The playback controller. An instance of
|
||||
:class:`mopidy.core.PlaybackController`."""
|
||||
"""An instance of :class:`~mopidy.core.PlaybackController`"""
|
||||
|
||||
playlists = None
|
||||
"""The playlists controller. An instance of
|
||||
:class:`mopidy.core.PlaylistsController`."""
|
||||
"""An instance of :class:`~mopidy.core.PlaylistsController`"""
|
||||
|
||||
tracklist = None
|
||||
"""The tracklist controller. An instance of
|
||||
:class:`mopidy.core.TracklistController`."""
|
||||
"""An instance of :class:`~mopidy.core.TracklistController`"""
|
||||
|
||||
def __init__(self, mixer=None, backends=None, audio=None):
|
||||
def __init__(self, config=None, mixer=None, backends=None, audio=None):
|
||||
super(Core, self).__init__()
|
||||
|
||||
self._config = config
|
||||
|
||||
self.backends = Backends(backends)
|
||||
|
||||
self.library = LibraryController(backends=self.backends, core=self)
|
||||
@ -150,10 +150,15 @@ class Backends(list):
|
||||
return b.actor_ref.actor_class.__name__
|
||||
|
||||
for b in backends:
|
||||
has_library = b.has_library().get()
|
||||
has_library_browse = b.has_library_browse().get()
|
||||
has_playback = b.has_playback().get()
|
||||
has_playlists = b.has_playlists().get()
|
||||
try:
|
||||
has_library = b.has_library().get()
|
||||
has_library_browse = b.has_library_browse().get()
|
||||
has_playback = b.has_playback().get()
|
||||
has_playlists = b.has_playlists().get()
|
||||
except Exception:
|
||||
self.remove(b)
|
||||
logger.exception('Fetching backend info for %s failed',
|
||||
b.actor_ref.actor_class.__name__)
|
||||
|
||||
for scheme in b.uri_schemes.get():
|
||||
assert scheme not in backends_by_scheme, (
|
||||
|
||||
@ -11,6 +11,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HistoryController(object):
|
||||
pykka_traversable = True
|
||||
|
||||
def __init__(self):
|
||||
self._history = []
|
||||
|
||||
@ -1,18 +1,32 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import collections
|
||||
import contextlib
|
||||
import logging
|
||||
import operator
|
||||
import urlparse
|
||||
|
||||
import pykka
|
||||
|
||||
from mopidy.utils import deprecation
|
||||
from mopidy import compat, exceptions, models
|
||||
from mopidy.internal import deprecation, validation
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _backend_error_handling(backend, reraise=None):
|
||||
try:
|
||||
yield
|
||||
except exceptions.ValidationError as e:
|
||||
logger.error('%s backend returned bad data: %s',
|
||||
backend.actor_ref.actor_class.__name__, e)
|
||||
except Exception as e:
|
||||
if reraise and isinstance(e, reraise):
|
||||
raise
|
||||
logger.exception('%s backend caused an exception.',
|
||||
backend.actor_ref.actor_class.__name__)
|
||||
|
||||
|
||||
class LibraryController(object):
|
||||
pykka_traversable = True
|
||||
|
||||
@ -41,8 +55,8 @@ class LibraryController(object):
|
||||
Browse directories and tracks at the given ``uri``.
|
||||
|
||||
``uri`` is a string which represents some directory belonging to a
|
||||
backend. To get the intial root directories for backends pass None as
|
||||
the URI.
|
||||
backend. To get the intial root directories for backends pass
|
||||
:class:`None` as the URI.
|
||||
|
||||
Returns a list of :class:`mopidy.models.Ref` objects for the
|
||||
directories and tracks at the given ``uri``.
|
||||
@ -70,15 +84,36 @@ class LibraryController(object):
|
||||
.. versionadded:: 0.18
|
||||
"""
|
||||
if uri is None:
|
||||
backends = self.backends.with_library_browse.values()
|
||||
unique_dirs = {b.library.root_directory.get() for b in backends}
|
||||
return sorted(unique_dirs, key=operator.attrgetter('name'))
|
||||
return self._roots()
|
||||
elif not uri.strip():
|
||||
return []
|
||||
validation.check_uri(uri)
|
||||
return self._browse(uri)
|
||||
|
||||
def _roots(self):
|
||||
directories = set()
|
||||
backends = self.backends.with_library_browse.values()
|
||||
futures = {b: b.library.root_directory for b in backends}
|
||||
for backend, future in futures.items():
|
||||
with _backend_error_handling(backend):
|
||||
root = future.get()
|
||||
validation.check_instance(root, models.Ref)
|
||||
directories.add(root)
|
||||
return sorted(directories, key=operator.attrgetter('name'))
|
||||
|
||||
def _browse(self, uri):
|
||||
scheme = urlparse.urlparse(uri).scheme
|
||||
backend = self.backends.with_library_browse.get(scheme)
|
||||
|
||||
if not backend:
|
||||
return []
|
||||
return backend.library.browse(uri).get()
|
||||
|
||||
with _backend_error_handling(backend):
|
||||
result = backend.library.browse(uri).get()
|
||||
validation.check_instances(result, models.Ref)
|
||||
return result
|
||||
|
||||
return []
|
||||
|
||||
def get_distinct(self, field, query=None):
|
||||
"""
|
||||
@ -88,19 +123,26 @@ class LibraryController(object):
|
||||
protocol supports in a more sane fashion. Other frontends are not
|
||||
recommended to use this method.
|
||||
|
||||
:param string field: One of ``artist``, ``albumartist``, ``album``,
|
||||
``composer``, ``performer``, ``date``or ``genre``.
|
||||
:param string field: One of ``track``, ``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.
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
futures = [b.library.get_distinct(field, query)
|
||||
for b in self.backends.with_library.values()]
|
||||
validation.check_choice(field, validation.DISTINCT_FIELDS)
|
||||
query is None or validation.check_query(query) # TODO: normalize?
|
||||
|
||||
result = set()
|
||||
for r in pykka.get_all(futures):
|
||||
result.update(r)
|
||||
futures = {b: b.library.get_distinct(field, query)
|
||||
for b in self.backends.with_library.values()}
|
||||
for backend, future in futures.items():
|
||||
with _backend_error_handling(backend):
|
||||
values = future.get()
|
||||
if values is not None:
|
||||
validation.check_instances(values, compat.text_type)
|
||||
result.update(values)
|
||||
return result
|
||||
|
||||
def get_images(self, uris):
|
||||
@ -113,20 +155,31 @@ class LibraryController(object):
|
||||
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
|
||||
:param uris: list of URIs to find images for
|
||||
:type uris: list of string
|
||||
:rtype: {uri: tuple of :class:`mopidy.models.Image`}
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
futures = [
|
||||
backend.library.get_images(backend_uris)
|
||||
validation.check_uris(uris)
|
||||
|
||||
futures = {
|
||||
backend: backend.library.get_images(backend_uris)
|
||||
for (backend, backend_uris)
|
||||
in self._get_backends_to_uris(uris).items() if backend_uris]
|
||||
in self._get_backends_to_uris(uris).items() if backend_uris}
|
||||
|
||||
results = {uri: tuple() for uri in uris}
|
||||
for r in pykka.get_all(futures):
|
||||
for uri, images in r.items():
|
||||
results[uri] += tuple(images)
|
||||
for backend, future in futures.items():
|
||||
with _backend_error_handling(backend):
|
||||
if future.get() is None:
|
||||
continue
|
||||
validation.check_instance(future.get(), collections.Mapping)
|
||||
for uri, images in future.get().items():
|
||||
if uri not in uris:
|
||||
raise exceptions.ValidationError(
|
||||
'Got unknown image URI: %s' % uri)
|
||||
validation.check_instances(images, models.Image)
|
||||
results[uri] += tuple(images)
|
||||
return results
|
||||
|
||||
def find_exact(self, query=None, uris=None, **kwargs):
|
||||
@ -140,7 +193,7 @@ class LibraryController(object):
|
||||
|
||||
def lookup(self, uri=None, uris=None):
|
||||
"""
|
||||
Lookup the given URI.
|
||||
Lookup the given URIs.
|
||||
|
||||
If the URI expands to multiple tracks, the returned list will contain
|
||||
them all.
|
||||
@ -150,7 +203,7 @@ class LibraryController(object):
|
||||
: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.
|
||||
{uri: list of :class:`mopidy.models.Track`} if uris was set.
|
||||
|
||||
.. versionadded:: 1.0
|
||||
The ``uris`` argument.
|
||||
@ -158,11 +211,11 @@ class LibraryController(object):
|
||||
.. deprecated:: 1.0
|
||||
The ``uri`` argument. Use ``uris`` instead.
|
||||
"""
|
||||
none_set = uri is None and uris is None
|
||||
both_set = uri is not None and uris is not None
|
||||
if sum(o is not None for o in [uri, uris]) != 1:
|
||||
raise ValueError('Exactly one of "uri" or "uris" must be set')
|
||||
|
||||
if none_set or both_set:
|
||||
raise ValueError("One of 'uri' or 'uris' must be set")
|
||||
uris is None or validation.check_uris(uris)
|
||||
uri is None or validation.check_uri(uri)
|
||||
|
||||
if uri:
|
||||
deprecation.warn('core.library.lookup:uri_arg')
|
||||
@ -171,23 +224,23 @@ class LibraryController(object):
|
||||
uris = [uri]
|
||||
|
||||
futures = {}
|
||||
result = {}
|
||||
backends = self._get_backends_to_uris(uris)
|
||||
results = {u: [] for u in 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 backend, backend_uris in self._get_backends_to_uris(uris).items():
|
||||
for u in backend_uris:
|
||||
futures[(backend, u)] = backend.library.lookup(u)
|
||||
|
||||
for u in uris:
|
||||
if u in futures:
|
||||
result[u] = futures[u].get()
|
||||
else:
|
||||
result[u] = []
|
||||
for (backend, u), future in futures.items():
|
||||
with _backend_error_handling(backend):
|
||||
result = future.get()
|
||||
if result is not None:
|
||||
validation.check_instances(result, models.Track)
|
||||
results[u] = result
|
||||
|
||||
if uri:
|
||||
return result[uri]
|
||||
return result
|
||||
return results[uri]
|
||||
return results
|
||||
|
||||
def refresh(self, uri=None):
|
||||
"""
|
||||
@ -196,14 +249,22 @@ class LibraryController(object):
|
||||
:param uri: directory or track URI
|
||||
:type uri: string
|
||||
"""
|
||||
if uri is not None:
|
||||
backend = self._get_backend(uri)
|
||||
if backend:
|
||||
backend.library.refresh(uri).get()
|
||||
else:
|
||||
futures = [b.library.refresh(uri)
|
||||
for b in self.backends.with_library.values()]
|
||||
pykka.get_all(futures)
|
||||
uri is None or validation.check_uri(uri)
|
||||
|
||||
futures = {}
|
||||
backends = {}
|
||||
uri_scheme = urlparse.urlparse(uri).scheme if uri else None
|
||||
|
||||
for backend_scheme, backend in self.backends.with_playlists.items():
|
||||
backends.setdefault(backend, set()).add(backend_scheme)
|
||||
|
||||
for backend, backend_schemes in backends.items():
|
||||
if uri_scheme is None or uri_scheme in backend_schemes:
|
||||
futures[backend] = backend.library.refresh(uri)
|
||||
|
||||
for backend, future in futures.items():
|
||||
with _backend_error_handling(backend):
|
||||
future.get()
|
||||
|
||||
def search(self, query=None, uris=None, exact=False, **kwargs):
|
||||
"""
|
||||
@ -217,26 +278,27 @@ class LibraryController(object):
|
||||
|
||||
# Returns results matching 'a' in any backend
|
||||
search({'any': ['a']})
|
||||
search(any=['a'])
|
||||
|
||||
# Returns results matching artist 'xyz' in any backend
|
||||
search({'artist': ['xyz']})
|
||||
search(artist=['xyz'])
|
||||
|
||||
# Returns results matching 'a' and 'b' and artist 'xyz' in any
|
||||
# backend
|
||||
search({'any': ['a', 'b'], 'artist': ['xyz']})
|
||||
search(any=['a', 'b'], artist=['xyz'])
|
||||
|
||||
# Returns results matching 'a' if within the given URI roots
|
||||
# "file:///media/music" and "spotify:"
|
||||
search({'any': ['a']}, uris=['file:///media/music', 'spotify:'])
|
||||
search(any=['a'], uris=['file:///media/music', 'spotify:'])
|
||||
|
||||
# Returns results matching artist 'xyz' and 'abc' in any backend
|
||||
search({'artist': ['xyz', 'abc']})
|
||||
|
||||
: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`
|
||||
:type uris: list of string or :class:`None`
|
||||
:param exact: if the search should use exact matching
|
||||
:type exact: :class:`bool`
|
||||
:rtype: list of :class:`mopidy.models.SearchResult`
|
||||
|
||||
.. versionadded:: 1.0
|
||||
@ -253,6 +315,10 @@ class LibraryController(object):
|
||||
"""
|
||||
query = _normalize_query(query or kwargs)
|
||||
|
||||
uris is None or validation.check_uris(uris)
|
||||
query is None or validation.check_query(query)
|
||||
validation.check_boolean(exact)
|
||||
|
||||
if kwargs:
|
||||
deprecation.warn('core.library.search:kwargs_query')
|
||||
|
||||
@ -264,20 +330,31 @@ class LibraryController(object):
|
||||
futures[backend] = backend.library.search(
|
||||
query=query, uris=backend_uris, exact=exact)
|
||||
|
||||
# Some of our tests check for LookupError to catch bad queries. This is
|
||||
# silly and should be replaced with query validation before passing it
|
||||
# to the backends.
|
||||
reraise = (TypeError, LookupError)
|
||||
|
||||
results = []
|
||||
for backend, future in futures.items():
|
||||
try:
|
||||
results.append(future.get())
|
||||
with _backend_error_handling(backend, reraise=reraise):
|
||||
result = future.get()
|
||||
if result is not None:
|
||||
validation.check_instance(result, models.SearchResult)
|
||||
results.append(result)
|
||||
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]
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _normalize_query(query):
|
||||
broken_client = False
|
||||
# TODO: this breaks if query is not a dictionary like object...
|
||||
for (field, values) in query.items():
|
||||
if isinstance(values, basestring):
|
||||
broken_client = True
|
||||
|
||||
@ -1,11 +1,27 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
from mopidy import exceptions
|
||||
from mopidy.internal import validation
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _mixer_error_handling(mixer):
|
||||
try:
|
||||
yield
|
||||
except exceptions.ValidationError as e:
|
||||
logger.error('%s mixer returned bad data: %s',
|
||||
mixer.actor_ref.actor_class.__name__, e)
|
||||
except Exception:
|
||||
logger.exception('%s mixer caused an exception.',
|
||||
mixer.actor_ref.actor_class.__name__)
|
||||
|
||||
|
||||
class MixerController(object):
|
||||
pykka_traversable = True
|
||||
|
||||
@ -19,8 +35,15 @@ class MixerController(object):
|
||||
|
||||
The volume scale is linear.
|
||||
"""
|
||||
if self._mixer is not None:
|
||||
return self._mixer.get_volume().get()
|
||||
if self._mixer is None:
|
||||
return None
|
||||
|
||||
with _mixer_error_handling(self._mixer):
|
||||
volume = self._mixer.get_volume().get()
|
||||
volume is None or validation.check_integer(volume, min=0, max=100)
|
||||
return volume
|
||||
|
||||
return None
|
||||
|
||||
def set_volume(self, volume):
|
||||
"""Set the volume.
|
||||
@ -31,10 +54,17 @@ class MixerController(object):
|
||||
|
||||
Returns :class:`True` if call is successful, otherwise :class:`False`.
|
||||
"""
|
||||
validation.check_integer(volume, min=0, max=100)
|
||||
|
||||
if self._mixer is None:
|
||||
return False
|
||||
else:
|
||||
return self._mixer.set_volume(volume).get()
|
||||
return False # TODO: 2.0 return None
|
||||
|
||||
with _mixer_error_handling(self._mixer):
|
||||
result = self._mixer.set_volume(volume).get()
|
||||
validation.check_instance(result, bool)
|
||||
return result
|
||||
|
||||
return False
|
||||
|
||||
def get_mute(self):
|
||||
"""Get mute state.
|
||||
@ -42,8 +72,15 @@ class MixerController(object):
|
||||
:class:`True` if muted, :class:`False` unmuted, :class:`None` if
|
||||
unknown.
|
||||
"""
|
||||
if self._mixer is not None:
|
||||
return self._mixer.get_mute().get()
|
||||
if self._mixer is None:
|
||||
return None
|
||||
|
||||
with _mixer_error_handling(self._mixer):
|
||||
mute = self._mixer.get_mute().get()
|
||||
mute is None or validation.check_instance(mute, bool)
|
||||
return mute
|
||||
|
||||
return None
|
||||
|
||||
def set_mute(self, mute):
|
||||
"""Set mute state.
|
||||
@ -52,7 +89,13 @@ class MixerController(object):
|
||||
|
||||
Returns :class:`True` if call is successful, otherwise :class:`False`.
|
||||
"""
|
||||
validation.check_boolean(mute)
|
||||
if self._mixer is None:
|
||||
return False
|
||||
else:
|
||||
return self._mixer.set_mute(bool(mute)).get()
|
||||
return False # TODO: 2.0 return None
|
||||
|
||||
with _mixer_error_handling(self._mixer):
|
||||
result = self._mixer.set_mute(bool(mute)).get()
|
||||
validation.check_instance(result, bool)
|
||||
return result
|
||||
|
||||
return False
|
||||
|
||||
@ -3,9 +3,10 @@ from __future__ import absolute_import, unicode_literals
|
||||
import logging
|
||||
import urlparse
|
||||
|
||||
from mopidy import models
|
||||
from mopidy.audio import PlaybackState
|
||||
from mopidy.core import listener
|
||||
from mopidy.utils import deprecation
|
||||
from mopidy.internal import deprecation, validation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -64,9 +65,7 @@ class PlaybackController(object):
|
||||
|
||||
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
|
||||
return getattr(self.get_current_tl_track(), 'track', None)
|
||||
|
||||
current_track = deprecation.deprecated_property(get_current_track)
|
||||
"""
|
||||
@ -74,6 +73,18 @@ class PlaybackController(object):
|
||||
Use :meth:`get_current_track` instead.
|
||||
"""
|
||||
|
||||
def get_current_tlid(self):
|
||||
"""
|
||||
Get the currently playing or selected TLID.
|
||||
|
||||
Extracted from :meth:`get_current_tl_track` for convenience.
|
||||
|
||||
Returns a :class:`int` or :class:`None`.
|
||||
|
||||
.. versionadded:: 1.1
|
||||
"""
|
||||
return getattr(self.get_current_tl_track(), 'tlid', None)
|
||||
|
||||
def get_stream_title(self):
|
||||
"""Get the current stream title or :class:`None`."""
|
||||
return self._stream_title
|
||||
@ -100,6 +111,8 @@ class PlaybackController(object):
|
||||
"PAUSED" -> "PLAYING" [ label="resume" ]
|
||||
"PAUSED" -> "STOPPED" [ label="stop" ]
|
||||
"""
|
||||
validation.check_choice(new_state, validation.PLAYBACK_STATES)
|
||||
|
||||
(old_state, self._state) = (self.get_state(), new_state)
|
||||
logger.debug('Changing state: %s -> %s', old_state, new_state)
|
||||
|
||||
@ -265,17 +278,37 @@ class PlaybackController(object):
|
||||
self.set_state(PlaybackState.PAUSED)
|
||||
self._trigger_track_playback_paused()
|
||||
|
||||
def play(self, tl_track=None):
|
||||
def play(self, tl_track=None, tlid=None):
|
||||
"""
|
||||
Play the given track, or if the given track is :class:`None`, play the
|
||||
currently active track.
|
||||
Play the given track, or if the given tl_track and tlid is
|
||||
:class:`None`, play the currently active track.
|
||||
|
||||
Note that the track **must** already be in the tracklist.
|
||||
|
||||
:param tl_track: track to play
|
||||
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
:param tlid: TLID of the track to play
|
||||
:type tlid: :class:`int` or :class:`None`
|
||||
"""
|
||||
self._play(tl_track, on_error_step=1)
|
||||
if sum(o is not None for o in [tl_track, tlid]) > 1:
|
||||
raise ValueError('At most one of "tl_track" and "tlid" may be set')
|
||||
|
||||
tl_track is None or validation.check_instance(tl_track, models.TlTrack)
|
||||
tlid is None or validation.check_integer(tlid, min=0)
|
||||
|
||||
if tl_track:
|
||||
deprecation.warn('core.playback.play:tl_track_kwarg', pending=True)
|
||||
|
||||
self._play(tl_track=tl_track, tlid=tlid, on_error_step=1)
|
||||
|
||||
def _play(self, tl_track=None, tlid=None, on_error_step=1):
|
||||
if tl_track is None and tlid is not None:
|
||||
for tl_track in self.core.tracklist.get_tl_tracks():
|
||||
if tl_track.tlid == tlid:
|
||||
break
|
||||
else:
|
||||
tl_track = None
|
||||
|
||||
def _play(self, tl_track=None, on_error_step=1):
|
||||
if tl_track is None:
|
||||
if self.get_state() == PlaybackState.PAUSED:
|
||||
return self.resume()
|
||||
@ -382,6 +415,13 @@ class PlaybackController(object):
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
# TODO: seek needs to take pending tracks into account :(
|
||||
validation.check_integer(time_position)
|
||||
|
||||
if time_position < 0:
|
||||
logger.debug(
|
||||
'Client seeked to negative position. Seeking to zero.')
|
||||
time_position = 0
|
||||
|
||||
if not self.core.tracklist.tracks:
|
||||
return False
|
||||
|
||||
|
||||
@ -1,17 +1,31 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import urlparse
|
||||
|
||||
import pykka
|
||||
|
||||
from mopidy import exceptions
|
||||
from mopidy.core import listener
|
||||
from mopidy.models import Playlist
|
||||
from mopidy.utils import deprecation
|
||||
from mopidy.internal import deprecation, validation
|
||||
from mopidy.models import Playlist, Ref
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _backend_error_handling(backend, reraise=None):
|
||||
try:
|
||||
yield
|
||||
except exceptions.ValidationError as e:
|
||||
logger.error('%s backend returned bad data: %s',
|
||||
backend.actor_ref.actor_class.__name__, e)
|
||||
except Exception as e:
|
||||
if reraise and isinstance(e, reraise):
|
||||
raise
|
||||
logger.exception('%s backend caused an exception.',
|
||||
backend.actor_ref.actor_class.__name__)
|
||||
|
||||
|
||||
class PlaylistsController(object):
|
||||
pykka_traversable = True
|
||||
|
||||
@ -32,14 +46,19 @@ class PlaylistsController(object):
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
futures = {
|
||||
b.actor_ref.actor_class.__name__: b.playlists.as_list()
|
||||
for b in set(self.backends.with_playlists.values())}
|
||||
backend: backend.playlists.as_list()
|
||||
for backend in set(self.backends.with_playlists.values())}
|
||||
|
||||
results = []
|
||||
for backend_name, future in futures.items():
|
||||
for b, future in futures.items():
|
||||
try:
|
||||
results.extend(future.get())
|
||||
with _backend_error_handling(b, reraise=NotImplementedError):
|
||||
playlists = future.get()
|
||||
if playlists is not None:
|
||||
validation.check_instances(playlists, Ref)
|
||||
results.extend(playlists)
|
||||
except NotImplementedError:
|
||||
backend_name = b.actor_ref.actor_class.__name__
|
||||
logger.warning(
|
||||
'%s does not implement playlists.as_list(). '
|
||||
'Please upgrade it.', backend_name)
|
||||
@ -60,10 +79,20 @@ class PlaylistsController(object):
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
validation.check_uri(uri)
|
||||
|
||||
uri_scheme = urlparse.urlparse(uri).scheme
|
||||
backend = self.backends.with_playlists.get(uri_scheme, None)
|
||||
if backend:
|
||||
return backend.playlists.get_items(uri).get()
|
||||
|
||||
if not backend:
|
||||
return None
|
||||
|
||||
with _backend_error_handling(backend):
|
||||
items = backend.playlists.get_items(uri).get()
|
||||
items is None or validation.check_instances(items, Ref)
|
||||
return items
|
||||
|
||||
return None
|
||||
|
||||
def get_playlists(self, include_tracks=True):
|
||||
"""
|
||||
@ -88,7 +117,7 @@ class PlaylistsController(object):
|
||||
# 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)
|
||||
playlists[r.uri].replace(name=r.name)
|
||||
for r in playlist_refs if playlists[r.uri] is not None]
|
||||
else:
|
||||
return [
|
||||
@ -116,16 +145,23 @@ class PlaylistsController(object):
|
||||
:type name: string
|
||||
:param uri_scheme: use the backend matching the URI scheme
|
||||
:type uri_scheme: string
|
||||
:rtype: :class:`mopidy.models.Playlist`
|
||||
:rtype: :class:`mopidy.models.Playlist` or :class:`None`
|
||||
"""
|
||||
if uri_scheme in self.backends.with_playlists:
|
||||
backend = self.backends.with_playlists[uri_scheme]
|
||||
backends = [self.backends.with_playlists[uri_scheme]]
|
||||
else:
|
||||
# TODO: this fallback looks suspicious
|
||||
backend = list(self.backends.with_playlists.values())[0]
|
||||
playlist = backend.playlists.create(name).get()
|
||||
listener.CoreListener.send('playlist_changed', playlist=playlist)
|
||||
return playlist
|
||||
backends = self.backends.with_playlists.values()
|
||||
|
||||
for backend in backends:
|
||||
with _backend_error_handling(backend):
|
||||
result = backend.playlists.create(name).get()
|
||||
if result is None:
|
||||
continue
|
||||
validation.check_instance(result, Playlist)
|
||||
listener.CoreListener.send('playlist_changed', playlist=result)
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
def delete(self, uri):
|
||||
"""
|
||||
@ -137,10 +173,18 @@ class PlaylistsController(object):
|
||||
:param uri: URI of the playlist to delete
|
||||
:type uri: string
|
||||
"""
|
||||
validation.check_uri(uri)
|
||||
|
||||
uri_scheme = urlparse.urlparse(uri).scheme
|
||||
backend = self.backends.with_playlists.get(uri_scheme, None)
|
||||
if backend:
|
||||
if not backend:
|
||||
return
|
||||
|
||||
with _backend_error_handling(backend):
|
||||
backend.playlists.delete(uri).get()
|
||||
# TODO: emit playlist changed?
|
||||
|
||||
# TODO: return value?
|
||||
|
||||
def filter(self, criteria=None, **kwargs):
|
||||
"""
|
||||
@ -150,15 +194,12 @@ class PlaylistsController(object):
|
||||
|
||||
# Returns track with name 'a'
|
||||
filter({'name': 'a'})
|
||||
filter(name='a')
|
||||
|
||||
# Returns track with URI 'xyz'
|
||||
filter({'uri': 'xyz'})
|
||||
filter(uri='xyz')
|
||||
|
||||
# Returns track with name 'a' and URI 'xyz'
|
||||
filter({'name': 'a', 'uri': 'xyz'})
|
||||
filter(name='a', uri='xyz')
|
||||
|
||||
:param criteria: one or more criteria to match by
|
||||
:type criteria: dict
|
||||
@ -170,7 +211,10 @@ class PlaylistsController(object):
|
||||
deprecation.warn('core.playlists.filter')
|
||||
|
||||
criteria = criteria or kwargs
|
||||
matches = self.playlists
|
||||
validation.check_query(
|
||||
criteria, validation.PLAYLIST_FIELDS, list_values=False)
|
||||
|
||||
matches = self.playlists # TODO: stop using self playlists
|
||||
for (key, value) in criteria.iteritems():
|
||||
matches = filter(lambda p: getattr(p, key) == value, matches)
|
||||
return matches
|
||||
@ -186,11 +230,18 @@ class PlaylistsController(object):
|
||||
"""
|
||||
uri_scheme = urlparse.urlparse(uri).scheme
|
||||
backend = self.backends.with_playlists.get(uri_scheme, None)
|
||||
if backend:
|
||||
return backend.playlists.lookup(uri).get()
|
||||
else:
|
||||
if not backend:
|
||||
return None
|
||||
|
||||
with _backend_error_handling(backend):
|
||||
playlist = backend.playlists.lookup(uri).get()
|
||||
playlist is None or validation.check_instance(playlist, Playlist)
|
||||
return playlist
|
||||
|
||||
return None
|
||||
|
||||
# TODO: there is an inconsistency between library.refresh(uri) and this
|
||||
# call, not sure how to sort this out.
|
||||
def refresh(self, uri_scheme=None):
|
||||
"""
|
||||
Refresh the playlists in :attr:`playlists`.
|
||||
@ -203,16 +254,26 @@ class PlaylistsController(object):
|
||||
:param uri_scheme: limit to the backend matching the URI scheme
|
||||
:type uri_scheme: string
|
||||
"""
|
||||
if uri_scheme is None:
|
||||
futures = [b.playlists.refresh()
|
||||
for b in self.backends.with_playlists.values()]
|
||||
pykka.get_all(futures)
|
||||
# TODO: check: uri_scheme is None or uri_scheme?
|
||||
|
||||
futures = {}
|
||||
backends = {}
|
||||
playlists_loaded = False
|
||||
|
||||
for backend_scheme, backend in self.backends.with_playlists.items():
|
||||
backends.setdefault(backend, set()).add(backend_scheme)
|
||||
|
||||
for backend, backend_schemes in backends.items():
|
||||
if uri_scheme is None or uri_scheme in backend_schemes:
|
||||
futures[backend] = backend.playlists.refresh()
|
||||
|
||||
for backend, future in futures.items():
|
||||
with _backend_error_handling(backend):
|
||||
future.get()
|
||||
playlists_loaded = True
|
||||
|
||||
if playlists_loaded:
|
||||
listener.CoreListener.send('playlists_loaded')
|
||||
else:
|
||||
backend = self.backends.with_playlists.get(uri_scheme, None)
|
||||
if backend:
|
||||
backend.playlists.refresh().get()
|
||||
listener.CoreListener.send('playlists_loaded')
|
||||
|
||||
def save(self, playlist):
|
||||
"""
|
||||
@ -236,11 +297,23 @@ class PlaylistsController(object):
|
||||
:type playlist: :class:`mopidy.models.Playlist`
|
||||
:rtype: :class:`mopidy.models.Playlist` or :class:`None`
|
||||
"""
|
||||
validation.check_instance(playlist, Playlist)
|
||||
|
||||
if playlist.uri is None:
|
||||
return
|
||||
return # TODO: log this problem?
|
||||
|
||||
uri_scheme = urlparse.urlparse(playlist.uri).scheme
|
||||
backend = self.backends.with_playlists.get(uri_scheme, None)
|
||||
if backend:
|
||||
if not backend:
|
||||
return None
|
||||
|
||||
# TODO: we let AssertionError error through due to legacy tests :/
|
||||
with _backend_error_handling(backend, reraise=AssertionError):
|
||||
playlist = backend.playlists.save(playlist).get()
|
||||
listener.CoreListener.send('playlist_changed', playlist=playlist)
|
||||
playlist is None or validation.check_instance(playlist, Playlist)
|
||||
if playlist:
|
||||
listener.CoreListener.send(
|
||||
'playlist_changed', playlist=playlist)
|
||||
return playlist
|
||||
|
||||
return None
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import collections
|
||||
import logging
|
||||
import random
|
||||
|
||||
from mopidy import compat
|
||||
from mopidy import exceptions
|
||||
from mopidy.core import listener
|
||||
from mopidy.models import TlTrack
|
||||
from mopidy.utils import deprecation
|
||||
from mopidy.internal import deprecation, validation
|
||||
from mopidy.models import TlTrack, Track
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -93,6 +92,7 @@ class TracklistController(object):
|
||||
:class:`False`
|
||||
Tracks are not removed from the tracklist.
|
||||
"""
|
||||
validation.check_boolean(value)
|
||||
if self.get_consume() != value:
|
||||
self._trigger_options_changed()
|
||||
return setattr(self, '_consume', value)
|
||||
@ -121,7 +121,7 @@ class TracklistController(object):
|
||||
:class:`False`
|
||||
Tracks are played in the order of the tracklist.
|
||||
"""
|
||||
|
||||
validation.check_boolean(value)
|
||||
if self.get_random() != value:
|
||||
self._trigger_options_changed()
|
||||
if value:
|
||||
@ -157,7 +157,7 @@ class TracklistController(object):
|
||||
:class:`False`
|
||||
The tracklist is played once.
|
||||
"""
|
||||
|
||||
validation.check_boolean(value)
|
||||
if self.get_repeat() != value:
|
||||
self._trigger_options_changed()
|
||||
return setattr(self, '_repeat', value)
|
||||
@ -188,6 +188,7 @@ class TracklistController(object):
|
||||
:class:`False`
|
||||
Playback continues after current song.
|
||||
"""
|
||||
validation.check_boolean(value)
|
||||
if self.get_single() != value:
|
||||
self._trigger_options_changed()
|
||||
return setattr(self, '_single', value)
|
||||
@ -200,18 +201,52 @@ class TracklistController(object):
|
||||
|
||||
# Methods
|
||||
|
||||
def index(self, tl_track):
|
||||
def index(self, tl_track=None, tlid=None):
|
||||
"""
|
||||
The position of the given track in the tracklist.
|
||||
|
||||
If neither *tl_track* or *tlid* is given we return the index of
|
||||
the currently playing track.
|
||||
|
||||
:param tl_track: the track to find the index of
|
||||
:type tl_track: :class:`mopidy.models.TlTrack`
|
||||
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
:param tlid: TLID of the track to find the index of
|
||||
:type tlid: :class:`int` or :class:`None`
|
||||
:rtype: :class:`int` or :class:`None`
|
||||
|
||||
.. versionadded:: 1.1
|
||||
The *tlid* parameter
|
||||
"""
|
||||
try:
|
||||
return self._tl_tracks.index(tl_track)
|
||||
except ValueError:
|
||||
return None
|
||||
tl_track is None or validation.check_instance(tl_track, TlTrack)
|
||||
tlid is None or validation.check_integer(tlid, min=0)
|
||||
|
||||
if tl_track is None and tlid is None:
|
||||
tl_track = self.core.playback.get_current_tl_track()
|
||||
|
||||
if tl_track is not None:
|
||||
try:
|
||||
return self._tl_tracks.index(tl_track)
|
||||
except ValueError:
|
||||
pass
|
||||
elif tlid is not None:
|
||||
for i, tl_track in enumerate(self._tl_tracks):
|
||||
if tl_track.tlid == tlid:
|
||||
return i
|
||||
return None
|
||||
|
||||
def get_eot_tlid(self):
|
||||
"""
|
||||
The TLID of the track that will be played after the given track.
|
||||
|
||||
Not necessarily the same TLID as returned by :meth:`get_next_tlid`.
|
||||
|
||||
:rtype: :class:`int` or :class:`None`
|
||||
|
||||
.. versionadded:: 1.1
|
||||
"""
|
||||
|
||||
current_tl_track = self.core.playback.get_current_tl_track()
|
||||
return getattr(self.eot_track(current_tl_track), 'tlid', None)
|
||||
|
||||
def eot_track(self, tl_track):
|
||||
"""
|
||||
@ -223,6 +258,8 @@ class TracklistController(object):
|
||||
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
:rtype: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
"""
|
||||
deprecation.warn('core.tracklist.eot_track', pending=True)
|
||||
tl_track is None or validation.check_instance(tl_track, TlTrack)
|
||||
if self.get_single() and self.get_repeat():
|
||||
return tl_track
|
||||
elif self.get_single():
|
||||
@ -233,6 +270,23 @@ class TracklistController(object):
|
||||
# shared.
|
||||
return self.next_track(tl_track)
|
||||
|
||||
def get_next_tlid(self):
|
||||
"""
|
||||
The tlid of the track that will be played if calling
|
||||
:meth:`mopidy.core.PlaybackController.next()`.
|
||||
|
||||
For normal playback this is the next track in the tracklist. If repeat
|
||||
is enabled the next track can loop around the tracklist. When random is
|
||||
enabled this should be a random track, all tracks should be played once
|
||||
before the tracklist repeats.
|
||||
|
||||
:rtype: :class:`int` or :class:`None`
|
||||
|
||||
.. versionadded:: 1.1
|
||||
"""
|
||||
current_tl_track = self.core.playback.get_current_tl_track()
|
||||
return getattr(self.next_track(current_tl_track), 'tlid', None)
|
||||
|
||||
def next_track(self, tl_track):
|
||||
"""
|
||||
The track that will be played if calling
|
||||
@ -247,34 +301,51 @@ class TracklistController(object):
|
||||
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
:rtype: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
"""
|
||||
deprecation.warn('core.tracklist.next_track', pending=True)
|
||||
tl_track is None or validation.check_instance(tl_track, TlTrack)
|
||||
|
||||
if not self.get_tl_tracks():
|
||||
if not self._tl_tracks:
|
||||
return None
|
||||
|
||||
if self.get_random() and not self._shuffled:
|
||||
if self.get_repeat() or not tl_track:
|
||||
logger.debug('Shuffling tracks')
|
||||
self._shuffled = self.get_tl_tracks()
|
||||
self._shuffled = self._tl_tracks[:]
|
||||
random.shuffle(self._shuffled)
|
||||
|
||||
if self.get_random():
|
||||
try:
|
||||
if self._shuffled:
|
||||
return self._shuffled[0]
|
||||
except IndexError:
|
||||
return None
|
||||
return None
|
||||
|
||||
if tl_track is None:
|
||||
return self.get_tl_tracks()[0]
|
||||
next_index = 0
|
||||
else:
|
||||
next_index = self.index(tl_track) + 1
|
||||
|
||||
next_index = self.index(tl_track) + 1
|
||||
if self.get_repeat():
|
||||
next_index %= len(self.get_tl_tracks())
|
||||
|
||||
try:
|
||||
return self.get_tl_tracks()[next_index]
|
||||
except IndexError:
|
||||
next_index %= len(self._tl_tracks)
|
||||
elif next_index >= len(self._tl_tracks):
|
||||
return None
|
||||
|
||||
return self._tl_tracks[next_index]
|
||||
|
||||
def get_previous_tlid(self):
|
||||
"""
|
||||
Returns the TLID of the track that will be played if calling
|
||||
:meth:`mopidy.core.PlaybackController.previous()`.
|
||||
|
||||
For normal playback this is the previous track in the tracklist. If
|
||||
random and/or consume is enabled it should return the current track
|
||||
instead.
|
||||
|
||||
:rtype: :class:`int` or :class:`None`
|
||||
|
||||
.. versionadded:: 1.1
|
||||
"""
|
||||
current_tl_track = self.core.playback.get_current_tl_track()
|
||||
return getattr(self.previous_track(current_tl_track), 'tlid', None)
|
||||
|
||||
def previous_track(self, tl_track):
|
||||
"""
|
||||
Returns the track that will be played if calling
|
||||
@ -288,6 +359,9 @@ class TracklistController(object):
|
||||
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
:rtype: :class:`mopidy.models.TlTrack` or :class:`None`
|
||||
"""
|
||||
deprecation.warn('core.tracklist.previous_track', pending=True)
|
||||
tl_track is None or validation.check_instance(tl_track, TlTrack)
|
||||
|
||||
if self.get_repeat() or self.get_consume() or self.get_random():
|
||||
return tl_track
|
||||
|
||||
@ -296,30 +370,35 @@ class TracklistController(object):
|
||||
if position in (None, 0):
|
||||
return None
|
||||
|
||||
return self.get_tl_tracks()[position - 1]
|
||||
# Since we know we are not at zero we have to be somewhere in the range
|
||||
# 1 - len(tracks) Thus 'position - 1' will always be within the list.
|
||||
return self._tl_tracks[position - 1]
|
||||
|
||||
def add(self, tracks=None, at_position=None, uri=None, uris=None):
|
||||
"""
|
||||
Add the track or list of tracks to the tracklist.
|
||||
Add 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 ``uris`` is given instead of ``uri`` or ``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.
|
||||
If ``at_position`` is given, the tracks are inserted at the given
|
||||
position in the tracklist. If ``at_position`` is not given, the tracks
|
||||
are appended to the end of the tracklist.
|
||||
|
||||
Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event.
|
||||
|
||||
:param tracks: tracks to add
|
||||
:type tracks: list of :class:`mopidy.models.Track`
|
||||
:param at_position: position in tracklist to add track
|
||||
:type tracks: list of :class:`mopidy.models.Track` or :class:`None`
|
||||
:param at_position: position in tracklist to add tracks
|
||||
:type at_position: int or :class:`None`
|
||||
:param uri: URI for tracks to add
|
||||
:type uri: string
|
||||
:type uri: string or :class:`None`
|
||||
:param uris: list of URIs for tracks to add
|
||||
:type uris: list of string or :class:`None`
|
||||
:rtype: list of :class:`mopidy.models.TlTrack`
|
||||
|
||||
.. versionadded:: 1.0
|
||||
@ -328,10 +407,14 @@ class TracklistController(object):
|
||||
.. deprecated:: 1.0
|
||||
The ``tracks`` and ``uri`` arguments. Use ``uris``.
|
||||
"""
|
||||
assert tracks is not None or uri is not None or uris is not None, \
|
||||
'tracks, uri or uris must be provided'
|
||||
if sum(o is not None for o in [tracks, uri, uris]) != 1:
|
||||
raise ValueError(
|
||||
'Exactly one of "tracks", "uri" or "uris" must be set')
|
||||
|
||||
# TODO: assert that tracks are track instances
|
||||
tracks is None or validation.check_instances(tracks, Track)
|
||||
uri is None or validation.check_uri(uri)
|
||||
uris is None or validation.check_uris(uris)
|
||||
validation.check_integer(at_position or 0)
|
||||
|
||||
if tracks:
|
||||
deprecation.warn('core.tracklist.add:tracks_arg')
|
||||
@ -349,8 +432,13 @@ class TracklistController(object):
|
||||
tracks.extend(track_map[uri])
|
||||
|
||||
tl_tracks = []
|
||||
max_length = self.core._config['core']['max_tracklist_length']
|
||||
|
||||
for track in tracks:
|
||||
if self.get_length() >= max_length:
|
||||
raise exceptions.TracklistFull(
|
||||
'Tracklist may contain at most %d tracks.' % max_length)
|
||||
|
||||
tl_track = TlTrack(self._next_tlid, track)
|
||||
self._next_tlid += 1
|
||||
if at_position is not None:
|
||||
@ -388,41 +476,35 @@ class TracklistController(object):
|
||||
|
||||
# Returns tracks with TLIDs 1, 2, 3, or 4 (tracklist ID)
|
||||
filter({'tlid': [1, 2, 3, 4]})
|
||||
filter(tlid=[1, 2, 3, 4])
|
||||
|
||||
# Returns track with IDs 1, 5, or 7
|
||||
filter({'id': [1, 5, 7]})
|
||||
filter(id=[1, 5, 7])
|
||||
|
||||
# Returns track with URIs 'xyz' or 'abc'
|
||||
filter({'uri': ['xyz', 'abc']})
|
||||
filter(uri=['xyz', 'abc'])
|
||||
|
||||
# Returns tracks with ID 1 and URI 'xyz'
|
||||
filter({'id': [1], 'uri': ['xyz']})
|
||||
filter(id=[1], uri=['xyz'])
|
||||
|
||||
# Returns track with a matching ID (1, 3 or 6) and a matching URI
|
||||
# ('xyz' or 'abc')
|
||||
filter({'id': [1, 3, 6], 'uri': ['xyz', 'abc']})
|
||||
filter(id=[1, 3, 6], uri=['xyz', 'abc'])
|
||||
# Returns track with a matching TLIDs (1, 3 or 6) and a
|
||||
# matching URI ('xyz' or 'abc')
|
||||
filter({'tlid': [1, 3, 6], 'uri': ['xyz', 'abc']})
|
||||
|
||||
:param criteria: on or more criteria to match by
|
||||
:type criteria: dict, of (string, list) pairs
|
||||
:rtype: list of :class:`mopidy.models.TlTrack`
|
||||
|
||||
.. deprecated:: 1.1
|
||||
Providing the criteria via ``kwargs``.
|
||||
"""
|
||||
if kwargs:
|
||||
deprecation.warn('core.tracklist.filter:kwargs_criteria')
|
||||
|
||||
criteria = criteria or kwargs
|
||||
tlids = criteria.pop('tlid', [])
|
||||
validation.check_query(criteria, validation.TRACKLIST_FIELDS)
|
||||
validation.check_instances(tlids, int)
|
||||
|
||||
matches = self._tl_tracks
|
||||
for (key, values) in criteria.items():
|
||||
if (not isinstance(values, collections.Iterable) or
|
||||
isinstance(values, compat.string_types)):
|
||||
# Fail hard if anyone is using the <0.17 calling style
|
||||
raise ValueError('Filter values must be iterable: %r' % values)
|
||||
if key == 'tlid':
|
||||
matches = [ct for ct in matches if ct.tlid in values]
|
||||
else:
|
||||
matches = [
|
||||
ct for ct in matches if getattr(ct.track, key) in values]
|
||||
matches = [
|
||||
ct for ct in matches if getattr(ct.track, key) in values]
|
||||
if tlids:
|
||||
matches = [ct for ct in matches if ct.tlid in tlids]
|
||||
return matches
|
||||
|
||||
def move(self, start, end, to_position):
|
||||
@ -443,6 +525,7 @@ class TracklistController(object):
|
||||
|
||||
tl_tracks = self._tl_tracks
|
||||
|
||||
# TODO: use validation helpers?
|
||||
assert start < end, 'start must be smaller than end'
|
||||
assert start >= 0, 'start must be at least zero'
|
||||
assert end <= len(tl_tracks), \
|
||||
@ -469,8 +552,14 @@ class TracklistController(object):
|
||||
:param criteria: on or more criteria to match by
|
||||
:type criteria: dict
|
||||
:rtype: list of :class:`mopidy.models.TlTrack` that was removed
|
||||
|
||||
.. deprecated:: 1.1
|
||||
Providing the criteria via ``kwargs`` is no longer supported.
|
||||
"""
|
||||
tl_tracks = self.filter(criteria, **kwargs)
|
||||
if kwargs:
|
||||
deprecation.warn('core.tracklist.remove:kwargs_criteria')
|
||||
|
||||
tl_tracks = self.filter(criteria or kwargs)
|
||||
for tl_track in tl_tracks:
|
||||
position = self._tl_tracks.index(tl_track)
|
||||
del self._tl_tracks[position]
|
||||
@ -491,6 +580,7 @@ class TracklistController(object):
|
||||
"""
|
||||
tl_tracks = self._tl_tracks
|
||||
|
||||
# TOOD: use validation helpers?
|
||||
if start is not None and end is not None:
|
||||
assert start < end, 'start must be smaller than end'
|
||||
|
||||
@ -519,6 +609,7 @@ class TracklistController(object):
|
||||
:type end: int
|
||||
:rtype: :class:`mopidy.models.TlTrack`
|
||||
"""
|
||||
# TODO: validate slice?
|
||||
return self._tl_tracks[start:end]
|
||||
|
||||
def _mark_playing(self, tl_track):
|
||||
@ -535,13 +626,13 @@ class TracklistController(object):
|
||||
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])
|
||||
self.remove({'tlid': [tl_track.tlid]})
|
||||
return True
|
||||
return False
|
||||
|
||||
def _trigger_tracklist_changed(self):
|
||||
if self.get_random():
|
||||
self._shuffled = self.get_tl_tracks()
|
||||
self._shuffled = self._tl_tracks[:]
|
||||
random.shuffle(self._shuffled)
|
||||
else:
|
||||
self._shuffled = []
|
||||
|
||||
@ -21,6 +21,13 @@ class BackendError(MopidyException):
|
||||
pass
|
||||
|
||||
|
||||
class CoreError(MopidyException):
|
||||
|
||||
def __init__(self, message, errno=None):
|
||||
super(CoreError, self).__init__(message, errno)
|
||||
self.errno = errno
|
||||
|
||||
|
||||
class ExtensionError(MopidyException):
|
||||
pass
|
||||
|
||||
@ -44,5 +51,16 @@ class ScannerError(MopidyException):
|
||||
pass
|
||||
|
||||
|
||||
class TracklistFull(CoreError):
|
||||
|
||||
def __init__(self, message, errno=None):
|
||||
super(TracklistFull, self).__init__(message, errno)
|
||||
self.errno = errno
|
||||
|
||||
|
||||
class AudioException(MopidyException):
|
||||
pass
|
||||
|
||||
|
||||
class ValidationError(ValueError):
|
||||
pass
|
||||
|
||||
@ -11,6 +11,12 @@ from mopidy import config as config_lib, exceptions
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_extension_data_fields = ['extension', 'entry_point', 'config_schema',
|
||||
'config_defaults', 'command']
|
||||
|
||||
ExtensionData = collections.namedtuple('ExtensionData', _extension_data_fields)
|
||||
|
||||
|
||||
class Extension(object):
|
||||
|
||||
"""Base class for Mopidy extensions"""
|
||||
@ -89,14 +95,7 @@ class Extension(object):
|
||||
the ``frontend`` and ``backend`` registry keys.
|
||||
|
||||
This method can also be used for other setup tasks not involving the
|
||||
extension registry. For example, to register custom GStreamer
|
||||
elements::
|
||||
|
||||
def setup(self, registry):
|
||||
from .mixer import SoundspotMixer
|
||||
gobject.type_register(SoundspotMixer)
|
||||
gst.element_register(
|
||||
SoundspotMixer, 'soundspotmixer', gst.RANK_MARGINAL)
|
||||
extension registry.
|
||||
|
||||
:param registry: the extension registry
|
||||
:type registry: :class:`Registry`
|
||||
@ -155,55 +154,100 @@ def load_extensions():
|
||||
for entry_point in pkg_resources.iter_entry_points('mopidy.ext'):
|
||||
logger.debug('Loading entry point: %s', entry_point)
|
||||
extension_class = entry_point.load(require=False)
|
||||
extension = extension_class()
|
||||
extension.entry_point = entry_point
|
||||
installed_extensions.append(extension)
|
||||
|
||||
try:
|
||||
if not issubclass(extension_class, Extension):
|
||||
raise TypeError # issubclass raises TypeError on non-class
|
||||
except TypeError:
|
||||
logger.error('Entry point %s did not contain a valid extension'
|
||||
'class: %r', entry_point.name, extension_class)
|
||||
continue
|
||||
|
||||
try:
|
||||
extension = extension_class()
|
||||
config_schema = extension.get_config_schema()
|
||||
default_config = extension.get_default_config()
|
||||
command = extension.get_command()
|
||||
except Exception:
|
||||
logger.exception('Setup of extension from entry point %s failed, '
|
||||
'ignoring extension.', entry_point.name)
|
||||
continue
|
||||
|
||||
installed_extensions.append(ExtensionData(
|
||||
extension, entry_point, config_schema, default_config, command))
|
||||
|
||||
logger.debug(
|
||||
'Loaded extension: %s %s', extension.dist_name, extension.version)
|
||||
|
||||
names = (e.ext_name for e in installed_extensions)
|
||||
names = (ed.extension.ext_name for ed in installed_extensions)
|
||||
logger.debug('Discovered extensions: %s', ', '.join(names))
|
||||
return installed_extensions
|
||||
|
||||
|
||||
def validate_extension(extension):
|
||||
def validate_extension_data(data):
|
||||
"""Verify extension's dependencies and environment.
|
||||
|
||||
:param extensions: an extension to check
|
||||
:returns: if extension should be run
|
||||
"""
|
||||
|
||||
logger.debug('Validating extension: %s', extension.ext_name)
|
||||
logger.debug('Validating extension: %s', data.extension.ext_name)
|
||||
|
||||
if extension.ext_name != extension.entry_point.name:
|
||||
if data.extension.ext_name != data.entry_point.name:
|
||||
logger.warning(
|
||||
'Disabled extension %(ep)s: entry point name (%(ep)s) '
|
||||
'does not match extension name (%(ext)s)',
|
||||
{'ep': extension.entry_point.name, 'ext': extension.ext_name})
|
||||
{'ep': data.entry_point.name, 'ext': data.extension.ext_name})
|
||||
return False
|
||||
|
||||
try:
|
||||
extension.entry_point.require()
|
||||
data.entry_point.require()
|
||||
except pkg_resources.DistributionNotFound as ex:
|
||||
logger.info(
|
||||
'Disabled extension %s: Dependency %s not found',
|
||||
extension.ext_name, ex)
|
||||
data.extension.ext_name, ex)
|
||||
return False
|
||||
except pkg_resources.VersionConflict as ex:
|
||||
if len(ex.args) == 2:
|
||||
found, required = ex.args
|
||||
logger.info(
|
||||
'Disabled extension %s: %s required, but found %s at %s',
|
||||
extension.ext_name, required, found, found.location)
|
||||
data.extension.ext_name, required, found, found.location)
|
||||
else:
|
||||
logger.info('Disabled extension %s: %s', extension.ext_name, ex)
|
||||
logger.info(
|
||||
'Disabled extension %s: %s', data.extension.ext_name, ex)
|
||||
return False
|
||||
|
||||
try:
|
||||
extension.validate_environment()
|
||||
data.extension.validate_environment()
|
||||
except exceptions.ExtensionError as ex:
|
||||
logger.info(
|
||||
'Disabled extension %s: %s', extension.ext_name, ex.message)
|
||||
'Disabled extension %s: %s', data.extension.ext_name, ex.message)
|
||||
return False
|
||||
except Exception:
|
||||
logger.exception('Validating extension %s failed with an exception.',
|
||||
data.extension.ext_name)
|
||||
return False
|
||||
|
||||
if not data.config_schema:
|
||||
logger.error('Extension %s does not have a config schema, disabling.',
|
||||
data.extension.ext_name)
|
||||
return False
|
||||
elif not isinstance(data.config_schema.get('enabled'), config_lib.Boolean):
|
||||
logger.error('Extension %s does not have the required "enabled" config'
|
||||
' option, disabling.', data.extension.ext_name)
|
||||
return False
|
||||
|
||||
for key, value in data.config_schema.items():
|
||||
if not isinstance(value, config_lib.ConfigValue):
|
||||
logger.error('Extension %s config schema contains an invalid value'
|
||||
' for the option "%s", disabling.',
|
||||
data.extension.ext_name, key)
|
||||
return False
|
||||
|
||||
if not data.config_defaults:
|
||||
logger.error('Extension %s does not have a default config, disabling.',
|
||||
data.extension.ext_name)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
32
mopidy/file/__init__.py
Normal file
32
mopidy/file/__init__.py
Normal file
@ -0,0 +1,32 @@
|
||||
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-File'
|
||||
ext_name = 'file'
|
||||
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['media_dirs'] = config.List(optional=True)
|
||||
schema['show_dotfiles'] = config.Boolean(optional=True)
|
||||
schema['follow_symlinks'] = config.Boolean(optional=True)
|
||||
schema['metadata_timeout'] = config.Integer(optional=True)
|
||||
return schema
|
||||
|
||||
def setup(self, registry):
|
||||
from .backend import FileBackend
|
||||
registry.add('backend', FileBackend)
|
||||
21
mopidy/file/backend.py
Normal file
21
mopidy/file/backend.py
Normal file
@ -0,0 +1,21 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
import pykka
|
||||
|
||||
from mopidy import backend
|
||||
from mopidy.file import library
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileBackend(pykka.ThreadingActor, backend.Backend):
|
||||
uri_schemes = ['file']
|
||||
|
||||
def __init__(self, config, audio):
|
||||
super(FileBackend, self).__init__()
|
||||
self.library = library.FileLibraryProvider(backend=self, config=config)
|
||||
self.playback = backend.PlaybackProvider(audio=audio, backend=self)
|
||||
self.playlists = None
|
||||
8
mopidy/file/ext.conf
Normal file
8
mopidy/file/ext.conf
Normal file
@ -0,0 +1,8 @@
|
||||
[file]
|
||||
enabled = true
|
||||
media_dirs =
|
||||
$XDG_MUSIC_DIR|Music
|
||||
~/|Home
|
||||
show_dotfiles = false
|
||||
follow_symlinks = false
|
||||
metadata_timeout = 1000
|
||||
149
mopidy/file/library.py
Normal file
149
mopidy/file/library.py
Normal file
@ -0,0 +1,149 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
import operator
|
||||
import os
|
||||
import sys
|
||||
import urllib2
|
||||
|
||||
from mopidy import backend, exceptions, models
|
||||
from mopidy.audio import scan, utils
|
||||
from mopidy.internal import path
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
FS_ENCODING = sys.getfilesystemencoding()
|
||||
|
||||
|
||||
class FileLibraryProvider(backend.LibraryProvider):
|
||||
"""Library for browsing local files."""
|
||||
|
||||
# TODO: get_images that can pull from metadata and/or .folder.png etc?
|
||||
# TODO: handle playlists?
|
||||
|
||||
@property
|
||||
def root_directory(self):
|
||||
if not self._media_dirs:
|
||||
return None
|
||||
elif len(self._media_dirs) == 1:
|
||||
uri = path.path_to_uri(self._media_dirs[0]['path'])
|
||||
else:
|
||||
uri = 'file:root'
|
||||
return models.Ref.directory(name='Files', uri=uri)
|
||||
|
||||
def __init__(self, backend, config):
|
||||
super(FileLibraryProvider, self).__init__(backend)
|
||||
self._media_dirs = list(self._get_media_dirs(config))
|
||||
self._follow_symlinks = config['file']['follow_symlinks']
|
||||
self._show_dotfiles = config['file']['show_dotfiles']
|
||||
self._scanner = scan.Scanner(
|
||||
timeout=config['file']['metadata_timeout'])
|
||||
|
||||
def browse(self, uri):
|
||||
logger.debug('Browsing files at: %s', uri)
|
||||
result = []
|
||||
local_path = path.uri_to_path(uri)
|
||||
|
||||
if local_path == 'root':
|
||||
return list(self._get_media_dirs_refs())
|
||||
|
||||
if not self._is_in_basedir(os.path.realpath(local_path)):
|
||||
logger.warning(
|
||||
'Rejected attempt to browse path (%s) outside dirs defined '
|
||||
'in file/media_dirs config.', uri)
|
||||
return []
|
||||
|
||||
for dir_entry in os.listdir(local_path):
|
||||
child_path = os.path.join(local_path, dir_entry)
|
||||
uri = path.path_to_uri(child_path)
|
||||
|
||||
if not self._show_dotfiles and dir_entry.startswith(b'.'):
|
||||
continue
|
||||
|
||||
if os.path.islink(child_path) and not self._follow_symlinks:
|
||||
logger.debug('Ignoring symlink: %s', uri)
|
||||
continue
|
||||
|
||||
if not self._is_in_basedir(os.path.realpath(child_path)):
|
||||
logger.debug('Ignoring symlink to outside base dir: %s', uri)
|
||||
continue
|
||||
|
||||
name = dir_entry.decode(FS_ENCODING, 'replace')
|
||||
if os.path.isdir(child_path):
|
||||
result.append(models.Ref.directory(name=name, uri=uri))
|
||||
elif os.path.isfile(child_path) and self._is_audio_file(uri):
|
||||
result.append(models.Ref.track(name=name, uri=uri))
|
||||
|
||||
result.sort(key=operator.attrgetter('name'))
|
||||
return result
|
||||
|
||||
def lookup(self, uri):
|
||||
logger.debug('Looking up file URI: %s', uri)
|
||||
local_path = path.uri_to_path(uri)
|
||||
|
||||
if not self._is_in_basedir(local_path):
|
||||
logger.warning('Ignoring URI outside base dir: %s', local_path)
|
||||
return []
|
||||
|
||||
try:
|
||||
result = self._scanner.scan(uri)
|
||||
track = utils.convert_tags_to_track(result.tags).copy(
|
||||
uri=uri, length=result.duration)
|
||||
except exceptions.ScannerError as e:
|
||||
logger.warning('Failed looking up %s: %s', uri, e)
|
||||
track = models.Track(uri=uri)
|
||||
|
||||
if not track.name:
|
||||
filename = os.path.basename(local_path)
|
||||
name = urllib2.unquote(filename).decode(FS_ENCODING, 'replace')
|
||||
track = track.copy(name=name)
|
||||
|
||||
return [track]
|
||||
|
||||
def _get_media_dirs(self, config):
|
||||
for entry in config['file']['media_dirs']:
|
||||
media_dir = {}
|
||||
media_dir_split = entry.split('|', 1)
|
||||
local_path = path.expand_path(
|
||||
media_dir_split[0].encode(FS_ENCODING))
|
||||
|
||||
if not local_path:
|
||||
logger.warning('Failed expanding path (%s) from'
|
||||
'file/media_dirs config value.',
|
||||
media_dir_split[0])
|
||||
continue
|
||||
elif not os.path.isdir(local_path):
|
||||
logger.warning('%s is not a directory', local_path)
|
||||
continue
|
||||
|
||||
media_dir['path'] = local_path
|
||||
if len(media_dir_split) == 2:
|
||||
media_dir['name'] = media_dir_split[1]
|
||||
else:
|
||||
# TODO Mpd client should accept / in dir name
|
||||
media_dir['name'] = media_dir_split[0].replace(os.sep, '+')
|
||||
|
||||
yield media_dir
|
||||
|
||||
def _get_media_dirs_refs(self):
|
||||
for media_dir in self._media_dirs:
|
||||
yield models.Ref.directory(
|
||||
name=media_dir['name'],
|
||||
uri=path.path_to_uri(media_dir['path']))
|
||||
|
||||
def _is_audio_file(self, uri):
|
||||
try:
|
||||
result = self._scanner.scan(uri)
|
||||
if result.playable:
|
||||
logger.debug('Playable file: %s', result.uri)
|
||||
else:
|
||||
logger.debug('Unplayable file: %s (not audio)', result.uri)
|
||||
return result.playable
|
||||
except exceptions.ScannerError as e:
|
||||
logger.debug('Unplayable file: %s (%s)', uri, e)
|
||||
return False
|
||||
|
||||
def _is_in_basedir(self, local_path):
|
||||
return any(
|
||||
path.is_path_inside_base_dir(local_path, media_dir['path'])
|
||||
for media_dir in self._media_dirs)
|
||||
@ -16,7 +16,7 @@ import tornado.websocket
|
||||
from mopidy import exceptions, models, zeroconf
|
||||
from mopidy.core import CoreListener
|
||||
from mopidy.http import handlers
|
||||
from mopidy.utils import encoding, formatting, network
|
||||
from mopidy.internal import encoding, formatting, network
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -1,16 +1,18 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
|
||||
import tornado.escape
|
||||
import tornado.ioloop
|
||||
import tornado.web
|
||||
import tornado.websocket
|
||||
|
||||
import mopidy
|
||||
from mopidy import core, models
|
||||
from mopidy.utils import encoding, jsonrpc
|
||||
from mopidy.internal import encoding, jsonrpc
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -65,6 +67,19 @@ def make_jsonrpc_wrapper(core_actor):
|
||||
)
|
||||
|
||||
|
||||
def _send_broadcast(client, msg):
|
||||
# We could check for client.ws_connection, but we don't really
|
||||
# care why the broadcast failed, we just want the rest of them
|
||||
# to succeed, so catch everything.
|
||||
try:
|
||||
client.write_message(msg)
|
||||
except Exception as e:
|
||||
error_msg = encoding.locale_decode(e)
|
||||
logger.debug('Broadcast of WebSocket message to %s failed: %s',
|
||||
client.request.remote_ip, error_msg)
|
||||
# TODO: should this do the same cleanup as the on_message code?
|
||||
|
||||
|
||||
class WebSocketHandler(tornado.websocket.WebSocketHandler):
|
||||
|
||||
# XXX This set is shared by all WebSocketHandler objects. This isn't
|
||||
@ -74,17 +89,17 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
|
||||
|
||||
@classmethod
|
||||
def broadcast(cls, msg):
|
||||
if hasattr(tornado.ioloop.IOLoop, 'current'):
|
||||
loop = tornado.ioloop.IOLoop.current()
|
||||
else:
|
||||
loop = tornado.ioloop.IOLoop.instance() # Fallback for pre 3.0
|
||||
|
||||
# This can be called from outside the Tornado ioloop, so we need to
|
||||
# safely cross the thread boundary by adding a callback to the loop.
|
||||
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?
|
||||
# One callback per client to keep time we hold up the loop short
|
||||
# NOTE: Pre 3.0 does not support *args or **kwargs...
|
||||
loop.add_callback(functools.partial(_send_broadcast, client, msg))
|
||||
|
||||
def initialize(self, core):
|
||||
self.jsonrpc = make_jsonrpc_wrapper(core)
|
||||
|
||||
52
mopidy/httpclient.py
Normal file
52
mopidy/httpclient.py
Normal file
@ -0,0 +1,52 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import platform
|
||||
|
||||
import mopidy
|
||||
|
||||
"Helpers for configuring HTTP clients used in Mopidy extensions."
|
||||
|
||||
|
||||
def format_proxy(proxy_config, auth=True):
|
||||
"""Convert a Mopidy proxy config to the commonly used proxy string format.
|
||||
|
||||
Outputs ``scheme://host:port``, ``scheme://user:pass@host:port`` or
|
||||
:class:`None` depending on the proxy config provided.
|
||||
|
||||
You can also opt out of getting the basic auth by setting ``auth`` to
|
||||
:class:`False`.
|
||||
|
||||
.. versionadded:: 1.1
|
||||
"""
|
||||
if not proxy_config.get('hostname'):
|
||||
return None
|
||||
|
||||
port = proxy_config.get('port', 80)
|
||||
if port < 0:
|
||||
port = 80
|
||||
|
||||
if proxy_config.get('username') and proxy_config.get('password') and auth:
|
||||
template = '{scheme}://{username}:{password}@{hostname}:{port}'
|
||||
else:
|
||||
template = '{scheme}://{hostname}:{port}'
|
||||
|
||||
return template.format(scheme=proxy_config.get('scheme') or 'http',
|
||||
username=proxy_config.get('username'),
|
||||
password=proxy_config.get('password'),
|
||||
hostname=proxy_config['hostname'], port=port)
|
||||
|
||||
|
||||
def format_user_agent(name=None):
|
||||
"""Construct a User-Agent suitable for use in client code.
|
||||
|
||||
This will identify use by the provided ``name`` (which should be on the
|
||||
format ``dist_name/version``), Mopidy version and Python version.
|
||||
|
||||
.. versionadded:: 1.1
|
||||
"""
|
||||
parts = ['Mopidy/%s' % (mopidy.__version__),
|
||||
'%s/%s' % (platform.python_implementation(),
|
||||
platform.python_version())]
|
||||
if name:
|
||||
parts.insert(0, name)
|
||||
return ' '.join(parts)
|
||||
@ -30,6 +30,9 @@ _MESSAGES = {
|
||||
'core.playback.set_mute': 'playback.set_mute() is deprecated',
|
||||
'core.playback.get_volume': 'playback.get_volume() is deprecated',
|
||||
'core.playback.set_volume': 'playback.set_volume() is deprecated',
|
||||
'core.playback.play:tl_track_kwargs':
|
||||
'playback.play() with "tl_track" argument is pending deprecation use '
|
||||
'"tlid" instead',
|
||||
|
||||
# Deprecated features in core playlists:
|
||||
'core.playlists.filter': 'playlists.filter() is deprecated',
|
||||
@ -40,11 +43,32 @@ _MESSAGES = {
|
||||
'tracklist.add() "tracks" argument is deprecated',
|
||||
'core.tracklist.add:uri_arg':
|
||||
'tracklist.add() "uri" argument is deprecated',
|
||||
'core.tracklist.filter:kwargs_criteria':
|
||||
'tracklist.filter() with "kwargs" as criteria is deprecated',
|
||||
'core.tracklist.remove:kwargs_criteria':
|
||||
'tracklist.remove() with "kwargs" as criteria is deprecated',
|
||||
|
||||
'core.tracklist.eot_track':
|
||||
'tracklist.eot_track() is pending deprecation, use '
|
||||
'tracklist.get_eot_tlid()',
|
||||
'core.tracklist.next_track':
|
||||
'tracklist.next_track() is pending deprecation, use '
|
||||
'tracklist.get_next_tlid()',
|
||||
'core.tracklist.previous_track':
|
||||
'tracklist.previous_track() is pending deprecation, use '
|
||||
'tracklist.get_previous_tlid()',
|
||||
|
||||
'models.immutable.copy':
|
||||
'ImmutableObject.copy() is deprecated, use ImmutableObject.replace()',
|
||||
}
|
||||
|
||||
|
||||
def warn(msg_id):
|
||||
warnings.warn(_MESSAGES.get(msg_id, msg_id), DeprecationWarning)
|
||||
def warn(msg_id, pending=False):
|
||||
if pending:
|
||||
category = PendingDeprecationWarning
|
||||
else:
|
||||
category = DeprecationWarning
|
||||
warnings.warn(_MESSAGES.get(msg_id, msg_id), category)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
@ -5,13 +5,13 @@ import os
|
||||
import platform
|
||||
import sys
|
||||
|
||||
import pkg_resources
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst # noqa
|
||||
|
||||
import pkg_resources
|
||||
|
||||
from mopidy.utils import formatting
|
||||
from mopidy.internal import formatting
|
||||
|
||||
|
||||
def format_dependency_list(adapters=None):
|
||||
@ -11,7 +11,7 @@ import gobject
|
||||
|
||||
import pykka
|
||||
|
||||
from mopidy.utils import encoding
|
||||
from mopidy.internal import encoding
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -8,25 +8,15 @@ import threading
|
||||
import urllib
|
||||
import urlparse
|
||||
|
||||
import glib
|
||||
|
||||
from mopidy import compat, exceptions
|
||||
from mopidy.compat import queue
|
||||
from mopidy.utils import encoding
|
||||
from mopidy.internal import encoding, xdg
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
XDG_DIRS = {
|
||||
'XDG_CACHE_DIR': glib.get_user_cache_dir(),
|
||||
'XDG_CONFIG_DIR': glib.get_user_config_dir(),
|
||||
'XDG_DATA_DIR': glib.get_user_data_dir(),
|
||||
'XDG_MUSIC_DIR': glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC),
|
||||
}
|
||||
|
||||
# XDG_MUSIC_DIR can be none, so filter out any bad data.
|
||||
XDG_DIRS = dict((k, v) for k, v in XDG_DIRS.items() if v is not None)
|
||||
XDG_DIRS = xdg.get_dirs()
|
||||
|
||||
|
||||
def get_or_create_dir(dir_path):
|
||||
@ -206,23 +196,23 @@ def find_mtimes(root, follow=False):
|
||||
return mtimes, errors
|
||||
|
||||
|
||||
def check_file_path_is_inside_base_dir(file_path, base_path):
|
||||
assert not file_path.endswith(os.sep), (
|
||||
'File path %s cannot end with a path separator' % file_path)
|
||||
|
||||
def is_path_inside_base_dir(path, base_path):
|
||||
if path.endswith(os.sep):
|
||||
raise ValueError('Path %s cannot end with a path separator'
|
||||
% path)
|
||||
# Expand symlinks
|
||||
real_base_path = os.path.realpath(base_path)
|
||||
real_file_path = os.path.realpath(file_path)
|
||||
real_path = os.path.realpath(path)
|
||||
|
||||
# Use dir of file for prefix comparision, so we don't accept
|
||||
# /tmp/foo.m3u as being inside /tmp/foo, simply because they have a
|
||||
# common prefix, /tmp/foo, which matches the base path, /tmp/foo.
|
||||
real_dir_path = os.path.dirname(real_file_path)
|
||||
if os.path.isfile(path):
|
||||
# Use dir of file for prefix comparision, so we don't accept
|
||||
# /tmp/foo.m3u as being inside /tmp/foo, simply because they have a
|
||||
# common prefix, /tmp/foo, which matches the base path, /tmp/foo.
|
||||
real_path = os.path.dirname(real_path)
|
||||
|
||||
# Check if dir of file is the base path or a subdir
|
||||
common_prefix = os.path.commonprefix([real_base_path, real_dir_path])
|
||||
assert common_prefix == real_base_path, (
|
||||
'File path %s must be in %s' % (real_file_path, real_base_path))
|
||||
common_prefix = os.path.commonprefix([real_base_path, real_path])
|
||||
return common_prefix == real_base_path
|
||||
|
||||
|
||||
# FIXME replace with mock usage in tests.
|
||||
105
mopidy/internal/validation.py
Normal file
105
mopidy/internal/validation.py
Normal file
@ -0,0 +1,105 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import collections
|
||||
import urlparse
|
||||
|
||||
from mopidy import compat, exceptions
|
||||
|
||||
PLAYBACK_STATES = {'paused', 'stopped', 'playing'}
|
||||
|
||||
SEARCH_FIELDS = {
|
||||
'uri', 'track_name', 'album', 'artist', 'albumartist', 'composer',
|
||||
'performer', 'track_no', 'genre', 'date', 'comment', 'any'}
|
||||
|
||||
PLAYLIST_FIELDS = {'uri', 'name'} # TODO: add length and last_modified?
|
||||
|
||||
TRACKLIST_FIELDS = { # TODO: add bitrate, length, disc_no, track_no, modified?
|
||||
'uri', 'name', 'genre', 'date', 'comment', 'musicbrainz_id'}
|
||||
|
||||
DISTINCT_FIELDS = {
|
||||
'track', 'artist', 'albumartist', 'album', 'composer', 'performer', 'date',
|
||||
'genre'}
|
||||
|
||||
|
||||
# TODO: _check_iterable(check, msg, **kwargs) + [check(a) for a in arg]?
|
||||
def _check_iterable(arg, msg, **kwargs):
|
||||
"""Ensure we have an iterable which is not a string or an iterator"""
|
||||
if isinstance(arg, compat.string_types):
|
||||
raise exceptions.ValidationError(msg.format(arg=arg, **kwargs))
|
||||
elif not isinstance(arg, collections.Iterable):
|
||||
raise exceptions.ValidationError(msg.format(arg=arg, **kwargs))
|
||||
elif iter(arg) is iter(arg):
|
||||
raise exceptions.ValidationError(msg.format(arg=arg, **kwargs))
|
||||
|
||||
|
||||
def check_choice(arg, choices, msg='Expected one of {choices}, not {arg!r}'):
|
||||
if arg not in choices:
|
||||
raise exceptions.ValidationError(msg.format(
|
||||
arg=arg, choices=tuple(choices)))
|
||||
|
||||
|
||||
def check_boolean(arg, msg='Expected a boolean, not {arg!r}'):
|
||||
check_instance(arg, bool, msg=msg)
|
||||
|
||||
|
||||
def check_instance(arg, cls, msg='Expected a {name} instance, not {arg!r}'):
|
||||
if not isinstance(arg, cls):
|
||||
raise exceptions.ValidationError(
|
||||
msg.format(arg=arg, name=cls.__name__))
|
||||
|
||||
|
||||
def check_instances(arg, cls, msg='Expected a list of {name}, not {arg!r}'):
|
||||
_check_iterable(arg, msg, name=cls.__name__)
|
||||
if not all(isinstance(instance, cls) for instance in arg):
|
||||
raise exceptions.ValidationError(
|
||||
msg.format(arg=arg, name=cls.__name__))
|
||||
|
||||
|
||||
def check_integer(arg, min=None, max=None):
|
||||
if not isinstance(arg, (int, long)):
|
||||
raise exceptions.ValidationError('Expected an integer, not %r' % arg)
|
||||
elif min is not None and arg < min:
|
||||
raise exceptions.ValidationError(
|
||||
'Expected number larger or equal to %d, not %r' % (min, arg))
|
||||
elif max is not None and arg > max:
|
||||
raise exceptions.ValidationError(
|
||||
'Expected number smaller or equal to %d, not %r' % (max, arg))
|
||||
|
||||
|
||||
def check_query(arg, fields=SEARCH_FIELDS, list_values=True):
|
||||
# TODO: normalize name -> track_name
|
||||
# TODO: normalize value -> [value]
|
||||
# TODO: normalize blank -> [] or just remove field?
|
||||
# TODO: remove list_values?
|
||||
|
||||
if not isinstance(arg, collections.Mapping):
|
||||
raise exceptions.ValidationError(
|
||||
'Expected a query dictionary, not {arg!r}'.format(arg=arg))
|
||||
|
||||
for key, value in arg.items():
|
||||
check_choice(key, fields, msg='Expected query field to be one of '
|
||||
'{choices}, not {arg!r}')
|
||||
if list_values:
|
||||
msg = 'Expected "{key}" to be list of strings, not {arg!r}'
|
||||
_check_iterable(value, msg, key=key)
|
||||
[_check_query_value(key, v, msg) for v in value]
|
||||
else:
|
||||
_check_query_value(
|
||||
key, value, 'Expected "{key}" to be a string, not {arg!r}')
|
||||
|
||||
|
||||
def _check_query_value(key, arg, msg):
|
||||
if not isinstance(arg, compat.string_types) or not arg.strip():
|
||||
raise exceptions.ValidationError(msg.format(arg=arg, key=key))
|
||||
|
||||
|
||||
def check_uri(arg, msg='Expected a valid URI, not {arg!r}'):
|
||||
if not isinstance(arg, compat.string_types):
|
||||
raise exceptions.ValidationError(msg.format(arg=arg))
|
||||
elif urlparse.urlparse(arg).scheme == '':
|
||||
raise exceptions.ValidationError(msg.format(arg=arg))
|
||||
|
||||
|
||||
def check_uris(arg, msg='Expected a list of URIs, not {arg!r}'):
|
||||
_check_iterable(arg, msg)
|
||||
[check_uri(a, msg) for a in arg]
|
||||
66
mopidy/internal/xdg.py
Normal file
66
mopidy/internal/xdg.py
Normal file
@ -0,0 +1,66 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import ConfigParser as configparser
|
||||
import io
|
||||
import os
|
||||
|
||||
|
||||
def get_dirs():
|
||||
"""Returns a dict of all the known XDG Base Directories for the current user.
|
||||
|
||||
The keys ``XDG_CACHE_DIR``, ``XDG_CONFIG_DIR``, and ``XDG_DATA_DIR`` is
|
||||
always available.
|
||||
|
||||
Additional keys, like ``XDG_MUSIC_DIR``, may be available if the
|
||||
``$XDG_CONFIG_DIR/user-dirs.dirs`` file exists and is parseable.
|
||||
|
||||
See http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html
|
||||
for the XDG Base Directory specification.
|
||||
"""
|
||||
|
||||
dirs = {
|
||||
'XDG_CACHE_DIR': (
|
||||
os.environ.get('XDG_CACHE_HOME') or
|
||||
os.path.expanduser(b'~/.cache')),
|
||||
'XDG_CONFIG_DIR': (
|
||||
os.environ.get('XDG_CONFIG_HOME') or
|
||||
os.path.expanduser(b'~/.config')),
|
||||
'XDG_DATA_DIR': (
|
||||
os.environ.get('XDG_DATA_HOME') or
|
||||
os.path.expanduser(b'~/.local/share')),
|
||||
}
|
||||
|
||||
dirs.update(_get_user_dirs(dirs['XDG_CONFIG_DIR']))
|
||||
|
||||
return dirs
|
||||
|
||||
|
||||
def _get_user_dirs(xdg_config_dir):
|
||||
"""Returns a dict of XDG dirs read from
|
||||
``$XDG_CONFIG_HOME/user-dirs.dirs``.
|
||||
|
||||
This is used at import time for most users of :mod:`mopidy`. By rolling our
|
||||
own implementation instead of using :meth:`glib.get_user_special_dir` we
|
||||
make it possible for many extensions to run their test suites, which are
|
||||
importing parts of :mod:`mopidy`, in a virtualenv with global site-packages
|
||||
disabled, and thus no :mod:`glib` available.
|
||||
"""
|
||||
|
||||
dirs_file = os.path.join(xdg_config_dir, 'user-dirs.dirs')
|
||||
|
||||
if not os.path.exists(dirs_file):
|
||||
return {}
|
||||
|
||||
with open(dirs_file, 'rb') as fh:
|
||||
data = fh.read()
|
||||
|
||||
data = b'[XDG_USER_DIRS]\n' + data
|
||||
data = data.replace(b'$HOME', os.path.expanduser(b'~'))
|
||||
data = data.replace(b'"', b'')
|
||||
|
||||
config = configparser.RawConfigParser()
|
||||
config.readfp(io.BytesIO(data))
|
||||
|
||||
return {
|
||||
k.decode('utf-8').upper(): os.path.abspath(v)
|
||||
for k, v in config.items('XDG_USER_DIRS') if v is not None}
|
||||
@ -2,14 +2,18 @@ from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
import gobject
|
||||
|
||||
import pykka
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def send_async(cls, event, **kwargs):
|
||||
# This file is imported by mopidy.backends, which again is imported by all
|
||||
# backend extensions. By importing modules that are not easily installable
|
||||
# close to their use, we make some extensions able to run their tests in a
|
||||
# virtualenv with global site-packages disabled.
|
||||
import gobject
|
||||
|
||||
gobject.idle_add(lambda: send(cls, event, **kwargs))
|
||||
|
||||
|
||||
|
||||
@ -7,8 +7,8 @@ import time
|
||||
|
||||
from mopidy import commands, compat, exceptions
|
||||
from mopidy.audio import scan, utils
|
||||
from mopidy.internal import path
|
||||
from mopidy.local import translator
|
||||
from mopidy.utils import path
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -136,12 +136,14 @@ class ScanCommand(commands.Command):
|
||||
file_uri = path.path_to_uri(os.path.join(media_dir, relpath))
|
||||
result = scanner.scan(file_uri)
|
||||
tags, duration = result.tags, result.duration
|
||||
if duration < MIN_DURATION_MS:
|
||||
if not result.playable:
|
||||
logger.warning('Failed %s: No audio found in file.', uri)
|
||||
elif duration < MIN_DURATION_MS:
|
||||
logger.warning('Failed %s: Track shorter than %dms',
|
||||
uri, MIN_DURATION_MS)
|
||||
else:
|
||||
mtime = file_mtimes.get(os.path.join(media_dir, relpath))
|
||||
track = utils.convert_tags_to_track(tags).copy(
|
||||
track = utils.convert_tags_to_track(tags).replace(
|
||||
uri=uri, length=duration, last_modified=mtime)
|
||||
if library.add_supports_tags_and_duration:
|
||||
library.add(track, tags=tags, duration=duration)
|
||||
|
||||
@ -11,8 +11,8 @@ import tempfile
|
||||
|
||||
import mopidy
|
||||
from mopidy import compat, local, models
|
||||
from mopidy.internal import encoding, timer
|
||||
from mopidy.local import search, storage, translator
|
||||
from mopidy.utils import encoding, timer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -141,7 +141,10 @@ class JsonLibrary(local.Library):
|
||||
return []
|
||||
|
||||
def get_distinct(self, field, query=None):
|
||||
if field == 'artist':
|
||||
if field == 'track':
|
||||
def distinct(track):
|
||||
return {track.name}
|
||||
elif field == 'artist':
|
||||
def distinct(track):
|
||||
return {a.name for a in track.artists}
|
||||
elif field == 'albumartist':
|
||||
@ -171,7 +174,7 @@ class JsonLibrary(local.Library):
|
||||
search_result = search.search(self._tracks.values(), query, limit=None)
|
||||
for track in search_result.tracks:
|
||||
distinct_result.update(distinct(track))
|
||||
return distinct_result
|
||||
return distinct_result - {None}
|
||||
|
||||
def search(self, query=None, limit=100, offset=0, uris=None, exact=False):
|
||||
tracks = self._tracks.values()
|
||||
|
||||
@ -7,5 +7,5 @@ from mopidy.local import translator
|
||||
class LocalPlaybackProvider(backend.PlaybackProvider):
|
||||
|
||||
def translate_uri(self, uri):
|
||||
return translator.local_track_uri_to_file_uri(
|
||||
return translator.local_uri_to_file_uri(
|
||||
uri, self.backend.config['local']['media_dir'])
|
||||
|
||||
@ -3,7 +3,7 @@ from __future__ import absolute_import, unicode_literals
|
||||
import logging
|
||||
import os
|
||||
|
||||
from mopidy.utils import encoding, path
|
||||
from mopidy.internal import encoding, path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -5,25 +5,41 @@ import os
|
||||
import urllib
|
||||
|
||||
from mopidy import compat
|
||||
from mopidy.utils.path import path_to_uri, uri_to_path
|
||||
from mopidy.internal import path
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def local_track_uri_to_file_uri(uri, media_dir):
|
||||
return path_to_uri(local_track_uri_to_path(uri, media_dir))
|
||||
def local_uri_to_file_uri(uri, media_dir):
|
||||
"""Convert local track or directory URI to file URI."""
|
||||
return path_to_file_uri(local_uri_to_path(uri, media_dir))
|
||||
|
||||
|
||||
def local_track_uri_to_path(uri, media_dir):
|
||||
if not uri.startswith('local:track:'):
|
||||
def local_uri_to_path(uri, media_dir):
|
||||
"""Convert local track or directory URI to absolute path."""
|
||||
if (
|
||||
not uri.startswith('local:directory:') and
|
||||
not uri.startswith('local:track:')):
|
||||
raise ValueError('Invalid URI.')
|
||||
file_path = uri_to_path(uri).split(b':', 1)[1]
|
||||
file_path = path.uri_to_path(uri).split(b':', 1)[1]
|
||||
return os.path.join(media_dir, file_path)
|
||||
|
||||
|
||||
def local_track_uri_to_path(uri, media_dir):
|
||||
# Deprecated version to keep old versions of Mopidy-Local-Sqlite working.
|
||||
return local_uri_to_path(uri, media_dir)
|
||||
|
||||
|
||||
def path_to_file_uri(abspath):
|
||||
"""Convert absolute path to file URI."""
|
||||
# Re-export internal method for use by Mopidy-Local-* extensions.
|
||||
return path.path_to_uri(abspath)
|
||||
|
||||
|
||||
def path_to_local_track_uri(relpath):
|
||||
"""Convert path relative to media_dir to local track URI."""
|
||||
"""Convert path relative to :confval:`local/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)
|
||||
|
||||
@ -5,9 +5,9 @@ import logging
|
||||
import pykka
|
||||
|
||||
from mopidy import backend
|
||||
from mopidy.internal import encoding, path
|
||||
from mopidy.m3u.library import M3ULibraryProvider
|
||||
from mopidy.m3u.playlists import M3UPlaylistsProvider
|
||||
from mopidy.utils import encoding, path
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -51,10 +51,11 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider):
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
else:
|
||||
logger.warn('Trying to delete missing playlist file %s', path)
|
||||
logger.warning(
|
||||
'Trying to delete missing playlist file %s', path)
|
||||
del self._playlists[uri]
|
||||
else:
|
||||
logger.warn('Trying to delete unknown playlist %s', uri)
|
||||
logger.warning('Trying to delete unknown playlist %s', uri)
|
||||
|
||||
def lookup(self, uri):
|
||||
return self._playlists.get(uri)
|
||||
@ -66,7 +67,7 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider):
|
||||
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)
|
||||
name = os.path.splitext(relpath)[0].decode(encoding, 'replace')
|
||||
tracks = translator.parse_m3u(path)
|
||||
playlists[uri] = Playlist(uri=uri, name=name, tracks=tracks)
|
||||
|
||||
@ -76,6 +77,8 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider):
|
||||
'Loaded %d M3U playlists from %s',
|
||||
len(playlists), self._playlists_dir)
|
||||
|
||||
# TODO Trigger playlists_loaded event?
|
||||
|
||||
def save(self, playlist):
|
||||
assert playlist.uri, 'Cannot save playlist without URI'
|
||||
assert playlist.uri in self._playlists, \
|
||||
@ -88,11 +91,6 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider):
|
||||
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
|
||||
@ -113,15 +111,6 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider):
|
||||
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')
|
||||
|
||||
translator.save_m3u(path, playlist.tracks, 'latin1')
|
||||
# assert playlist name matches file name/uri
|
||||
return playlist.copy(uri=uri, name=name)
|
||||
return playlist.replace(uri=uri, name=name)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import codecs
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@ -7,9 +8,8 @@ import urllib
|
||||
import urlparse
|
||||
|
||||
from mopidy import compat
|
||||
from mopidy.internal import encoding, path
|
||||
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+),(.*)')
|
||||
@ -20,7 +20,7 @@ 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)
|
||||
file_path = path.uri_to_path(uri)
|
||||
return os.path.join(playlists_dir, file_path)
|
||||
|
||||
|
||||
@ -80,7 +80,7 @@ def parse_m3u(file_path, media_dir=None):
|
||||
with open(file_path) as m3u:
|
||||
contents = m3u.readlines()
|
||||
except IOError as error:
|
||||
logger.warning('Couldn\'t open m3u: %s', locale_decode(error))
|
||||
logger.warning('Couldn\'t open m3u: %s', encoding.locale_decode(error))
|
||||
return tracks
|
||||
|
||||
if not contents:
|
||||
@ -98,13 +98,28 @@ def parse_m3u(file_path, media_dir=None):
|
||||
continue
|
||||
|
||||
if urlparse.urlsplit(line).scheme:
|
||||
tracks.append(track.copy(uri=line))
|
||||
tracks.append(track.replace(uri=line))
|
||||
elif os.path.normpath(line) == os.path.abspath(line):
|
||||
path = path_to_uri(line)
|
||||
tracks.append(track.copy(uri=path))
|
||||
uri = path.path_to_uri(line)
|
||||
tracks.append(track.replace(uri=uri))
|
||||
elif media_dir is not None:
|
||||
path = path_to_uri(os.path.join(media_dir, line))
|
||||
tracks.append(track.copy(uri=path))
|
||||
uri = path.path_to_uri(os.path.join(media_dir, line))
|
||||
tracks.append(track.replace(uri=uri))
|
||||
|
||||
track = Track()
|
||||
return tracks
|
||||
|
||||
|
||||
def save_m3u(filename, tracks, encoding='latin1', errors='replace'):
|
||||
extended = any(track.name for track in tracks)
|
||||
# codecs.open() always uses binary mode, just being explicit here
|
||||
with codecs.open(filename, 'wb', encoding, errors) as m3u:
|
||||
if extended:
|
||||
m3u.write('#EXTM3U' + os.linesep)
|
||||
for track in tracks:
|
||||
if extended and track.name:
|
||||
m3u.write('#EXTINF:%d,%s%s' % (
|
||||
track.length // 1000 if track.length else -1,
|
||||
track.name,
|
||||
os.linesep))
|
||||
m3u.write(track.uri + os.linesep)
|
||||
|
||||
@ -110,6 +110,10 @@ class Mixer(object):
|
||||
logger.debug('Mixer event: mute_changed(mute=%s)', mute)
|
||||
MixerListener.send('mute_changed', mute=mute)
|
||||
|
||||
def ping(self):
|
||||
"""Called to check if the actor is still alive."""
|
||||
return True
|
||||
|
||||
|
||||
class MixerListener(listener.Listener):
|
||||
|
||||
|
||||
@ -1,151 +1,16 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import json
|
||||
from mopidy.models import fields
|
||||
from mopidy.models.immutable import ImmutableObject, ValidatedImmutableObject
|
||||
from mopidy.models.serialize import ModelJSONEncoder, model_json_decoder
|
||||
|
||||
__all__ = [
|
||||
'ImmutableObject', 'Ref', 'Image', 'Artist', 'Album', 'track', 'TlTrack',
|
||||
'Playlist', 'SearchResult', 'model_json_decoder', 'ModelJSONEncoder',
|
||||
'ValidatedImmutableObject']
|
||||
|
||||
|
||||
class ImmutableObject(object):
|
||||
|
||||
"""
|
||||
Superclass for immutable objects whose fields can only be modified via the
|
||||
constructor.
|
||||
|
||||
:param kwargs: kwargs to set as fields on the object
|
||||
:type kwargs: any
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
for key, value in kwargs.items():
|
||||
if not hasattr(self, key) or callable(getattr(self, key)):
|
||||
raise TypeError(
|
||||
'__init__() got an unexpected keyword argument "%s"' %
|
||||
key)
|
||||
if value == getattr(self, key):
|
||||
continue # Don't explicitly set default values
|
||||
self.__dict__[key] = value
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
if name.startswith('_'):
|
||||
return super(ImmutableObject, self).__setattr__(name, value)
|
||||
raise AttributeError('Object is immutable.')
|
||||
|
||||
def __repr__(self):
|
||||
kwarg_pairs = []
|
||||
for (key, value) in sorted(self.__dict__.items()):
|
||||
if isinstance(value, (frozenset, tuple)):
|
||||
if not value:
|
||||
continue
|
||||
value = list(value)
|
||||
kwarg_pairs.append('%s=%s' % (key, repr(value)))
|
||||
return '%(classname)s(%(kwargs)s)' % {
|
||||
'classname': self.__class__.__name__,
|
||||
'kwargs': ', '.join(kwarg_pairs),
|
||||
}
|
||||
|
||||
def __hash__(self):
|
||||
hash_sum = 0
|
||||
for key, value in self.__dict__.items():
|
||||
hash_sum += hash(key) + hash(value)
|
||||
return hash_sum
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, self.__class__):
|
||||
return False
|
||||
|
||||
return self.__dict__ == other.__dict__
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def copy(self, **values):
|
||||
"""
|
||||
Copy the model with ``field`` updated to new value.
|
||||
|
||||
Examples::
|
||||
|
||||
# Returns a track with a new name
|
||||
Track(name='foo').copy(name='bar')
|
||||
# Return an album with a new number of tracks
|
||||
Album(num_tracks=2).copy(num_tracks=5)
|
||||
|
||||
:param values: the model fields to modify
|
||||
:type values: dict
|
||||
:rtype: new instance of the model being copied
|
||||
"""
|
||||
data = {}
|
||||
for key in self.__dict__.keys():
|
||||
public_key = key.lstrip('_')
|
||||
value = values.pop(public_key, self.__dict__[key])
|
||||
data[public_key] = value
|
||||
for key in values.keys():
|
||||
if hasattr(self, key):
|
||||
value = values.pop(key)
|
||||
data[key] = value
|
||||
if values:
|
||||
raise TypeError(
|
||||
'copy() got an unexpected keyword argument "%s"' % key)
|
||||
return self.__class__(**data)
|
||||
|
||||
def serialize(self):
|
||||
data = {}
|
||||
data['__model__'] = self.__class__.__name__
|
||||
for key in self.__dict__.keys():
|
||||
public_key = key.lstrip('_')
|
||||
value = self.__dict__[key]
|
||||
if isinstance(value, (set, frozenset, list, tuple)):
|
||||
value = [
|
||||
v.serialize() if isinstance(v, ImmutableObject) else v
|
||||
for v in value]
|
||||
elif isinstance(value, ImmutableObject):
|
||||
value = value.serialize()
|
||||
if not (isinstance(value, list) and len(value) == 0):
|
||||
data[public_key] = value
|
||||
return data
|
||||
|
||||
|
||||
class ModelJSONEncoder(json.JSONEncoder):
|
||||
|
||||
"""
|
||||
Automatically serialize Mopidy models to JSON.
|
||||
|
||||
Usage::
|
||||
|
||||
>>> import json
|
||||
>>> json.dumps({'a_track': Track(name='name')}, cls=ModelJSONEncoder)
|
||||
'{"a_track": {"__model__": "Track", "name": "name"}}'
|
||||
|
||||
"""
|
||||
|
||||
def default(self, obj):
|
||||
if isinstance(obj, ImmutableObject):
|
||||
return obj.serialize()
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
|
||||
|
||||
def model_json_decoder(dct):
|
||||
"""
|
||||
Automatically deserialize Mopidy models from JSON.
|
||||
|
||||
Usage::
|
||||
|
||||
>>> import json
|
||||
>>> json.loads(
|
||||
... '{"a_track": {"__model__": "Track", "name": "name"}}',
|
||||
... object_hook=model_json_decoder)
|
||||
{u'a_track': Track(artists=[], name=u'name')}
|
||||
|
||||
"""
|
||||
if '__model__' in dct:
|
||||
model_name = dct.pop('__model__')
|
||||
cls = globals().get(model_name, None)
|
||||
if issubclass(cls, ImmutableObject):
|
||||
kwargs = {}
|
||||
for key, value in dct.items():
|
||||
kwargs[key] = value
|
||||
return cls(**kwargs)
|
||||
return dct
|
||||
|
||||
|
||||
class Ref(ImmutableObject):
|
||||
class Ref(ValidatedImmutableObject):
|
||||
|
||||
"""
|
||||
Model to represent URI references with a human friendly name and type
|
||||
@ -161,14 +26,15 @@ class Ref(ImmutableObject):
|
||||
"""
|
||||
|
||||
#: The object URI. Read-only.
|
||||
uri = None
|
||||
uri = fields.URI()
|
||||
|
||||
#: The object name. Read-only.
|
||||
name = None
|
||||
name = fields.String()
|
||||
|
||||
#: The object type, e.g. "artist", "album", "track", "playlist",
|
||||
#: "directory". Read-only.
|
||||
type = None
|
||||
type = fields.Identifier() # TODO: consider locking this down.
|
||||
# type = fields.Field(choices=(ALBUM, ARTIST, DIRECTORY, PLAYLIST, TRACK))
|
||||
|
||||
#: Constant used for comparison with the :attr:`type` field.
|
||||
ALBUM = 'album'
|
||||
@ -216,7 +82,7 @@ class Ref(ImmutableObject):
|
||||
return cls(**kwargs)
|
||||
|
||||
|
||||
class Image(ImmutableObject):
|
||||
class Image(ValidatedImmutableObject):
|
||||
|
||||
"""
|
||||
:param string uri: URI of the image
|
||||
@ -225,16 +91,16 @@ class Image(ImmutableObject):
|
||||
"""
|
||||
|
||||
#: The image URI. Read-only.
|
||||
uri = None
|
||||
uri = fields.URI()
|
||||
|
||||
#: Optional width of the image or :class:`None`. Read-only.
|
||||
width = None
|
||||
width = fields.Integer(min=0)
|
||||
|
||||
#: Optional height of the image or :class:`None`. Read-only.
|
||||
height = None
|
||||
height = fields.Integer(min=0)
|
||||
|
||||
|
||||
class Artist(ImmutableObject):
|
||||
class Artist(ValidatedImmutableObject):
|
||||
|
||||
"""
|
||||
:param uri: artist URI
|
||||
@ -246,16 +112,16 @@ class Artist(ImmutableObject):
|
||||
"""
|
||||
|
||||
#: The artist URI. Read-only.
|
||||
uri = None
|
||||
uri = fields.URI()
|
||||
|
||||
#: The artist name. Read-only.
|
||||
name = None
|
||||
name = fields.String()
|
||||
|
||||
#: The MusicBrainz ID of the artist. Read-only.
|
||||
musicbrainz_id = None
|
||||
musicbrainz_id = fields.Identifier()
|
||||
|
||||
|
||||
class Album(ImmutableObject):
|
||||
class Album(ValidatedImmutableObject):
|
||||
|
||||
"""
|
||||
:param uri: album URI
|
||||
@ -277,39 +143,34 @@ class Album(ImmutableObject):
|
||||
"""
|
||||
|
||||
#: The album URI. Read-only.
|
||||
uri = None
|
||||
uri = fields.URI()
|
||||
|
||||
#: The album name. Read-only.
|
||||
name = None
|
||||
name = fields.String()
|
||||
|
||||
#: A set of album artists. Read-only.
|
||||
artists = frozenset()
|
||||
artists = fields.Collection(type=Artist, container=frozenset)
|
||||
|
||||
#: The number of tracks in the album. Read-only.
|
||||
num_tracks = None
|
||||
num_tracks = fields.Integer(min=0)
|
||||
|
||||
#: The number of discs in the album. Read-only.
|
||||
num_discs = None
|
||||
num_discs = fields.Integer(min=0)
|
||||
|
||||
#: The album release date. Read-only.
|
||||
date = None
|
||||
date = fields.Date()
|
||||
|
||||
#: The MusicBrainz ID of the album. Read-only.
|
||||
musicbrainz_id = None
|
||||
musicbrainz_id = fields.Identifier()
|
||||
|
||||
#: The album image URIs. Read-only.
|
||||
images = frozenset()
|
||||
images = fields.Collection(type=basestring, container=frozenset)
|
||||
# XXX If we want to keep the order of images we shouldn't use frozenset()
|
||||
# as it doesn't preserve order. I'm deferring this issue until we got
|
||||
# actual usage of this field with more than one image.
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.__dict__['artists'] = frozenset(kwargs.pop('artists', None) or [])
|
||||
self.__dict__['images'] = frozenset(kwargs.pop('images', None) or [])
|
||||
super(Album, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class Track(ImmutableObject):
|
||||
class Track(ValidatedImmutableObject):
|
||||
|
||||
"""
|
||||
:param uri: track URI
|
||||
@ -345,64 +206,55 @@ class Track(ImmutableObject):
|
||||
"""
|
||||
|
||||
#: The track URI. Read-only.
|
||||
uri = None
|
||||
uri = fields.URI()
|
||||
|
||||
#: The track name. Read-only.
|
||||
name = None
|
||||
name = fields.String()
|
||||
|
||||
#: A set of track artists. Read-only.
|
||||
artists = frozenset()
|
||||
artists = fields.Collection(type=Artist, container=frozenset)
|
||||
|
||||
#: The track :class:`Album`. Read-only.
|
||||
album = None
|
||||
album = fields.Field(type=Album)
|
||||
|
||||
#: A set of track composers. Read-only.
|
||||
composers = frozenset()
|
||||
composers = fields.Collection(type=Artist, container=frozenset)
|
||||
|
||||
#: A set of track performers`. Read-only.
|
||||
performers = frozenset()
|
||||
performers = fields.Collection(type=Artist, container=frozenset)
|
||||
|
||||
#: The track genre. Read-only.
|
||||
genre = None
|
||||
genre = fields.String()
|
||||
|
||||
#: The track number in the album. Read-only.
|
||||
track_no = None
|
||||
track_no = fields.Integer(min=0)
|
||||
|
||||
#: The disc number in the album. Read-only.
|
||||
disc_no = None
|
||||
disc_no = fields.Integer(min=0)
|
||||
|
||||
#: The track release date. Read-only.
|
||||
date = None
|
||||
date = fields.Date()
|
||||
|
||||
#: The track length in milliseconds. Read-only.
|
||||
length = None
|
||||
length = fields.Integer(min=0)
|
||||
|
||||
#: The track's bitrate in kbit/s. Read-only.
|
||||
bitrate = None
|
||||
bitrate = fields.Integer(min=0)
|
||||
|
||||
#: The track comment. Read-only.
|
||||
comment = None
|
||||
comment = fields.String()
|
||||
|
||||
#: The MusicBrainz ID of the track. Read-only.
|
||||
musicbrainz_id = None
|
||||
musicbrainz_id = fields.Identifier()
|
||||
|
||||
#: 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):
|
||||
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')
|
||||
super(Track, self).__init__(*args, **kwargs)
|
||||
last_modified = fields.Integer(min=0)
|
||||
|
||||
|
||||
class TlTrack(ImmutableObject):
|
||||
class TlTrack(ValidatedImmutableObject):
|
||||
|
||||
"""
|
||||
A tracklist track. Wraps a regular track and it's tracklist ID.
|
||||
@ -425,10 +277,10 @@ class TlTrack(ImmutableObject):
|
||||
"""
|
||||
|
||||
#: The tracklist ID. Read-only.
|
||||
tlid = None
|
||||
tlid = fields.Integer(min=0)
|
||||
|
||||
#: The track. Read-only.
|
||||
track = None
|
||||
track = fields.Field(type=Track)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if len(args) == 2 and len(kwargs) == 0:
|
||||
@ -441,7 +293,7 @@ class TlTrack(ImmutableObject):
|
||||
return iter([self.tlid, self.track])
|
||||
|
||||
|
||||
class Playlist(ImmutableObject):
|
||||
class Playlist(ValidatedImmutableObject):
|
||||
|
||||
"""
|
||||
:param uri: playlist URI
|
||||
@ -456,23 +308,19 @@ class Playlist(ImmutableObject):
|
||||
"""
|
||||
|
||||
#: The playlist URI. Read-only.
|
||||
uri = None
|
||||
uri = fields.URI()
|
||||
|
||||
#: The playlist name. Read-only.
|
||||
name = None
|
||||
name = fields.String()
|
||||
|
||||
#: The playlist's tracks. Read-only.
|
||||
tracks = tuple()
|
||||
tracks = fields.Collection(type=Track, container=tuple)
|
||||
|
||||
#: The playlist modification time in milliseconds since Unix epoch.
|
||||
#: Read-only.
|
||||
#:
|
||||
#: Integer, or :class:`None` if unknown.
|
||||
last_modified = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.__dict__['tracks'] = tuple(kwargs.pop('tracks', None) or [])
|
||||
super(Playlist, self).__init__(*args, **kwargs)
|
||||
last_modified = fields.Integer(min=0)
|
||||
|
||||
# TODO: def insert(self, pos, track): ... ?
|
||||
|
||||
@ -482,7 +330,7 @@ class Playlist(ImmutableObject):
|
||||
return len(self.tracks)
|
||||
|
||||
|
||||
class SearchResult(ImmutableObject):
|
||||
class SearchResult(ValidatedImmutableObject):
|
||||
|
||||
"""
|
||||
:param uri: search result URI
|
||||
@ -496,19 +344,13 @@ class SearchResult(ImmutableObject):
|
||||
"""
|
||||
|
||||
# The search result URI. Read-only.
|
||||
uri = None
|
||||
uri = fields.URI()
|
||||
|
||||
# The tracks matching the search query. Read-only.
|
||||
tracks = tuple()
|
||||
tracks = fields.Collection(type=Track, container=tuple)
|
||||
|
||||
# The artists matching the search query. Read-only.
|
||||
artists = tuple()
|
||||
artists = fields.Collection(type=Artist, container=tuple)
|
||||
|
||||
# The albums matching the search query. Read-only.
|
||||
albums = tuple()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.__dict__['tracks'] = tuple(kwargs.pop('tracks', None) or [])
|
||||
self.__dict__['artists'] = tuple(kwargs.pop('artists', None) or [])
|
||||
self.__dict__['albums'] = tuple(kwargs.pop('albums', None) or [])
|
||||
super(SearchResult, self).__init__(*args, **kwargs)
|
||||
albums = fields.Collection(type=Album, container=tuple)
|
||||
154
mopidy/models/fields.py
Normal file
154
mopidy/models/fields.py
Normal file
@ -0,0 +1,154 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
|
||||
class Field(object):
|
||||
|
||||
"""
|
||||
Base field for use in
|
||||
:class:`~mopidy.models.immutable.ValidatedImmutableObject`. These fields
|
||||
are responsible for type checking and other data sanitation in our models.
|
||||
|
||||
For simplicity fields use the Python descriptor protocol to store the
|
||||
values in the instance dictionary. Also note that fields are mutable if
|
||||
the object they are attached to allow it.
|
||||
|
||||
Default values will be validated with the exception of :class:`None`.
|
||||
|
||||
:param default: default value for field
|
||||
:param type: if set the field value must be of this type
|
||||
:param choices: if set the field value must be one of these
|
||||
"""
|
||||
|
||||
def __init__(self, default=None, type=None, choices=None):
|
||||
self._name = None # Set by ValidatedImmutableObjectMeta
|
||||
self._choices = choices
|
||||
self._default = default
|
||||
self._type = type
|
||||
|
||||
if self._default is not None:
|
||||
self.validate(self._default)
|
||||
|
||||
def validate(self, value):
|
||||
"""Validate and possibly modify the field value before assignment"""
|
||||
if self._type and not isinstance(value, self._type):
|
||||
raise TypeError('Expected %s to be a %s, not %r' %
|
||||
(self._name, self._type, value))
|
||||
if self._choices and value not in self._choices:
|
||||
raise TypeError('Expected %s to be a one of %s, not %r' %
|
||||
(self._name, self._choices, value))
|
||||
return value
|
||||
|
||||
def __get__(self, instance, owner):
|
||||
if not instance:
|
||||
return self
|
||||
return getattr(instance, '_' + self._name, self._default)
|
||||
|
||||
def __set__(self, instance, value):
|
||||
if value is not None:
|
||||
value = self.validate(value)
|
||||
|
||||
if value is None or value == self._default:
|
||||
self.__delete__(instance)
|
||||
else:
|
||||
setattr(instance, '_' + self._name, value)
|
||||
|
||||
def __delete__(self, instance):
|
||||
if hasattr(instance, '_' + self._name):
|
||||
delattr(instance, '_' + self._name)
|
||||
|
||||
|
||||
class String(Field):
|
||||
|
||||
"""
|
||||
Specialized :class:`Field` which is wired up for bytes and unicode.
|
||||
|
||||
:param default: default value for field
|
||||
"""
|
||||
|
||||
def __init__(self, default=None):
|
||||
# TODO: normalize to unicode?
|
||||
# TODO: only allow unicode?
|
||||
# TODO: disallow empty strings?
|
||||
super(String, self).__init__(type=basestring, default=default)
|
||||
|
||||
|
||||
class Date(String):
|
||||
"""
|
||||
:class:`Field` for storing ISO 8601 dates as a string.
|
||||
|
||||
Supported formats are ``YYYY-MM-DD``, ``YYYY-MM`` and ``YYYY``, currently
|
||||
not validated.
|
||||
|
||||
:param default: default value for field
|
||||
"""
|
||||
pass # TODO: make this check for YYYY-MM-DD, YYYY-MM, YYYY using strptime.
|
||||
|
||||
|
||||
class Identifier(String):
|
||||
"""
|
||||
:class:`Field` for storing ASCII values such as GUIDs or other identifiers.
|
||||
|
||||
Values will be interned.
|
||||
|
||||
:param default: default value for field
|
||||
"""
|
||||
def validate(self, value):
|
||||
return intern(str(super(Identifier, self).validate(value)))
|
||||
|
||||
|
||||
class URI(Identifier):
|
||||
"""
|
||||
:class:`Field` for storing URIs
|
||||
|
||||
Values will be interned, currently not validated.
|
||||
|
||||
:param default: default value for field
|
||||
"""
|
||||
pass # TODO: validate URIs?
|
||||
|
||||
|
||||
class Integer(Field):
|
||||
"""
|
||||
:class:`Field` for storing integer numbers.
|
||||
|
||||
:param default: default value for field
|
||||
:param min: field value must be larger or equal to this value when set
|
||||
:param max: field value must be smaller or equal to this value when set
|
||||
"""
|
||||
|
||||
def __init__(self, default=None, min=None, max=None):
|
||||
self._min = min
|
||||
self._max = max
|
||||
super(Integer, self).__init__(type=(int, long), default=default)
|
||||
|
||||
def validate(self, value):
|
||||
value = super(Integer, self).validate(value)
|
||||
if self._min is not None and value < self._min:
|
||||
raise ValueError('Expected %s to be at least %d, not %d' %
|
||||
(self._name, self._min, value))
|
||||
if self._max is not None and value > self._max:
|
||||
raise ValueError('Expected %s to be at most %d, not %d' %
|
||||
(self._name, self._max, value))
|
||||
return value
|
||||
|
||||
|
||||
class Collection(Field):
|
||||
"""
|
||||
:class:`Field` for storing collections of a given type.
|
||||
|
||||
:param type: all items stored in the collection must be of this type
|
||||
:param container: the type to store the items in
|
||||
"""
|
||||
|
||||
def __init__(self, type, container=tuple):
|
||||
super(Collection, self).__init__(type=type, default=container())
|
||||
|
||||
def validate(self, value):
|
||||
if isinstance(value, basestring):
|
||||
raise TypeError('Expected %s to be a collection of %s, not %r'
|
||||
% (self._name, self._type.__name__, value))
|
||||
for v in value:
|
||||
if not isinstance(v, self._type):
|
||||
raise TypeError('Expected %s to be a collection of %s, not %r'
|
||||
% (self._name, self._type.__name__, value))
|
||||
return self._default.__class__(value) or None
|
||||
217
mopidy/models/immutable.py
Normal file
217
mopidy/models/immutable.py
Normal file
@ -0,0 +1,217 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import copy
|
||||
import itertools
|
||||
import weakref
|
||||
|
||||
from mopidy.internal import deprecation
|
||||
from mopidy.models.fields import Field
|
||||
|
||||
|
||||
class ImmutableObject(object):
|
||||
"""
|
||||
Superclass for immutable objects whose fields can only be modified via the
|
||||
constructor.
|
||||
|
||||
This version of this class has been retained to avoid breaking any clients
|
||||
relying on it's behavior. Internally in Mopidy we now use
|
||||
:class:`ValidatedImmutableObject` for type safety and it's much smaller
|
||||
memory footprint.
|
||||
|
||||
:param kwargs: kwargs to set as fields on the object
|
||||
:type kwargs: any
|
||||
"""
|
||||
|
||||
# Any sub-classes that don't set slots won't be effected by the base using
|
||||
# slots as they will still get an instance dict.
|
||||
__slots__ = ['__weakref__']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
for key, value in kwargs.items():
|
||||
if not self._is_valid_field(key):
|
||||
raise TypeError(
|
||||
'__init__() got an unexpected keyword argument "%s"' % key)
|
||||
self._set_field(key, value)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
if name.startswith('_'):
|
||||
object.__setattr__(self, name, value)
|
||||
else:
|
||||
raise AttributeError('Object is immutable.')
|
||||
|
||||
def __delattr__(self, name):
|
||||
if name.startswith('_'):
|
||||
object.__delattr__(self, name)
|
||||
else:
|
||||
raise AttributeError('Object is immutable.')
|
||||
|
||||
def _is_valid_field(self, name):
|
||||
return hasattr(self, name) and not callable(getattr(self, name))
|
||||
|
||||
def _set_field(self, name, value):
|
||||
if value == getattr(self.__class__, name):
|
||||
self.__dict__.pop(name, None)
|
||||
else:
|
||||
self.__dict__[name] = value
|
||||
|
||||
def _items(self):
|
||||
return self.__dict__.iteritems()
|
||||
|
||||
def __repr__(self):
|
||||
kwarg_pairs = []
|
||||
for key, value in sorted(self._items()):
|
||||
if isinstance(value, (frozenset, tuple)):
|
||||
if not value:
|
||||
continue
|
||||
value = list(value)
|
||||
kwarg_pairs.append('%s=%s' % (key, repr(value)))
|
||||
return '%(classname)s(%(kwargs)s)' % {
|
||||
'classname': self.__class__.__name__,
|
||||
'kwargs': ', '.join(kwarg_pairs),
|
||||
}
|
||||
|
||||
def __hash__(self):
|
||||
hash_sum = 0
|
||||
for key, value in self._items():
|
||||
hash_sum += hash(key) + hash(value)
|
||||
return hash_sum
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, self.__class__):
|
||||
return False
|
||||
return all(a == b for a, b in itertools.izip_longest(
|
||||
self._items(), other._items(), fillvalue=object()))
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def copy(self, **values):
|
||||
"""
|
||||
.. deprecated:: 1.1
|
||||
Use :meth:`replace` instead.
|
||||
"""
|
||||
deprecation.warn('model.immutable.copy')
|
||||
return self.replace(**values)
|
||||
|
||||
def replace(self, **kwargs):
|
||||
"""
|
||||
Replace the fields in the model and return a new instance
|
||||
|
||||
Examples::
|
||||
|
||||
# Returns a track with a new name
|
||||
Track(name='foo').replace(name='bar')
|
||||
# Return an album with a new number of tracks
|
||||
Album(num_tracks=2).replace(num_tracks=5)
|
||||
|
||||
:param kwargs: kwargs to set as fields on the object
|
||||
:type kwargs: any
|
||||
:rtype: instance of the model with replaced fields
|
||||
"""
|
||||
other = copy.copy(self)
|
||||
for key, value in kwargs.items():
|
||||
if not self._is_valid_field(key):
|
||||
raise TypeError(
|
||||
'copy() got an unexpected keyword argument "%s"' % key)
|
||||
other._set_field(key, value)
|
||||
return other
|
||||
|
||||
def serialize(self):
|
||||
data = {}
|
||||
data['__model__'] = self.__class__.__name__
|
||||
for key, value in self._items():
|
||||
if isinstance(value, (set, frozenset, list, tuple)):
|
||||
value = [
|
||||
v.serialize() if isinstance(v, ImmutableObject) else v
|
||||
for v in value]
|
||||
elif isinstance(value, ImmutableObject):
|
||||
value = value.serialize()
|
||||
if not (isinstance(value, list) and len(value) == 0):
|
||||
data[key] = value
|
||||
return data
|
||||
|
||||
|
||||
class _ValidatedImmutableObjectMeta(type):
|
||||
|
||||
"""Helper that initializes fields, slots and memoizes instance creation."""
|
||||
|
||||
def __new__(cls, name, bases, attrs):
|
||||
fields = {}
|
||||
|
||||
for base in bases: # Copy parent fields over to our state
|
||||
fields.update(getattr(base, '_fields', {}))
|
||||
|
||||
for key, value in attrs.items(): # Add our own fields
|
||||
if isinstance(value, Field):
|
||||
fields[key] = '_' + key
|
||||
value._name = key
|
||||
|
||||
attrs['_fields'] = fields
|
||||
attrs['_instances'] = weakref.WeakValueDictionary()
|
||||
attrs['__slots__'] = list(attrs.get('__slots__', [])) + fields.values()
|
||||
|
||||
return super(_ValidatedImmutableObjectMeta, cls).__new__(
|
||||
cls, name, bases, attrs)
|
||||
|
||||
def __call__(cls, *args, **kwargs): # noqa: N805
|
||||
instance = super(_ValidatedImmutableObjectMeta, cls).__call__(
|
||||
*args, **kwargs)
|
||||
return cls._instances.setdefault(weakref.ref(instance), instance)
|
||||
|
||||
|
||||
class ValidatedImmutableObject(ImmutableObject):
|
||||
"""
|
||||
Superclass for immutable objects whose fields can only be modified via the
|
||||
constructor. Fields should be :class:`Field` instances to ensure type
|
||||
safety in our models.
|
||||
|
||||
Note that since these models can not be changed, we heavily memoize them
|
||||
to save memory. So constructing a class with the same arguments twice will
|
||||
give you the same instance twice.
|
||||
"""
|
||||
|
||||
__metaclass__ = _ValidatedImmutableObjectMeta
|
||||
__slots__ = ['_hash']
|
||||
|
||||
def __hash__(self):
|
||||
if not hasattr(self, '_hash'):
|
||||
hash_sum = super(ValidatedImmutableObject, self).__hash__()
|
||||
object.__setattr__(self, '_hash', hash_sum)
|
||||
return self._hash
|
||||
|
||||
def _is_valid_field(self, name):
|
||||
return name in self._fields
|
||||
|
||||
def _set_field(self, name, value):
|
||||
object.__setattr__(self, name, value)
|
||||
|
||||
def _items(self):
|
||||
for field, key in self._fields.items():
|
||||
if hasattr(self, key):
|
||||
yield field, getattr(self, key)
|
||||
|
||||
def replace(self, **kwargs):
|
||||
"""
|
||||
Replace the fields in the model and return a new instance
|
||||
|
||||
Examples::
|
||||
|
||||
# Returns a track with a new name
|
||||
Track(name='foo').replace(name='bar')
|
||||
# Return an album with a new number of tracks
|
||||
Album(num_tracks=2).replace(num_tracks=5)
|
||||
|
||||
Note that internally we memoize heavily to keep memory usage down given
|
||||
our overly repetitive data structures. So you might get an existing
|
||||
instance if it contains the same values.
|
||||
|
||||
:param kwargs: kwargs to set as fields on the object
|
||||
:type kwargs: any
|
||||
:rtype: instance of the model with replaced fields
|
||||
"""
|
||||
if not kwargs:
|
||||
return self
|
||||
other = super(ValidatedImmutableObject, self).replace(**kwargs)
|
||||
if hasattr(self, '_hash'):
|
||||
object.__delattr__(other, '_hash')
|
||||
return self._instances.setdefault(weakref.ref(other), other)
|
||||
47
mopidy/models/serialize.py
Normal file
47
mopidy/models/serialize.py
Normal file
@ -0,0 +1,47 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import json
|
||||
|
||||
from mopidy.models import immutable
|
||||
|
||||
_MODELS = ['Ref', 'Artist', 'Album', 'Track', 'TlTrack', 'Playlist']
|
||||
|
||||
|
||||
class ModelJSONEncoder(json.JSONEncoder):
|
||||
|
||||
"""
|
||||
Automatically serialize Mopidy models to JSON.
|
||||
|
||||
Usage::
|
||||
|
||||
>>> import json
|
||||
>>> json.dumps({'a_track': Track(name='name')}, cls=ModelJSONEncoder)
|
||||
'{"a_track": {"__model__": "Track", "name": "name"}}'
|
||||
|
||||
"""
|
||||
|
||||
def default(self, obj):
|
||||
if isinstance(obj, immutable.ImmutableObject):
|
||||
return obj.serialize()
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
|
||||
|
||||
def model_json_decoder(dct):
|
||||
"""
|
||||
Automatically deserialize Mopidy models from JSON.
|
||||
|
||||
Usage::
|
||||
|
||||
>>> import json
|
||||
>>> json.loads(
|
||||
... '{"a_track": {"__model__": "Track", "name": "name"}}',
|
||||
... object_hook=model_json_decoder)
|
||||
{u'a_track': Track(artists=[], name=u'name')}
|
||||
|
||||
"""
|
||||
if '__model__' in dct:
|
||||
from mopidy import models
|
||||
model_name = dct.pop('__model__')
|
||||
if model_name in _MODELS:
|
||||
return getattr(models, model_name)(**dct)
|
||||
return dct
|
||||
@ -6,8 +6,8 @@ import pykka
|
||||
|
||||
from mopidy import exceptions, zeroconf
|
||||
from mopidy.core import CoreListener
|
||||
from mopidy.internal import encoding, network, process
|
||||
from mopidy.mpd import session, uri_mapper
|
||||
from mopidy.utils import encoding, network, process
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -167,7 +167,8 @@ class MpdDispatcher(object):
|
||||
# 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])
|
||||
logger.warning(
|
||||
'MPD client used blacklisted command: %s', tokens[0])
|
||||
raise exceptions.MpdDisabled(command=tokens[0])
|
||||
try:
|
||||
return protocol.commands.call(tokens, context=self.context)
|
||||
|
||||
@ -22,8 +22,8 @@ ENCODING = 'UTF-8'
|
||||
#: The MPD protocol uses ``\n`` as line terminator.
|
||||
LINE_TERMINATOR = '\n'
|
||||
|
||||
#: The MPD protocol version is 0.17.0.
|
||||
VERSION = '0.17.0'
|
||||
#: The MPD protocol version is 0.19.0.
|
||||
VERSION = '0.19.0'
|
||||
|
||||
|
||||
def load_protocol_modules():
|
||||
@ -33,7 +33,8 @@ def load_protocol_modules():
|
||||
"""
|
||||
from . import ( # noqa
|
||||
audio_output, channels, command_list, connection, current_playlist,
|
||||
music_db, playback, reflection, status, stickers, stored_playlists)
|
||||
mount, music_db, playback, reflection, status, stickers,
|
||||
stored_playlists)
|
||||
|
||||
|
||||
def INT(value): # noqa: N802
|
||||
|
||||
@ -8,7 +8,7 @@ def disableoutput(context, outputid):
|
||||
"""
|
||||
*musicpd.org, audio output section:*
|
||||
|
||||
``disableoutput``
|
||||
``disableoutput {ID}``
|
||||
|
||||
Turns an output off.
|
||||
"""
|
||||
@ -25,7 +25,7 @@ def enableoutput(context, outputid):
|
||||
"""
|
||||
*musicpd.org, audio output section:*
|
||||
|
||||
``enableoutput``
|
||||
``enableoutput {ID}``
|
||||
|
||||
Turns an output on.
|
||||
"""
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import urlparse
|
||||
|
||||
from mopidy.internal import deprecation
|
||||
from mopidy.mpd import exceptions, protocol, translator
|
||||
from mopidy.utils import deprecation
|
||||
|
||||
|
||||
@protocol.commands.add('add')
|
||||
@ -21,8 +23,11 @@ def add(context, uri):
|
||||
if not uri.strip('/'):
|
||||
return
|
||||
|
||||
if context.core.tracklist.add(uris=[uri]).get():
|
||||
return
|
||||
# If we have an URI just try and add it directly without bothering with
|
||||
# jumping through browse...
|
||||
if urlparse.urlparse(uri).scheme != '':
|
||||
if context.core.tracklist.add(uris=[uri]).get():
|
||||
return
|
||||
|
||||
try:
|
||||
uris = []
|
||||
@ -59,17 +64,21 @@ def addid(context, uri, songpos=None):
|
||||
"""
|
||||
if not uri:
|
||||
raise exceptions.MpdNoExistError('No such song')
|
||||
if songpos is not None and songpos > context.core.tracklist.length.get():
|
||||
|
||||
length = context.core.tracklist.get_length()
|
||||
if songpos is not None and songpos > length.get():
|
||||
raise exceptions.MpdArgError('Bad song index')
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@protocol.commands.add('delete', position=protocol.RANGE)
|
||||
def delete(context, position):
|
||||
@protocol.commands.add('delete', songrange=protocol.RANGE)
|
||||
def delete(context, songrange):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
|
||||
@ -77,15 +86,15 @@ def delete(context, position):
|
||||
|
||||
Deletes a song from the playlist.
|
||||
"""
|
||||
start = position.start
|
||||
end = position.stop
|
||||
start = songrange.start
|
||||
end = songrange.stop
|
||||
if end is None:
|
||||
end = context.core.tracklist.length.get()
|
||||
end = context.core.tracklist.get_length().get()
|
||||
tl_tracks = context.core.tracklist.slice(start, end).get()
|
||||
if not tl_tracks:
|
||||
raise exceptions.MpdArgError('Bad song index', command='delete')
|
||||
for (tlid, _) in tl_tracks:
|
||||
context.core.tracklist.remove(tlid=[tlid])
|
||||
context.core.tracklist.remove({'tlid': [tlid]})
|
||||
|
||||
|
||||
@protocol.commands.add('deleteid', tlid=protocol.UINT)
|
||||
@ -97,7 +106,7 @@ def deleteid(context, tlid):
|
||||
|
||||
Deletes the song ``SONGID`` from the playlist
|
||||
"""
|
||||
tl_tracks = context.core.tracklist.remove(tlid=[tlid]).get()
|
||||
tl_tracks = context.core.tracklist.remove({'tlid': [tlid]}).get()
|
||||
if not tl_tracks:
|
||||
raise exceptions.MpdNoExistError('No such song')
|
||||
|
||||
@ -114,8 +123,8 @@ def clear(context):
|
||||
context.core.tracklist.clear()
|
||||
|
||||
|
||||
@protocol.commands.add('move', position=protocol.RANGE, to=protocol.UINT)
|
||||
def move_range(context, position, to):
|
||||
@protocol.commands.add('move', songrange=protocol.RANGE, to=protocol.UINT)
|
||||
def move_range(context, songrange, to):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
|
||||
@ -124,10 +133,10 @@ def move_range(context, position, to):
|
||||
Moves the song at ``FROM`` or range of songs at ``START:END`` to
|
||||
``TO`` in the playlist.
|
||||
"""
|
||||
start = position.start
|
||||
end = position.stop
|
||||
start = songrange.start
|
||||
end = songrange.stop
|
||||
if end is None:
|
||||
end = context.core.tracklist.length.get()
|
||||
end = context.core.tracklist.get_length().get()
|
||||
context.core.tracklist.move(start, end, to)
|
||||
|
||||
|
||||
@ -142,7 +151,7 @@ def moveid(context, tlid, to):
|
||||
the playlist. If ``TO`` is negative, it is relative to the current
|
||||
song in the playlist (if there is one).
|
||||
"""
|
||||
tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get()
|
||||
tl_tracks = context.core.tracklist.filter({'tlid': [tlid]}).get()
|
||||
if not tl_tracks:
|
||||
raise exceptions.MpdNoExistError('No such song')
|
||||
position = context.core.tracklist.index(tl_tracks[0]).get()
|
||||
@ -174,13 +183,9 @@ def playlistfind(context, tag, needle):
|
||||
``playlistfind {TAG} {NEEDLE}``
|
||||
|
||||
Finds songs in the current playlist with strict matching.
|
||||
|
||||
*GMPC:*
|
||||
|
||||
- does not add quotes around the tag.
|
||||
"""
|
||||
if tag == 'filename':
|
||||
tl_tracks = context.core.tracklist.filter(uri=[needle]).get()
|
||||
tl_tracks = context.core.tracklist.filter({'uri': [needle]}).get()
|
||||
if not tl_tracks:
|
||||
return None
|
||||
position = context.core.tracklist.index(tl_tracks[0]).get()
|
||||
@ -199,14 +204,14 @@ def playlistid(context, tlid=None):
|
||||
and specifies a single song to display info for.
|
||||
"""
|
||||
if tlid is not None:
|
||||
tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get()
|
||||
tl_tracks = context.core.tracklist.filter({'tlid': [tlid]}).get()
|
||||
if not tl_tracks:
|
||||
raise exceptions.MpdNoExistError('No such song')
|
||||
position = context.core.tracklist.index(tl_tracks[0]).get()
|
||||
return translator.track_to_mpd_format(tl_tracks[0], position=position)
|
||||
else:
|
||||
return translator.tracks_to_mpd_format(
|
||||
context.core.tracklist.tl_tracks.get())
|
||||
context.core.tracklist.get_tl_tracks().get())
|
||||
|
||||
|
||||
@protocol.commands.add('playlistinfo')
|
||||
@ -231,7 +236,7 @@ def playlistinfo(context, parameter=None):
|
||||
tracklist_slice = protocol.RANGE(parameter)
|
||||
start, end = tracklist_slice.start, tracklist_slice.stop
|
||||
|
||||
tl_tracks = context.core.tracklist.tl_tracks.get()
|
||||
tl_tracks = context.core.tracklist.get_tl_tracks().get()
|
||||
if start and start > len(tl_tracks):
|
||||
raise exceptions.MpdArgError('Bad song index')
|
||||
if end and end > len(tl_tracks):
|
||||
@ -251,7 +256,6 @@ def playlistsearch(context, tag, needle):
|
||||
|
||||
*GMPC:*
|
||||
|
||||
- does not add quotes around the tag
|
||||
- uses ``filename`` and ``any`` as tags
|
||||
"""
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
@ -274,10 +278,10 @@ 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
|
||||
tracklist_version = context.core.tracklist.version.get()
|
||||
tracklist_version = context.core.tracklist.get_version().get()
|
||||
if version < tracklist_version:
|
||||
return translator.tracks_to_mpd_format(
|
||||
context.core.tracklist.tl_tracks.get())
|
||||
context.core.tracklist.get_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.
|
||||
@ -285,7 +289,7 @@ def plchanges(context, version):
|
||||
if stream_title is None:
|
||||
return None
|
||||
|
||||
tl_track = context.core.playback.current_tl_track.get()
|
||||
tl_track = context.core.playback.get_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)
|
||||
@ -306,17 +310,65 @@ def plchangesposid(context, version):
|
||||
``playlistlength`` returned by status command.
|
||||
"""
|
||||
# XXX Naive implementation that returns all tracks as changed
|
||||
if int(version) != context.core.tracklist.version.get():
|
||||
if int(version) != context.core.tracklist.get_version().get():
|
||||
result = []
|
||||
for (position, (tlid, _)) in enumerate(
|
||||
context.core.tracklist.tl_tracks.get()):
|
||||
context.core.tracklist.get_tl_tracks().get()):
|
||||
result.append(('cpos', position))
|
||||
result.append(('Id', tlid))
|
||||
return result
|
||||
|
||||
|
||||
@protocol.commands.add('shuffle', position=protocol.RANGE)
|
||||
def shuffle(context, position=None):
|
||||
@protocol.commands.add(
|
||||
'prio', priority=protocol.UINT, position=protocol.RANGE)
|
||||
def prio(context, priority, position):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
|
||||
``prio {PRIORITY} {START:END...}``
|
||||
|
||||
Set the priority of the specified songs. A higher priority means that
|
||||
it will be played first when "random" mode is enabled.
|
||||
|
||||
A priority is an integer between 0 and 255. The default priority of new
|
||||
songs is 0.
|
||||
"""
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
|
||||
@protocol.commands.add('prioid')
|
||||
def prioid(context, *args):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
|
||||
``prioid {PRIORITY} {ID...}``
|
||||
|
||||
Same as prio, but address the songs with their id.
|
||||
"""
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
|
||||
@protocol.commands.add('rangeid', tlid=protocol.UINT, songrange=protocol.RANGE)
|
||||
def rangeid(context, tlid, songrange):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
|
||||
``rangeid {ID} {START:END}``
|
||||
|
||||
Specifies the portion of the song that shall be played. START and END
|
||||
are offsets in seconds (fractional seconds allowed); both are optional.
|
||||
Omitting both (i.e. sending just ":") means "remove the range, play
|
||||
everything". A song that is currently playing cannot be manipulated
|
||||
this way.
|
||||
|
||||
.. versionadded:: 0.19
|
||||
New in MPD protocol version 0.19
|
||||
"""
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
|
||||
@protocol.commands.add('shuffle', songrange=protocol.RANGE)
|
||||
def shuffle(context, songrange=None):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
|
||||
@ -325,10 +377,10 @@ def shuffle(context, position=None):
|
||||
Shuffles the current playlist. ``START:END`` is optional and
|
||||
specifies a range of songs.
|
||||
"""
|
||||
if position is None:
|
||||
if songrange is None:
|
||||
start, end = None, None
|
||||
else:
|
||||
start, end = position.start, position.stop
|
||||
start, end = songrange.start, songrange.stop
|
||||
context.core.tracklist.shuffle(start, end)
|
||||
|
||||
|
||||
@ -341,7 +393,7 @@ def swap(context, songpos1, songpos2):
|
||||
|
||||
Swaps the positions of ``SONG1`` and ``SONG2``.
|
||||
"""
|
||||
tracks = context.core.tracklist.tracks.get()
|
||||
tracks = context.core.tracklist.get_tracks().get()
|
||||
song1 = tracks[songpos1]
|
||||
song2 = tracks[songpos2]
|
||||
del tracks[songpos1]
|
||||
@ -365,8 +417,8 @@ def swapid(context, tlid1, tlid2):
|
||||
|
||||
Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids).
|
||||
"""
|
||||
tl_tracks1 = context.core.tracklist.filter(tlid=[tlid1]).get()
|
||||
tl_tracks2 = context.core.tracklist.filter(tlid=[tlid2]).get()
|
||||
tl_tracks1 = context.core.tracklist.filter({'tlid': [tlid1]}).get()
|
||||
tl_tracks2 = context.core.tracklist.filter({'tlid': [tlid2]}).get()
|
||||
if not tl_tracks1 or not tl_tracks2:
|
||||
raise exceptions.MpdNoExistError('No such song')
|
||||
position1 = context.core.tracklist.index(tl_tracks1[0]).get()
|
||||
@ -374,39 +426,7 @@ def swapid(context, tlid1, tlid2):
|
||||
swap(context, position1, position2)
|
||||
|
||||
|
||||
# TODO: add at least reflection tests before adding NotImplemented version
|
||||
# @protocol.commands.add(
|
||||
# 'prio', priority=protocol.UINT, position=protocol.RANGE)
|
||||
def prio(context, priority, position):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
|
||||
``prio {PRIORITY} {START:END...}``
|
||||
|
||||
Set the priority of the specified songs. A higher priority means that
|
||||
it will be played first when "random" mode is enabled.
|
||||
|
||||
A priority is an integer between 0 and 255. The default priority of new
|
||||
songs is 0.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
# TODO: add at least reflection tests before adding NotImplemented version
|
||||
# @protocol.commands.add('prioid')
|
||||
def prioid(context, *args):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
|
||||
``prioid {PRIORITY} {ID...}``
|
||||
|
||||
Same as prio, but address the songs with their id.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
# TODO: add at least reflection tests before adding NotImplemented version
|
||||
# @protocol.commands.add('addtagid', tlid=protocol.UINT)
|
||||
@protocol.commands.add('addtagid', tlid=protocol.UINT)
|
||||
def addtagid(context, tlid, tag, value):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
@ -417,12 +437,14 @@ def addtagid(context, tlid, tag, value):
|
||||
for remote songs. This change is volatile: it may be overwritten by
|
||||
tags received from the server, and the data is gone when the song gets
|
||||
removed from the queue.
|
||||
|
||||
.. versionadded:: 0.19
|
||||
New in MPD protocol version 0.19
|
||||
"""
|
||||
pass
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
|
||||
# TODO: add at least reflection tests before adding NotImplemented version
|
||||
# @protocol.commands.add('cleartagid', tlid=protocol.UINT)
|
||||
@protocol.commands.add('cleartagid', tlid=protocol.UINT)
|
||||
def cleartagid(context, tlid, tag):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
@ -432,5 +454,8 @@ def cleartagid(context, tlid, tag):
|
||||
Removes tags from the specified song. If TAG is not specified, then all
|
||||
tag values will be removed. Editing song tags is only possible for
|
||||
remote songs.
|
||||
|
||||
.. versionadded:: 0.19
|
||||
New in MPD protocol version 0.19
|
||||
"""
|
||||
pass
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user