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:
Thomas Adamcik 2015-07-22 20:00:46 +02:00
commit 3fe9f7b3a7
159 changed files with 5145 additions and 1565 deletions

View File

@ -23,4 +23,5 @@ Ignasi Fosch <natx@y10k.ws> <ifosch@serenity-2.local>
Christopher Schirner <christopher@hackerspace-bamberg.de> <schinken@hackerspace-bamberg.de> Christopher Schirner <christopher@hackerspace-bamberg.de> <schinken@hackerspace-bamberg.de>
Laura Barber <laura.c.barber@gmail.com> <artzii.laura@gmail.com> Laura Barber <laura.c.barber@gmail.com> <artzii.laura@gmail.com>
John Cass <john.cass77@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>

View File

@ -52,4 +52,10 @@
- John Cass <john.cass77@gmail.com> - John Cass <john.cass77@gmail.com>
- Laura Barber <laura.c.barber@gmail.com> - Laura Barber <laura.c.barber@gmail.com>
- Jakab Kristóf <jaksi07c8@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>

View File

@ -1,8 +1,8 @@
.. _concepts: .. _concepts:
************************* ************
Architecture and concepts Architecture
************************* ************
The overall architecture of Mopidy is organized around multiple frontends and The overall architecture of Mopidy is organized around multiple frontends and
backends. The frontends use the core API. The core actor makes multiple backends backends. The frontends use the core API. The core actor makes multiple backends

View File

@ -1,8 +1,8 @@
.. _audio-api: .. _audio-api:
********* *********************************
Audio API :mod:`mopidy.audio` --- Audio API
********* *********************************
.. module:: mopidy.audio .. module:: mopidy.audio
:synopsis: Thin wrapper around the parts of GStreamer we use :synopsis: Thin wrapper around the parts of GStreamer we use

View File

@ -1,8 +1,8 @@
.. _backend-api: .. _backend-api:
*********** *************************************
Backend API :mod:`mopidy.backend` --- Backend API
*********** *************************************
.. module:: mopidy.backend .. module:: mopidy.backend
:synopsis: The API implemented by backends :synopsis: The API implemented by backends

View File

@ -1,8 +1,8 @@
.. _commands-api: .. _commands-api:
************ ***************************************
Commands API :mod:`mopidy.commands` --- Commands API
************ ***************************************
.. automodule:: mopidy.commands .. automodule:: mopidy.commands
:synopsis: Commands API for Mopidy CLI. :synopsis: Commands API for Mopidy CLI.

View File

@ -1,8 +1,8 @@
.. _config-api: .. _config-api:
********** ***********************************
Config API :mod:`mopidy.config` --- Config API
********** ***********************************
.. automodule:: mopidy.config .. automodule:: mopidy.config
:synopsis: Config API for config loading and validation :synopsis: Config API for config loading and validation

View File

@ -1,79 +1,253 @@
.. _core-api: .. _core-api:
******** *******************************
Core API :mod:`mopidy.core` --- Core API
******** *******************************
.. module:: mopidy.core .. module:: mopidy.core
:synopsis: Core API for use by frontends :synopsis: Core API for use by frontends
The core API is the interface that is used by frontends like 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 :mod:`mopidy.http` and :mod:`mopidy.mpd`. The core layer is in between the
frontends and the backends. 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 .. 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, .. attribute:: playback
seek, and volume control.
.. autoclass:: mopidy.core.PlaybackState Manages playback state and the current playing track.
:members: See :class:`~mopidy.core.PlaybackController`.
.. autoclass:: mopidy.core.PlaybackController .. attribute:: library
:members:
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 Tracklist controller
==================== ====================
Manages everything related to the tracks we are currently playing.
.. autoclass:: mopidy.core.TracklistController .. 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 Playback control
:members: ----------------
.. 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 Playback states
:members: ---------------
.. 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 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 .. automethod:: mopidy.core.LibraryController.browse
:members: .. 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 Mixer controller
================ ================
Manages volume and muting. .. class:: mopidy.core.MixerController
.. autoclass:: mopidy.core.MixerController .. automethod:: mopidy.core.MixerController.get_mute
:members: .. 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 .. autoclass:: mopidy.core.CoreListener
:members: :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

View File

@ -1,8 +1,8 @@
.. _ext-api: .. _ext-api:
************* **********************************
Extension API :mod:`mopidy.ext` -- Extension API
************* **********************************
If you want to learn how to make Mopidy extensions, read :ref:`extensiondev`. If you want to learn how to make Mopidy extensions, read :ref:`extensiondev`.

View File

@ -25,6 +25,8 @@ For details on how to make a Mopidy extension, see the :ref:`extensiondev`
guide. guide.
.. _static-web-client:
Static web client example Static web client example
========================= =========================

View File

@ -4,9 +4,6 @@
HTTP JSON-RPC API 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 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 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 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 please refer to the `JSON-RPC 2.0 spec
<http://www.jsonrpc.org/specification>`_. <http://www.jsonrpc.org/specification>`_.
All methods (not attributes) in the :ref:`core-api` is made available through All methods in the :ref:`core-api` is made available through JSON-RPC calls
JSON-RPC calls over the WebSocket. For example, over the WebSocket. For example, :meth:`mopidy.core.PlaybackController.play` is
:meth:`mopidy.core.PlaybackController.play` is available as the JSON-RPC method available as the JSON-RPC method ``core.playback.play``.
``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``.
Example JSON-RPC request:: Example JSON-RPC request::

9
docs/api/httpclient.rst Normal file
View 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:

View File

@ -4,26 +4,57 @@
API reference API reference
************* *************
.. note:: What is public? .. note::
Only APIs documented here are public and open for use by Mopidy Only APIs documented here are public and open for use by Mopidy
extensions. extensions.
.. toctree:: Concepts
:glob: ========
concepts .. toctree::
architecture
models models
backends
Basics
======
.. toctree::
core core
audio frontend
mixer backend
frontends
commands
ext ext
config
zeroconf
Web/JavaScript
==============
.. toctree::
http-server http-server
http http
js js
Audio
=====
.. toctree::
audio
mixer
Utilities
=========
.. toctree::
commands
config
httpclient
zeroconf

View File

@ -21,9 +21,9 @@ available at:
You may need to adjust hostname and port for your local setup. 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 Thus, if you use Mopidy to host your web client, like described in
load the latest version of Mopidy.js by adding the following script tag to your :ref:`static-web-client`, you can load the latest version of Mopidy.js by
HTML file: adding the following script tag to your HTML file:
.. code-block:: html .. 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`` from the call, the errback will be called with a ``Mopidy.ConnectionError``
instance. instance.
All methods in Mopidy's :ref:`core-api` is available via Mopidy.js. The core All methods in Mopidy's :ref:`core-api` is available via Mopidy.js. For
API attributes is *not* available, but that shouldn't be a problem as we've example, the :meth:`mopidy.core.PlaybackController.get_state` method is
added (undocumented) getters and setters for all of them, so you can access the available in JSON-RPC as the method ``core.playback.get_state`` and in
attributes as well from JavaScript. For example, the Mopidy.js as ``mopidy.playback.getState()``.
: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()``.
Both the WebSocket API and the JavaScript API are based on introspection of the 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 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 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-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 by JSON-RPC 2.0.
passing parameters by-position.
Obviously, you'll want to get a return value from many of your method calls. 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 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)); .done(printCurrentTrack, console.error.bind(console));
If you don't hook up an error handler function and never call ``done()`` on the 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 promise object, warnings will be logged to the console complaining that you
unhandled errors. In general, unhandled errors will not go silently missing. 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 The promise objects returned by Mopidy.js adheres to the `CommonJS Promises/A
<http://wiki.commonjs.org/wiki/Promises/A>`_ standard. We use the <http://wiki.commonjs.org/wiki/Promises/A>`_ standard. We use the

View File

@ -1,8 +1,8 @@
.. _mixer-api: .. _mixer-api:
*************** ***************************************
Audio mixer API :mod:`mopidy.mixer` --- Audio mixer API
*************** ***************************************
.. module:: mopidy.mixer .. module:: mopidy.mixer
:synopsis: The audio mixer API :synopsis: The audio mixer API

View File

@ -1,14 +1,14 @@
*********** ************************************
Data models :mod:`mopidy.models` --- Data models
*********** ************************************
These immutable data models are used for all data transfer within the Mopidy 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 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 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 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 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:: 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(name='Christmas Carol', length=171)
>>> track1 >>> track1
Track(artists=[], length=171, name='Christmas Carol') Track(artists=[], length=171, name='Christmas Carol')
>>> track2 = track1.copy(length=37) >>> track2 = track1.replace(length=37)
>>> track2 >>> track2
Track(artists=[], length=37, name='Christmas Carol') Track(artists=[], length=37, name='Christmas Carol')
>>> track1 >>> track1
@ -75,7 +75,31 @@ Data model helpers
================== ==================
.. autoclass:: mopidy.models.ImmutableObject .. 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 .. 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

View File

@ -1,8 +1,8 @@
.. _zeroconf-api: .. _zeroconf-api:
************ ***************************************
Zeroconf API :mod:`mopidy.zeroconf` --- Zeroconf API
************ ***************************************
.. module:: mopidy.zeroconf .. module:: mopidy.zeroconf
:synopsis: Helper for publishing of services on Zeroconf :synopsis: Helper for publishing of services on Zeroconf

View File

@ -4,15 +4,118 @@ Changelog
This changelog is used to track all major changes to Mopidy. This changelog is used to track all major changes to Mopidy.
v1.1.0 (UNRELEASED) v1.1.0 (UNRELEASED)
=================== ===================
Core API Core API
-------- --------
- Calling :meth:`mopidy.core.library.LibraryController.search`` with ``kwargs`` - Calling the following methods with ``kwargs`` is being deprecated.
as the query is no longer supported (PR: :issue:`1090`) (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 Internal changes
---------------- ----------------
@ -20,19 +123,130 @@ Internal changes
- Tests have been cleaned up to stop using deprecated APIs where feasible. - Tests have been cleaned up to stop using deprecated APIs where feasible.
(Partial fix: :issue:`1083`, PR: :issue:`1090`) (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 - Audio: Software volume control has been reworked to greatly reduce the delay
between changing the volume and the change taking effect. (Fixes: 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 - 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 tied to the PulseAudio application volume when using ``pulsesink``. This
behavior was confusing for many users and doesn't work well with the plans behavior was confusing for many users and doesn't work well with the plans
for multiple outputs. 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) v1.0.0 (2015-03-25)
=================== ===================

View File

@ -12,38 +12,8 @@ http://mpd.wikia.com/wiki/Clients.
:local: :local:
Test procedure MPD console clients
============== ===================
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
===============
ncmpcpp ncmpcpp
------- -------
@ -83,8 +53,8 @@ A command line client. Version 0.16 and upwards seems to work nicely with
Mopidy. Mopidy.
Graphical clients MPD graphical clients
================= =====================
GMPC GMPC
---- ----
@ -132,22 +102,12 @@ client for OS X. It is unmaintained, but generally works well with Mopidy.
.. _android_mpd_clients: .. _android_mpd_clients:
Android clients MPD 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.
MPDroid MPDroid
------- -------
Test date:
2012-11-06
Tested version:
1.03.1 (released 2012-10-16)
.. image:: mpd-client-mpdroid.jpg .. image:: mpd-client-mpdroid.jpg
:width: 288 :width: 288
:height: 512 :height: 512
@ -155,128 +115,17 @@ Tested version:
You can get `MPDroid from Google Play You can get `MPDroid from Google Play
<https://play.google.com/store/apps/details?id=com.namelessdev.mpdroid>`_. <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. 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_mpd_clients:
iOS clients MPD iOS clients
=========== ===============
MPoD MPoD
---- ----
Test date:
2012-11-06
Tested version:
1.7.1
.. image:: mpd-client-mpod.jpg .. image:: mpd-client-mpod.jpg
:width: 320 :width: 320
:height: 480 :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 app can be installed from `MPoD at iTunes Store
<https://itunes.apple.com/us/app/mpod/id285063020>`_. <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 MPaD
---- ----
Test date:
2012-11-06
Tested version:
1.7.1
.. image:: mpd-client-mpad.jpg .. image:: mpd-client-mpad.jpg
:width: 480 :width: 480
:height: 360 :height: 360
@ -313,25 +146,11 @@ The `MPaD <http://www.katoemba.net/makesnosenseatall/mpad/>`_ iPad app can be
purchased from `MPaD at iTunes Store purchased from `MPaD at iTunes Store
<https://itunes.apple.com/us/app/mpad/id423097706>`_ <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: .. _mpd-web-clients:
Web clients MPD web clients
=========== ===============
The following web clients use the MPD protocol to communicate with Mopidy. For The following web clients use the MPD protocol to communicate with Mopidy. For
other web clients, see :ref:`http-clients`. other web clients, see :ref:`http-clients`.

View File

@ -47,7 +47,6 @@ class Mock(object):
return Mock() return Mock()
MOCK_MODULES = [ MOCK_MODULES = [
'cherrypy',
'dbus', 'dbus',
'dbus.mainloop', 'dbus.mainloop',
'dbus.mainloop.glib', 'dbus.mainloop.glib',
@ -61,12 +60,6 @@ MOCK_MODULES = [
'pykka.actor', 'pykka.actor',
'pykka.future', 'pykka.future',
'pykka.registry', 'pykka.registry',
'pylast',
'ws4py',
'ws4py.messaging',
'ws4py.server',
'ws4py.server.cherrypyserver',
'ws4py.websocket',
] ]
for mod_name in MOCK_MODULES: for mod_name in MOCK_MODULES:
sys.modules[mod_name] = Mock() sys.modules[mod_name] = Mock()
@ -102,10 +95,13 @@ master_doc = 'index'
project = 'Mopidy' project = 'Mopidy'
copyright = '2009-2015, Stein Magnus Jodal and contributors' 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() release = get_version()
version = '.'.join(release.split('.')[:2]) version = '.'.join(release.split('.')[:2])
# To make the build reproducible, avoid using today's date in the manpages
today = '2015'
exclude_trees = ['_build'] exclude_trees = ['_build']
pygments_style = 'sphinx' pygments_style = 'sphinx'

View File

@ -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 <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. 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: .. _code-linting:

View File

@ -57,6 +57,28 @@ Provides a backend for browsing the Internet radio channels from the `Dirble
<http://dirble.com/>`_ directory. <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 Mopidy-GMusic
============= =============

47
docs/ext/file.rst Normal file
View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -29,6 +29,14 @@ Extension for controlling volume using an external Arcam amplifier. Developed
and tested with an Arcam AVR-300. 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 Mopidy-NAD
========== ==========

BIN
docs/ext/mopster.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@ -48,6 +48,22 @@ To install, run::
pip install Mopidy-Local-Images 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 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. 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 Other web clients
================= =================

View File

@ -6,7 +6,7 @@ Extension development
Mopidy started as simply an MPD server that could play music from Spotify. 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 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 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 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 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 Example README.rst
================== ==================
The README file should quickly explain what the extension does, how to install 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 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 latest development version of the extension. It's important that this link ends
with ``#egg=Mopidy-Something-dev`` for installation using with ``#egg=Mopidy-Something-dev`` for installation using
``pip install Mopidy-Something==dev`` to work. ``pip install Mopidy-Something==dev`` to work.
.. code-block:: rst .. 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 named ``Extension`` which inherits from Mopidy's extension base class,
:class:`mopidy.ext.Extension`. This is the class referred to in the :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 ``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 extension, outside of Mopidy and it's core requirements, should be kept inside
methods. This ensures that this file can be imported without raising methods. This ensures that this file can be imported without raising
:exc:`ImportError` exceptions for missing dependencies, etc. :exc:`ImportError` exceptions for missing dependencies, etc.
The default configuration for the extension is defined by the 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 that case you should default to the most secure configuration. Leave any
configurations that don't have meaningful defaults blank, like ``username`` configurations that don't have meaningful defaults blank, like ``username``
and ``password``. In the example below, we've chosen to maintain the default 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. default config in documentation without duplicating it.
This is ``mopidy_soundspot/__init__.py``:: This is ``mopidy_soundspot/__init__.py``::
@ -413,11 +413,11 @@ examples, see the :ref:`http-server-api` docs or explore with
Running an extension Running an extension
==================== ====================
Once your extension is ready to go, to see it in action you'll need to register 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`` 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 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 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 ``python setup.py develop`` to effectively link Mopidy directly with your
development files. development files.
@ -434,9 +434,12 @@ Use of Mopidy APIs
================== ==================
When writing an extension, you should only use APIs documented at 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 :ref:`api-ref`. Other parts of Mopidy, like :mod:`mopidy.internal`, may change
any time and are not something extensions should use. 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 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 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 everything else due to the amount of noise, see the docs for the
:confval:`loglevels/*` config section. :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>`__.

View File

@ -6,7 +6,7 @@ Glossary
backend backend
A part of Mopidy providing music library, playlist storage and/or 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 music store or music service it supports. See :ref:`backend-api` for
details. details.

View File

@ -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`. To get started with Mopidy, start by reading :ref:`installation`.
.. _getting-help:
**Getting help** **Getting help**
If you get stuck, you can get help at the `Mopidy discussion forum If you get stuck, you can get help at the `Mopidy discussion forum
@ -94,6 +96,7 @@ Extensions
:maxdepth: 2 :maxdepth: 2
ext/local ext/local
ext/file
ext/m3u ext/m3u
ext/stream ext/stream
ext/http ext/http

View File

@ -1,20 +1,19 @@
.. _arch-install: .. _arch-install:
**************************** **********************************
Arch Linux: Install from AUR Arch Linux: Install from community
**************************** **********************************
If you are running Arch Linux, you can install Mopidy using the 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 #. To install Mopidy with all dependencies, you can use::
for example `yaourt <https://wiki.archlinux.org/index.php/yaourt>`_::
yaourt -S mopidy pacman -S mopidy
To upgrade Mopidy to future releases, just upgrade your system using:: 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 #. Finally, you need to set a couple of :doc:`config values </config>`, and
then you're ready to :doc:`run Mopidy </running>`. then you're ready to :doc:`run Mopidy </running>`.
@ -24,7 +23,7 @@ Installing extensions
===================== =====================
If you want to use any Mopidy extensions, like Spotify support or Last.fm 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>`_. <https://aur.archlinux.org/packages/?K=mopidy>`_.
You can also install any Mopidy extension directly from PyPI with ``pip``. To You can also install any Mopidy extension directly from PyPI with ``pip``. To

View File

@ -18,12 +18,12 @@ from scratch, we have a guide for installing Debian/Raspbian and Mopidy. See
The packages should work with: The packages should work with:
- Debian stable and testing, - Debian stable ("jessie") and testing ("stretch"),
- Raspbian stable and testing, - Raspbian stable ("jessie") and testing ("stretch"),
- Ubuntu 14.04 LTS and later. - Ubuntu 14.04 LTS and later.
Some of the packages, including the core "mopidy" packages, does *not* work Some of the packages *do not* work with Ubuntu 12.04 LTS or Debian 7
on Ubuntu 12.04 LTS. "wheezy".
This is just what we currently support, not a promise to continue to 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 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 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:: #. Install Mopidy and all dependencies::
sudo apt-get update sudo apt-get update

View File

@ -18,7 +18,7 @@ If you are running OS X, you can install everything needed with Homebrew.
date before you continue:: date before you continue::
brew update brew update
brew upgrade brew upgrade --all
Notice that this will upgrade all software on your system that have been Notice that this will upgrade all software on your system that have been
installed with Homebrew. installed with Homebrew.

View File

@ -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 Please note that if you're running Xbian or another XBMC distribution these
instructions might vary for your system. 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

View File

@ -5,7 +5,7 @@ Install from source
******************* *******************
If you are on Linux, but can't install :ref:`from the APT archive If you are on Linux, but can't install :ref:`from the APT archive
<debian-install>` or :ref:`from AUR <arch-install>`, you can install Mopidy <debian-install>` or :ref:`from the Arch Linux repository <arch-install>`, you can install Mopidy
from PyPI using the ``pip`` installer. from PyPI using the ``pip`` installer.
If you are looking to contribute or wish to install from source using ``git`` If you are looking to contribute or wish to install from source using ``git``

View File

@ -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`. For details on how to use Mopidy's local backend, see :ref:`ext-local`.
.. automodule:: mopidy.local .. automodule:: mopidy.local
:synopsis: Local backend :synopsis: Local backend
Local library API
=================
.. autoclass:: mopidy.local.Library
:members:
Translation utils
=================
.. automodule:: mopidy.local.translator
:synopsis: Translators for local library extensions
:members: :members:

View File

@ -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`. For details on how to use Mopidy's MPD server, see :ref:`ext-mpd`.
@ -71,6 +71,14 @@ Current playlist
:members: :members:
Mounts and neighbors
--------------------
.. automodule:: mopidy.mpd.protocol.mount
:synopsis: MPD protocol: mounts and neighbors
:members:
Music database Music database
-------------- --------------

View File

@ -47,8 +47,7 @@ Creating releases
#. Push to GitHub:: #. Push to GitHub::
git push git push --follow-tags
git push --tags
#. Upload the previously built and tested sdist and bdist_wheel packages to #. Upload the previously built and tested sdist and bdist_wheel packages to
PyPI:: PyPI::

View File

@ -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 accept connections by any MPD client. Check out our non-exhaustive
:doc:`/clients/mpd` list to find recommended clients. :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 Stopping Mopidy
=============== ===============
@ -33,8 +47,8 @@ Init scripts
<https://github.com/mopidy/mopidy/blob/debian/debian/mopidy.init>`_. For <https://github.com/mopidy/mopidy/blob/debian/debian/mopidy.init>`_. For
more details, see the :ref:`debian` section of the docs. more details, see the :ref:`debian` section of the docs.
- The ``mopidy`` package in `Arch Linux AUR - The ``mopidy`` package in `Arch Linux
<https://aur.archlinux.org/packages/mopidy>`__ comes with a systemd init <https://www.archlinux.org/packages/community/any/mopidy/>`__ comes with a systemd init
script. script.
- A blog post by Benjamin Guillet explains how to `Daemonize Mopidy and Launch - A blog post by Benjamin Guillet explains how to `Daemonize Mopidy and Launch

View File

@ -20,12 +20,6 @@ for free. We use their services for the following sites:
- Mailgun for sending emails from the Discourse forum. - 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 - CDN hosting at http://dl.mopidy.com, which is used to distribute Pi Musicbox
images. images.

View File

@ -2,7 +2,6 @@ from __future__ import absolute_import, print_function, unicode_literals
import platform import platform
import sys import sys
import textwrap
import warnings import warnings
@ -11,23 +10,8 @@ if not (2, 7) <= sys.version_info < (3,):
'ERROR: Mopidy requires Python 2.7, but found %s.' % 'ERROR: Mopidy requires Python 2.7, but found %s.' %
platform.python_version()) 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') warnings.filterwarnings('ignore', 'could not open display')
__version__ = '1.0.0' __version__ = '1.0.8'

View File

@ -4,8 +4,23 @@ import logging
import os import os
import signal import signal
import sys 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() gobject.threads_init()
try: try:
@ -26,7 +41,7 @@ sys.argv[1:] = []
from mopidy import commands, config as config_lib, ext 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__) logger = logging.getLogger(__name__)
@ -51,21 +66,23 @@ def main():
root_cmd.add_child('config', config_cmd) root_cmd.add_child('config', config_cmd)
root_cmd.add_child('deps', deps_cmd) root_cmd.add_child('deps', deps_cmd)
installed_extensions = ext.load_extensions() extensions_data = ext.load_extensions()
for extension in installed_extensions: for data in extensions_data:
ext_cmd = extension.get_command() if data.command: # TODO: check isinstance?
if ext_cmd: data.command.set(extension=data.extension)
ext_cmd.set(extension=extension) root_cmd.add_child(data.extension.ext_name, data.command)
root_cmd.add_child(extension.ext_name, ext_cmd)
args = root_cmd.parse(mopidy_args) 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() check_old_locations()
config, config_errors = config_lib.load( 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 verbosity_level = args.base_verbosity_level
if args.verbosity_level: if args.verbosity_level:
@ -75,8 +92,11 @@ def main():
extensions = { extensions = {
'validate': [], 'config': [], 'disabled': [], 'enabled': []} 'validate': [], 'config': [], 'disabled': [], 'enabled': []}
for extension in installed_extensions: for data in extensions_data:
if not ext.validate_extension(extension): 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[extension.ext_name] = {'enabled': False}
config_errors[extension.ext_name] = { config_errors[extension.ext_name] = {
'enabled': 'extension disabled by self check.'} 'enabled': 'extension disabled by self check.'}
@ -94,12 +114,13 @@ def main():
else: else:
extensions['enabled'].append(extension) 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. # Config and deps commands are simply special cased for now.
if args.command == config_cmd: if args.command == config_cmd:
return args.command.run( schemas = [d.config_schema for d in extensions_data]
config, config_errors, installed_extensions) return args.command.run(config, config_errors, schemas)
elif args.command == deps_cmd: elif args.command == deps_cmd:
return args.command.run() return args.command.run()
@ -119,10 +140,19 @@ def main():
return 1 return 1
for extension in extensions['enabled']: 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 # 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: try:
return args.command.run(args, proxied_config) return args.command.run(args, proxied_config)
except NotImplementedError: except NotImplementedError:

View File

@ -8,7 +8,7 @@ import gobject
import pygst import pygst
pygst.require('0.10') pygst.require('0.10')
import gst # noqa import gst # noqa
import gst.pbutils import gst.pbutils # noqa
import pykka import pykka
@ -16,7 +16,7 @@ from mopidy import exceptions
from mopidy.audio import playlists, utils from mopidy.audio import playlists, utils
from mopidy.audio.constants import PlaybackState from mopidy.audio.constants import PlaybackState
from mopidy.audio.listener import AudioListener from mopidy.audio.listener import AudioListener
from mopidy.utils import deprecation, process from mopidy.internal import deprecation, process
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -166,10 +166,7 @@ class _Outputs(gst.Bin):
logger.info('Audio output set to "%s"', description) logger.info('Audio output set to "%s"', description)
def _add(self, element): 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 = gst.element_factory_make('queue')
queue.set_property('max-size-buffers', 5)
self.add(element) self.add(element)
self.add(queue) self.add(queue)
queue.link(element) queue.link(element)
@ -199,16 +196,14 @@ class SoftwareMixer(object):
def set_volume(self, volume): def set_volume(self, volume):
self._element.set_property('volume', volume / 100.0) self._element.set_property('volume', volume / 100.0)
self._mixer.trigger_volume_changed(volume) self._mixer.trigger_volume_changed(self.get_volume())
def get_mute(self): def get_mute(self):
return self._element.get_property('mute') return self._element.get_property('mute')
def set_mute(self, mute): def set_mute(self, mute):
result = self._element.set_property('mute', bool(mute)) self._element.set_property('mute', bool(mute))
if result: self._mixer.trigger_mute_changed(self.get_mute())
self._mixer.trigger_mute_changed(bool(mute))
return result
class _Handler(object): class _Handler(object):

View File

@ -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 collections
import pygst import pygst
pygst.require('0.10') pygst.require('0.10')
import gst # noqa import gst # noqa
import gst.pbutils import gst.pbutils # noqa
from mopidy import exceptions from mopidy import exceptions
from mopidy.audio import utils from mopidy.audio import utils
from mopidy.utils import encoding from mopidy.internal import encoding
_missing_plugin_desc = gst.pbutils.missing_plugin_message_get_description _missing_plugin_desc = gst.pbutils.missing_plugin_message_get_description
_Result = collections.namedtuple( _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') _RAW_AUDIO = gst.Caps(b'audio/x-raw-int; audio/x-raw-float')
@ -52,14 +53,14 @@ class Scanner(object):
try: try:
_start_pipeline(pipeline) _start_pipeline(pipeline)
tags, mime = _process(pipeline, self._timeout_ms) tags, mime, have_audio = _process(pipeline, self._timeout_ms)
duration = _query_duration(pipeline) duration = _query_duration(pipeline)
seekable = _query_seekable(pipeline) seekable = _query_seekable(pipeline)
finally: finally:
pipeline.set_state(gst.STATE_NULL) pipeline.set_state(gst.STATE_NULL)
del pipeline 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 # 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') typefind = gst.element_factory_make('typefind')
decodebin = gst.element_factory_make('decodebin2') decodebin = gst.element_factory_make('decodebin2')
sink = gst.element_factory_make('fakesink')
pipeline = gst.element_factory_make('pipeline') pipeline = gst.element_factory_make('pipeline')
for e in (src, typefind, decodebin, sink): for e in (src, typefind, decodebin):
pipeline.add(e) pipeline.add(e)
gst.element_link_many(src, typefind, decodebin) gst.element_link_many(src, typefind, decodebin)
if proxy_config: if proxy_config:
utils.setup_proxy(src, 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) typefind.connect('have-type', _have_type, decodebin)
decodebin.connect('pad-added', _pad_added, pipeline)
return pipeline return pipeline
def _have_type(element, probability, caps, decodebin): def _have_type(element, probability, caps, decodebin):
decodebin.set_property('sink-caps', caps) decodebin.set_property('sink-caps', caps)
msg = gst.message_new_application(element, caps.get_structure(0)) struct = gst.Structure('have-type')
element.get_bus().post(msg) struct['caps'] = caps.get_structure(0)
element.get_bus().post(gst.message_new_application(element, struct))
def _pad_added(element, pad, sink): def _pad_added(element, pad, pipeline):
return pad.link(sink.get_pad('sink')) 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): def _start_pipeline(pipeline):
@ -125,7 +134,7 @@ def _process(pipeline, timeout_ms):
clock = pipeline.get_clock() clock = pipeline.get_clock()
bus = pipeline.get_bus() bus = pipeline.get_bus()
timeout = timeout_ms * gst.MSECOND 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 types = (gst.MESSAGE_ELEMENT | gst.MESSAGE_APPLICATION | gst.MESSAGE_ERROR
| gst.MESSAGE_EOS | gst.MESSAGE_ASYNC_DONE | gst.MESSAGE_TAG) | 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_description = encoding.locale_decode(
_missing_plugin_desc(message)) _missing_plugin_desc(message))
elif message.type == gst.MESSAGE_APPLICATION: elif message.type == gst.MESSAGE_APPLICATION:
mime = message.structure.get_name() if message.structure.get_name() == 'have-type':
if mime.startswith('text/') or mime == 'application/xml': mime = message.structure['caps'].get_name()
return tags, mime 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: elif message.type == gst.MESSAGE_ERROR:
error = encoding.locale_decode(message.parse_error()[0]) error = encoding.locale_decode(message.parse_error()[0])
if missing_description: if missing_description:
error = '%s (%s)' % (missing_description, error) error = '%s (%s)' % (missing_description, error)
raise exceptions.ScannerError(error) raise exceptions.ScannerError(error)
elif message.type == gst.MESSAGE_EOS: elif message.type == gst.MESSAGE_EOS:
return tags, mime return tags, mime, have_audio
elif message.type == gst.MESSAGE_ASYNC_DONE: elif message.type == gst.MESSAGE_ASYNC_DONE:
if message.src == pipeline: if message.src == pipeline:
return tags, mime return tags, mime, have_audio
elif message.type == gst.MESSAGE_TAG: elif message.type == gst.MESSAGE_TAG:
taglist = message.parse_tag() taglist = message.parse_tag()
# Note that this will only keep the last tag. # Note that this will only keep the last tag.
@ -162,3 +174,28 @@ def _process(pipeline, timeout_ms):
timeout -= clock.get_time() - start timeout -= clock.get_time() - start
raise exceptions.ScannerError('Timeout after %dms' % timeout_ms) 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))

View File

@ -8,7 +8,7 @@ import pygst
pygst.require('0.10') pygst.require('0.10')
import gst # noqa import gst # noqa
from mopidy import compat from mopidy import compat, httpclient
from mopidy.models import Album, Artist, Track from mopidy.models import Album, Artist, Track
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -142,11 +142,7 @@ def setup_proxy(element, config):
if not hasattr(element.props, 'proxy') or not config.get('hostname'): if not hasattr(element.props, 'proxy') or not config.get('hostname'):
return return
proxy = "%s://%s:%d" % (config.get('scheme', 'http'), element.set_property('proxy', httpclient.format_proxy(config, auth=False))
config.get('hostname'),
config.get('port', 80))
element.set_property('proxy', proxy)
element.set_property('proxy-id', config.get('username')) element.set_property('proxy-id', config.get('username'))
element.set_property('proxy-pw', config.get('password')) element.set_property('proxy-pw', config.get('password'))

View File

@ -58,6 +58,10 @@ class Backend(object):
def has_playlists(self): def has_playlists(self):
return self.playlists is not None return self.playlists is not None
def ping(self):
"""Called to check if the actor is still alive."""
return True
class LibraryProvider(object): class LibraryProvider(object):
@ -99,6 +103,9 @@ class LibraryProvider(object):
*MAY be implemented by subclass.* *MAY be implemented by subclass.*
Default implementation will simply return an empty set. Default implementation will simply return an empty set.
Note that backends should always return an empty set for unexpected
field types.
""" """
return set() return set()
@ -400,7 +407,7 @@ class BackendListener(listener.Listener):
Marker interface for recipients of events sent by the backend actors. Marker interface for recipients of events sent by the backend actors.
Any Pykka actor that mixes in this class will receive calls to the methods 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, 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 and for providing default implementations for those listeners that are not
interested in all events. interested in all events.

View File

@ -2,6 +2,7 @@ from __future__ import absolute_import, print_function, unicode_literals
import argparse import argparse
import collections import collections
import contextlib
import logging import logging
import os import os
import sys import sys
@ -10,10 +11,12 @@ import glib
import gobject import gobject
import pykka
from mopidy import config as config_lib, exceptions from mopidy import config as config_lib, exceptions
from mopidy.audio import Audio from mopidy.audio import Audio
from mopidy.core import Core from mopidy.core import Core
from mopidy.utils import deps, process, timer, versioning from mopidy.internal import deps, process, timer, versioning
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -230,7 +233,24 @@ class Command(object):
raise NotImplementedError 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): class RootCommand(Command):
def __init__(self): def __init__(self):
@ -277,9 +297,11 @@ class RootCommand(Command):
mixer = None mixer = None
if mixer_class is not None: if mixer_class is not None:
mixer = self.start_mixer(config, mixer_class) mixer = self.start_mixer(config, mixer_class)
if mixer:
self.configure_mixer(config, mixer)
audio = self.start_audio(config, mixer) audio = self.start_audio(config, mixer)
backends = self.start_backends(config, backend_classes, audio) backends = self.start_backends(config, backend_classes, audio)
core = self.start_core(mixer, backends, audio) core = self.start_core(config, mixer, backends, audio)
self.start_frontends(config, frontend_classes, core) self.start_frontends(config, frontend_classes, core)
loop.run() loop.run()
except (exceptions.BackendError, except (exceptions.BackendError,
@ -323,16 +345,15 @@ class RootCommand(Command):
return selected_mixers[0] return selected_mixers[0]
def start_mixer(self, config, mixer_class): 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() mixer = mixer_class.start(config=config).proxy()
self.configure_mixer(config, mixer) try:
return mixer mixer.ping().get()
except exceptions.MixerError as exc: return mixer
logger.error( except pykka.ActorDeadError as exc:
'Mixer (%s) initialization error: %s', logger.error('Actor died: %s', exc)
mixer_class.__name__, exc.message) return None
raise
def configure_mixer(self, config, mixer): def configure_mixer(self, config, mixer):
volume = config['audio']['mixer_volume'] volume = config['audio']['mixer_volume']
@ -353,22 +374,26 @@ class RootCommand(Command):
backends = [] backends = []
for backend_class in backend_classes: for backend_class in backend_classes:
try: with _actor_error_handling(backend_class.__name__):
with timer.time_logger(backend_class.__name__): with timer.time_logger(backend_class.__name__):
backend = backend_class.start( backend = backend_class.start(
config=config, audio=audio).proxy() config=config, audio=audio).proxy()
backends.append(backend) backends.append(backend)
except exceptions.BackendError as exc:
logger.error( # Block until all on_starts have finished, letting them run in parallel
'Backend (%s) initialization error: %s', for backend in backends[:]:
backend_class.__name__, exc.message) try:
raise backend.ping().get()
except pykka.ActorDeadError as exc:
backends.remove(backend)
logger.error('Actor died: %s', exc)
return backends return backends
def start_core(self, mixer, backends, audio): def start_core(self, config, mixer, backends, audio):
logger.info('Starting Mopidy core') 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): def start_frontends(self, config, frontend_classes, core):
logger.info( logger.info(
@ -376,14 +401,9 @@ class RootCommand(Command):
', '.join(f.__name__ for f in frontend_classes) or 'none') ', '.join(f.__name__ for f in frontend_classes) or 'none')
for frontend_class in frontend_classes: for frontend_class in frontend_classes:
try: with _actor_error_handling(frontend_class.__name__):
with timer.time_logger(frontend_class.__name__): with timer.time_logger(frontend_class.__name__):
frontend_class.start(config=config, core=core) frontend_class.start(config=config, core=core)
except exceptions.FrontendError as exc:
logger.error(
'Frontend (%s) initialization error: %s',
frontend_class.__name__, exc.message)
raise
def stop_frontends(self, frontend_classes): def stop_frontends(self, frontend_classes):
logger.info('Stopping Mopidy frontends') logger.info('Stopping Mopidy frontends')
@ -415,8 +435,8 @@ class ConfigCommand(Command):
super(ConfigCommand, self).__init__() super(ConfigCommand, self).__init__()
self.set(base_verbosity_level=-1) self.set(base_verbosity_level=-1)
def run(self, config, errors, extensions): def run(self, config, errors, schemas):
print(config_lib.format(config, extensions, errors)) print(config_lib.format(config, schemas, errors))
return 0 return 0

View File

@ -11,10 +11,14 @@ from mopidy.compat import configparser
from mopidy.config import keyring from mopidy.config import keyring
from mopidy.config.schemas import * # noqa from mopidy.config.schemas import * # noqa
from mopidy.config.types import * # noqa from mopidy.config.types import * # noqa
from mopidy.utils import path, versioning from mopidy.internal import path, versioning
logger = logging.getLogger(__name__) 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 = ConfigSchema('logging')
_logging_schema['color'] = Boolean() _logging_schema['color'] = Boolean()
_logging_schema['console_format'] = String() _logging_schema['console_format'] = String()
@ -43,8 +47,9 @@ _proxy_schema['password'] = Secret(optional=True)
# NOTE: if multiple outputs ever comes something like LogLevelConfigSchema # NOTE: if multiple outputs ever comes something like LogLevelConfigSchema
# _outputs_schema = config.AudioOutputConfigSchema() # _outputs_schema = config.AudioOutputConfigSchema()
_schemas = [_logging_schema, _loglevels_schema, _logcolors_schema, _schemas = [
_audio_schema, _proxy_schema] _core_schema, _logging_schema, _loglevels_schema, _logcolors_schema,
_audio_schema, _proxy_schema]
_INITIAL_HELP = """ _INITIAL_HELP = """
# For further information about options in this file see: # For further information about options in this file see:
@ -65,24 +70,20 @@ def read(config_file):
return filehandle.read() return filehandle.read()
def load(files, extensions, overrides): def load(files, ext_schemas, ext_defaults, overrides):
# Helper to get configs, as the rest of our config system should not need
# to know about extensions.
config_dir = os.path.dirname(__file__) config_dir = os.path.dirname(__file__)
defaults = [read(os.path.join(config_dir, 'default.conf'))] 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 [])) raw_config = _load(files, defaults, keyring.fetch() + (overrides or []))
schemas = _schemas[:] schemas = _schemas[:]
schemas.extend(e.get_config_schema() for e in extensions) schemas.extend(ext_schemas)
return _validate(raw_config, schemas) return _validate(raw_config, schemas)
def format(config, extensions, comments=None, display=True): def format(config, ext_schemas, comments=None, display=True):
# Helper to format configs, as the rest of our config system should not
# need to know about extensions.
schemas = _schemas[:] schemas = _schemas[:]
schemas.extend(e.get_config_schema() for e in extensions) schemas.extend(ext_schemas)
return _format(config, comments or {}, schemas, display, False) return _format(config, comments or {}, schemas, display, False)

View File

@ -1,3 +1,6 @@
[core]
max_tracklist_length = 10000
[logging] [logging]
color = true color = true
console_format = %(levelname)-8s %(message)s console_format = %(levelname)-8s %(message)s

View File

@ -6,7 +6,7 @@ import socket
from mopidy import compat from mopidy import compat
from mopidy.config import validators from mopidy.config import validators
from mopidy.utils import log, path from mopidy.internal import log, path
def decode(value): def decode(value):

View File

@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals
import collections import collections
import itertools import itertools
import logging
import pykka import pykka
@ -14,8 +15,11 @@ from mopidy.core.mixer import MixerController
from mopidy.core.playback import PlaybackController from mopidy.core.playback import PlaybackController
from mopidy.core.playlists import PlaylistsController from mopidy.core.playlists import PlaylistsController
from mopidy.core.tracklist import TracklistController from mopidy.core.tracklist import TracklistController
from mopidy.utils import versioning from mopidy.internal import versioning
from mopidy.utils.deprecation import deprecated_property from mopidy.internal.deprecation import deprecated_property
logger = logging.getLogger(__name__)
class Core( class Core(
@ -23,32 +27,28 @@ class Core(
mixer.MixerListener): mixer.MixerListener):
library = None library = None
"""The library controller. An instance of """An instance of :class:`~mopidy.core.LibraryController`"""
:class:`mopidy.core.LibraryController`."""
history = None history = None
"""The playback history controller. An instance of """An instance of :class:`~mopidy.core.HistoryController`"""
:class:`mopidy.core.HistoryController`."""
mixer = None mixer = None
"""The mixer controller. An instance of """An instance of :class:`~mopidy.core.MixerController`"""
:class:`mopidy.core.MixerController`."""
playback = None playback = None
"""The playback controller. An instance of """An instance of :class:`~mopidy.core.PlaybackController`"""
:class:`mopidy.core.PlaybackController`."""
playlists = None playlists = None
"""The playlists controller. An instance of """An instance of :class:`~mopidy.core.PlaylistsController`"""
:class:`mopidy.core.PlaylistsController`."""
tracklist = None tracklist = None
"""The tracklist controller. An instance of """An instance of :class:`~mopidy.core.TracklistController`"""
: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__() super(Core, self).__init__()
self._config = config
self.backends = Backends(backends) self.backends = Backends(backends)
self.library = LibraryController(backends=self.backends, core=self) self.library = LibraryController(backends=self.backends, core=self)
@ -150,10 +150,15 @@ class Backends(list):
return b.actor_ref.actor_class.__name__ return b.actor_ref.actor_class.__name__
for b in backends: for b in backends:
has_library = b.has_library().get() try:
has_library_browse = b.has_library_browse().get() has_library = b.has_library().get()
has_playback = b.has_playback().get() has_library_browse = b.has_library_browse().get()
has_playlists = b.has_playlists().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(): for scheme in b.uri_schemes.get():
assert scheme not in backends_by_scheme, ( assert scheme not in backends_by_scheme, (

View File

@ -11,6 +11,7 @@ logger = logging.getLogger(__name__)
class HistoryController(object): class HistoryController(object):
pykka_traversable = True
def __init__(self): def __init__(self):
self._history = [] self._history = []

View File

@ -1,18 +1,32 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
import collections import collections
import contextlib
import logging import logging
import operator import operator
import urlparse import urlparse
import pykka from mopidy import compat, exceptions, models
from mopidy.internal import deprecation, validation
from mopidy.utils import deprecation
logger = logging.getLogger(__name__) 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): class LibraryController(object):
pykka_traversable = True pykka_traversable = True
@ -41,8 +55,8 @@ class LibraryController(object):
Browse directories and tracks at the given ``uri``. Browse directories and tracks at the given ``uri``.
``uri`` is a string which represents some directory belonging to a ``uri`` is a string which represents some directory belonging to a
backend. To get the intial root directories for backends pass None as backend. To get the intial root directories for backends pass
the URI. :class:`None` as the URI.
Returns a list of :class:`mopidy.models.Ref` objects for the Returns a list of :class:`mopidy.models.Ref` objects for the
directories and tracks at the given ``uri``. directories and tracks at the given ``uri``.
@ -70,15 +84,36 @@ class LibraryController(object):
.. versionadded:: 0.18 .. versionadded:: 0.18
""" """
if uri is None: if uri is None:
backends = self.backends.with_library_browse.values() return self._roots()
unique_dirs = {b.library.root_directory.get() for b in backends} elif not uri.strip():
return sorted(unique_dirs, key=operator.attrgetter('name')) 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 scheme = urlparse.urlparse(uri).scheme
backend = self.backends.with_library_browse.get(scheme) backend = self.backends.with_library_browse.get(scheme)
if not backend: if not backend:
return [] 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): 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 protocol supports in a more sane fashion. Other frontends are not
recommended to use this method. recommended to use this method.
:param string field: One of ``artist``, ``albumartist``, ``album``, :param string field: One of ``track``, ``artist``, ``albumartist``,
``composer``, ``performer``, ``date``or ``genre``. ``album``, ``composer``, ``performer``, ``date`` or ``genre``.
:param dict query: Query to use for limiting results, see :param dict query: Query to use for limiting results, see
:meth:`search` for details about the query format. :meth:`search` for details about the query format.
:rtype: set of values corresponding to the requested field type. :rtype: set of values corresponding to the requested field type.
.. versionadded:: 1.0 .. versionadded:: 1.0
""" """
futures = [b.library.get_distinct(field, query) validation.check_choice(field, validation.DISTINCT_FIELDS)
for b in self.backends.with_library.values()] query is None or validation.check_query(query) # TODO: normalize?
result = set() result = set()
for r in pykka.get_all(futures): futures = {b: b.library.get_distinct(field, query)
result.update(r) 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 return result
def get_images(self, uris): def get_images(self, uris):
@ -113,20 +155,31 @@ class LibraryController(object):
Unknown URIs or URIs the corresponding backend couldn't find anything Unknown URIs or URIs the corresponding backend couldn't find anything
for will simply return an empty list for that URI. 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`} :rtype: {uri: tuple of :class:`mopidy.models.Image`}
.. versionadded:: 1.0 .. versionadded:: 1.0
""" """
futures = [ validation.check_uris(uris)
backend.library.get_images(backend_uris)
futures = {
backend: backend.library.get_images(backend_uris)
for (backend, 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} results = {uri: tuple() for uri in uris}
for r in pykka.get_all(futures): for backend, future in futures.items():
for uri, images in r.items(): with _backend_error_handling(backend):
results[uri] += tuple(images) 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 return results
def find_exact(self, query=None, uris=None, **kwargs): def find_exact(self, query=None, uris=None, **kwargs):
@ -140,7 +193,7 @@ class LibraryController(object):
def lookup(self, uri=None, uris=None): 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 If the URI expands to multiple tracks, the returned list will contain
them all. them all.
@ -150,7 +203,7 @@ class LibraryController(object):
:param uris: track URIs :param uris: track URIs
:type uris: list of string or :class:`None` :type uris: list of string or :class:`None`
:rtype: list of :class:`mopidy.models.Track` if uri was set or :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 .. versionadded:: 1.0
The ``uris`` argument. The ``uris`` argument.
@ -158,11 +211,11 @@ class LibraryController(object):
.. deprecated:: 1.0 .. deprecated:: 1.0
The ``uri`` argument. Use ``uris`` instead. The ``uri`` argument. Use ``uris`` instead.
""" """
none_set = uri is None and uris is None if sum(o is not None for o in [uri, uris]) != 1:
both_set = uri is not None and uris is not None raise ValueError('Exactly one of "uri" or "uris" must be set')
if none_set or both_set: uris is None or validation.check_uris(uris)
raise ValueError("One of 'uri' or 'uris' must be set") uri is None or validation.check_uri(uri)
if uri: if uri:
deprecation.warn('core.library.lookup:uri_arg') deprecation.warn('core.library.lookup:uri_arg')
@ -171,23 +224,23 @@ class LibraryController(object):
uris = [uri] uris = [uri]
futures = {} futures = {}
result = {} results = {u: [] for u in uris}
backends = self._get_backends_to_uris(uris)
# TODO: lookup(uris) to backend APIs # TODO: lookup(uris) to backend APIs
for backend, backend_uris in backends.items(): for backend, backend_uris in self._get_backends_to_uris(uris).items():
for u in backend_uris or []: for u in backend_uris:
futures[u] = backend.library.lookup(u) futures[(backend, u)] = backend.library.lookup(u)
for u in uris: for (backend, u), future in futures.items():
if u in futures: with _backend_error_handling(backend):
result[u] = futures[u].get() result = future.get()
else: if result is not None:
result[u] = [] validation.check_instances(result, models.Track)
results[u] = result
if uri: if uri:
return result[uri] return results[uri]
return result return results
def refresh(self, uri=None): def refresh(self, uri=None):
""" """
@ -196,14 +249,22 @@ class LibraryController(object):
:param uri: directory or track URI :param uri: directory or track URI
:type uri: string :type uri: string
""" """
if uri is not None: uri is None or validation.check_uri(uri)
backend = self._get_backend(uri)
if backend: futures = {}
backend.library.refresh(uri).get() backends = {}
else: uri_scheme = urlparse.urlparse(uri).scheme if uri else None
futures = [b.library.refresh(uri)
for b in self.backends.with_library.values()] for backend_scheme, backend in self.backends.with_playlists.items():
pykka.get_all(futures) 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): def search(self, query=None, uris=None, exact=False, **kwargs):
""" """
@ -217,26 +278,27 @@ class LibraryController(object):
# Returns results matching 'a' in any backend # Returns results matching 'a' in any backend
search({'any': ['a']}) search({'any': ['a']})
search(any=['a'])
# Returns results matching artist 'xyz' in any backend # Returns results matching artist 'xyz' in any backend
search({'artist': ['xyz']}) search({'artist': ['xyz']})
search(artist=['xyz'])
# Returns results matching 'a' and 'b' and artist 'xyz' in any # Returns results matching 'a' and 'b' and artist 'xyz' in any
# backend # backend
search({'any': ['a', 'b'], 'artist': ['xyz']}) search({'any': ['a', 'b'], 'artist': ['xyz']})
search(any=['a', 'b'], artist=['xyz'])
# Returns results matching 'a' if within the given URI roots # Returns results matching 'a' if within the given URI roots
# "file:///media/music" and "spotify:" # "file:///media/music" and "spotify:"
search({'any': ['a']}, uris=['file:///media/music', '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 :param query: one or more queries to search for
:type query: dict :type query: dict
:param uris: zero or more URI roots to limit the search to :param uris: zero or more URI roots to limit the search to
:type uris: list of strings or :class:`None` :type uris: list of string or :class:`None`
:param exact: if the search should use exact matching
:type exact: :class:`bool`
:rtype: list of :class:`mopidy.models.SearchResult` :rtype: list of :class:`mopidy.models.SearchResult`
.. versionadded:: 1.0 .. versionadded:: 1.0
@ -253,6 +315,10 @@ class LibraryController(object):
""" """
query = _normalize_query(query or kwargs) 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: if kwargs:
deprecation.warn('core.library.search:kwargs_query') deprecation.warn('core.library.search:kwargs_query')
@ -264,20 +330,31 @@ class LibraryController(object):
futures[backend] = backend.library.search( futures[backend] = backend.library.search(
query=query, uris=backend_uris, exact=exact) 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 = [] results = []
for backend, future in futures.items(): for backend, future in futures.items():
try: 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: except TypeError:
backend_name = backend.actor_ref.actor_class.__name__ backend_name = backend.actor_ref.actor_class.__name__
logger.warning( logger.warning(
'%s does not implement library.search() with "exact" ' '%s does not implement library.search() with "exact" '
'support. Please upgrade it.', backend_name) 'support. Please upgrade it.', backend_name)
return [r for r in results if r]
return results
def _normalize_query(query): def _normalize_query(query):
broken_client = False broken_client = False
# TODO: this breaks if query is not a dictionary like object...
for (field, values) in query.items(): for (field, values) in query.items():
if isinstance(values, basestring): if isinstance(values, basestring):
broken_client = True broken_client = True

View File

@ -1,11 +1,27 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
import contextlib
import logging import logging
from mopidy import exceptions
from mopidy.internal import validation
logger = logging.getLogger(__name__) 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): class MixerController(object):
pykka_traversable = True pykka_traversable = True
@ -19,8 +35,15 @@ class MixerController(object):
The volume scale is linear. The volume scale is linear.
""" """
if self._mixer is not None: if self._mixer is None:
return self._mixer.get_volume().get() 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): def set_volume(self, volume):
"""Set the volume. """Set the volume.
@ -31,10 +54,17 @@ class MixerController(object):
Returns :class:`True` if call is successful, otherwise :class:`False`. Returns :class:`True` if call is successful, otherwise :class:`False`.
""" """
validation.check_integer(volume, min=0, max=100)
if self._mixer is None: if self._mixer is None:
return False return False # TODO: 2.0 return None
else:
return self._mixer.set_volume(volume).get() 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): def get_mute(self):
"""Get mute state. """Get mute state.
@ -42,8 +72,15 @@ class MixerController(object):
:class:`True` if muted, :class:`False` unmuted, :class:`None` if :class:`True` if muted, :class:`False` unmuted, :class:`None` if
unknown. unknown.
""" """
if self._mixer is not None: if self._mixer is None:
return self._mixer.get_mute().get() 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): def set_mute(self, mute):
"""Set mute state. """Set mute state.
@ -52,7 +89,13 @@ class MixerController(object):
Returns :class:`True` if call is successful, otherwise :class:`False`. Returns :class:`True` if call is successful, otherwise :class:`False`.
""" """
validation.check_boolean(mute)
if self._mixer is None: if self._mixer is None:
return False return False # TODO: 2.0 return None
else:
return self._mixer.set_mute(bool(mute)).get() with _mixer_error_handling(self._mixer):
result = self._mixer.set_mute(bool(mute)).get()
validation.check_instance(result, bool)
return result
return False

View File

@ -3,9 +3,10 @@ from __future__ import absolute_import, unicode_literals
import logging import logging
import urlparse import urlparse
from mopidy import models
from mopidy.audio import PlaybackState from mopidy.audio import PlaybackState
from mopidy.core import listener from mopidy.core import listener
from mopidy.utils import deprecation from mopidy.internal import deprecation, validation
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -64,9 +65,7 @@ class PlaybackController(object):
Returns a :class:`mopidy.models.Track` or :class:`None`. Returns a :class:`mopidy.models.Track` or :class:`None`.
""" """
tl_track = self.get_current_tl_track() return getattr(self.get_current_tl_track(), 'track', None)
if tl_track is not None:
return tl_track.track
current_track = deprecation.deprecated_property(get_current_track) current_track = deprecation.deprecated_property(get_current_track)
""" """
@ -74,6 +73,18 @@ class PlaybackController(object):
Use :meth:`get_current_track` instead. 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): def get_stream_title(self):
"""Get the current stream title or :class:`None`.""" """Get the current stream title or :class:`None`."""
return self._stream_title return self._stream_title
@ -100,6 +111,8 @@ class PlaybackController(object):
"PAUSED" -> "PLAYING" [ label="resume" ] "PAUSED" -> "PLAYING" [ label="resume" ]
"PAUSED" -> "STOPPED" [ label="stop" ] "PAUSED" -> "STOPPED" [ label="stop" ]
""" """
validation.check_choice(new_state, validation.PLAYBACK_STATES)
(old_state, self._state) = (self.get_state(), new_state) (old_state, self._state) = (self.get_state(), new_state)
logger.debug('Changing state: %s -> %s', old_state, new_state) logger.debug('Changing state: %s -> %s', old_state, new_state)
@ -265,17 +278,37 @@ class PlaybackController(object):
self.set_state(PlaybackState.PAUSED) self.set_state(PlaybackState.PAUSED)
self._trigger_track_playback_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 Play the given track, or if the given tl_track and tlid is
currently active track. :class:`None`, play the currently active track.
Note that the track **must** already be in the tracklist.
:param tl_track: track to play :param tl_track: track to play
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
:param 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 tl_track is None:
if self.get_state() == PlaybackState.PAUSED: if self.get_state() == PlaybackState.PAUSED:
return self.resume() return self.resume()
@ -382,6 +415,13 @@ class PlaybackController(object):
:rtype: :class:`True` if successful, else :class:`False` :rtype: :class:`True` if successful, else :class:`False`
""" """
# TODO: seek needs to take pending tracks into account :( # 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: if not self.core.tracklist.tracks:
return False return False

View File

@ -1,17 +1,31 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
import contextlib
import logging import logging
import urlparse import urlparse
import pykka from mopidy import exceptions
from mopidy.core import listener from mopidy.core import listener
from mopidy.models import Playlist from mopidy.internal import deprecation, validation
from mopidy.utils import deprecation from mopidy.models import Playlist, Ref
logger = logging.getLogger(__name__) 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): class PlaylistsController(object):
pykka_traversable = True pykka_traversable = True
@ -32,14 +46,19 @@ class PlaylistsController(object):
.. versionadded:: 1.0 .. versionadded:: 1.0
""" """
futures = { futures = {
b.actor_ref.actor_class.__name__: b.playlists.as_list() backend: backend.playlists.as_list()
for b in set(self.backends.with_playlists.values())} for backend in set(self.backends.with_playlists.values())}
results = [] results = []
for backend_name, future in futures.items(): for b, future in futures.items():
try: 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: except NotImplementedError:
backend_name = b.actor_ref.actor_class.__name__
logger.warning( logger.warning(
'%s does not implement playlists.as_list(). ' '%s does not implement playlists.as_list(). '
'Please upgrade it.', backend_name) 'Please upgrade it.', backend_name)
@ -60,10 +79,20 @@ class PlaylistsController(object):
.. versionadded:: 1.0 .. versionadded:: 1.0
""" """
validation.check_uri(uri)
uri_scheme = urlparse.urlparse(uri).scheme uri_scheme = urlparse.urlparse(uri).scheme
backend = self.backends.with_playlists.get(uri_scheme, None) 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): 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 # Use the playlist name from as_list() because it knows about any
# playlist folder hierarchy, which lookup() does not. # playlist folder hierarchy, which lookup() does not.
return [ 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] for r in playlist_refs if playlists[r.uri] is not None]
else: else:
return [ return [
@ -116,16 +145,23 @@ class PlaylistsController(object):
:type name: string :type name: string
:param uri_scheme: use the backend matching the URI scheme :param uri_scheme: use the backend matching the URI scheme
:type uri_scheme: string :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: if uri_scheme in self.backends.with_playlists:
backend = self.backends.with_playlists[uri_scheme] backends = [self.backends.with_playlists[uri_scheme]]
else: else:
# TODO: this fallback looks suspicious backends = self.backends.with_playlists.values()
backend = list(self.backends.with_playlists.values())[0]
playlist = backend.playlists.create(name).get() for backend in backends:
listener.CoreListener.send('playlist_changed', playlist=playlist) with _backend_error_handling(backend):
return playlist 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): def delete(self, uri):
""" """
@ -137,10 +173,18 @@ class PlaylistsController(object):
:param uri: URI of the playlist to delete :param uri: URI of the playlist to delete
:type uri: string :type uri: string
""" """
validation.check_uri(uri)
uri_scheme = urlparse.urlparse(uri).scheme uri_scheme = urlparse.urlparse(uri).scheme
backend = self.backends.with_playlists.get(uri_scheme, None) 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() backend.playlists.delete(uri).get()
# TODO: emit playlist changed?
# TODO: return value?
def filter(self, criteria=None, **kwargs): def filter(self, criteria=None, **kwargs):
""" """
@ -150,15 +194,12 @@ class PlaylistsController(object):
# Returns track with name 'a' # Returns track with name 'a'
filter({'name': 'a'}) filter({'name': 'a'})
filter(name='a')
# Returns track with URI 'xyz' # Returns track with URI 'xyz'
filter({'uri': 'xyz'}) filter({'uri': 'xyz'})
filter(uri='xyz')
# Returns track with name 'a' and URI 'xyz' # Returns track with name 'a' and URI 'xyz'
filter({'name': 'a', 'uri': 'xyz'}) filter({'name': 'a', 'uri': 'xyz'})
filter(name='a', uri='xyz')
:param criteria: one or more criteria to match by :param criteria: one or more criteria to match by
:type criteria: dict :type criteria: dict
@ -170,7 +211,10 @@ class PlaylistsController(object):
deprecation.warn('core.playlists.filter') deprecation.warn('core.playlists.filter')
criteria = criteria or kwargs 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(): for (key, value) in criteria.iteritems():
matches = filter(lambda p: getattr(p, key) == value, matches) matches = filter(lambda p: getattr(p, key) == value, matches)
return matches return matches
@ -186,11 +230,18 @@ class PlaylistsController(object):
""" """
uri_scheme = urlparse.urlparse(uri).scheme uri_scheme = urlparse.urlparse(uri).scheme
backend = self.backends.with_playlists.get(uri_scheme, None) backend = self.backends.with_playlists.get(uri_scheme, None)
if backend: if not backend:
return backend.playlists.lookup(uri).get()
else:
return None 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): def refresh(self, uri_scheme=None):
""" """
Refresh the playlists in :attr:`playlists`. Refresh the playlists in :attr:`playlists`.
@ -203,16 +254,26 @@ class PlaylistsController(object):
:param uri_scheme: limit to the backend matching the URI scheme :param uri_scheme: limit to the backend matching the URI scheme
:type uri_scheme: string :type uri_scheme: string
""" """
if uri_scheme is None: # TODO: check: uri_scheme is None or uri_scheme?
futures = [b.playlists.refresh()
for b in self.backends.with_playlists.values()] futures = {}
pykka.get_all(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') 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): def save(self, playlist):
""" """
@ -236,11 +297,23 @@ class PlaylistsController(object):
:type playlist: :class:`mopidy.models.Playlist` :type playlist: :class:`mopidy.models.Playlist`
:rtype: :class:`mopidy.models.Playlist` or :class:`None` :rtype: :class:`mopidy.models.Playlist` or :class:`None`
""" """
validation.check_instance(playlist, Playlist)
if playlist.uri is None: if playlist.uri is None:
return return # TODO: log this problem?
uri_scheme = urlparse.urlparse(playlist.uri).scheme uri_scheme = urlparse.urlparse(playlist.uri).scheme
backend = self.backends.with_playlists.get(uri_scheme, None) 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() 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 playlist
return None

View File

@ -1,13 +1,12 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
import collections
import logging import logging
import random import random
from mopidy import compat from mopidy import exceptions
from mopidy.core import listener from mopidy.core import listener
from mopidy.models import TlTrack from mopidy.internal import deprecation, validation
from mopidy.utils import deprecation from mopidy.models import TlTrack, Track
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -93,6 +92,7 @@ class TracklistController(object):
:class:`False` :class:`False`
Tracks are not removed from the tracklist. Tracks are not removed from the tracklist.
""" """
validation.check_boolean(value)
if self.get_consume() != value: if self.get_consume() != value:
self._trigger_options_changed() self._trigger_options_changed()
return setattr(self, '_consume', value) return setattr(self, '_consume', value)
@ -121,7 +121,7 @@ class TracklistController(object):
:class:`False` :class:`False`
Tracks are played in the order of the tracklist. Tracks are played in the order of the tracklist.
""" """
validation.check_boolean(value)
if self.get_random() != value: if self.get_random() != value:
self._trigger_options_changed() self._trigger_options_changed()
if value: if value:
@ -157,7 +157,7 @@ class TracklistController(object):
:class:`False` :class:`False`
The tracklist is played once. The tracklist is played once.
""" """
validation.check_boolean(value)
if self.get_repeat() != value: if self.get_repeat() != value:
self._trigger_options_changed() self._trigger_options_changed()
return setattr(self, '_repeat', value) return setattr(self, '_repeat', value)
@ -188,6 +188,7 @@ class TracklistController(object):
:class:`False` :class:`False`
Playback continues after current song. Playback continues after current song.
""" """
validation.check_boolean(value)
if self.get_single() != value: if self.get_single() != value:
self._trigger_options_changed() self._trigger_options_changed()
return setattr(self, '_single', value) return setattr(self, '_single', value)
@ -200,18 +201,52 @@ class TracklistController(object):
# Methods # Methods
def index(self, tl_track): def index(self, tl_track=None, tlid=None):
""" """
The position of the given track in the tracklist. 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 :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` :rtype: :class:`int` or :class:`None`
.. versionadded:: 1.1
The *tlid* parameter
""" """
try: tl_track is None or validation.check_instance(tl_track, TlTrack)
return self._tl_tracks.index(tl_track) tlid is None or validation.check_integer(tlid, min=0)
except ValueError:
return None 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): def eot_track(self, tl_track):
""" """
@ -223,6 +258,8 @@ class TracklistController(object):
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
:rtype: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`mopidy.models.TlTrack` or :class:`None`
""" """
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(): if self.get_single() and self.get_repeat():
return tl_track return tl_track
elif self.get_single(): elif self.get_single():
@ -233,6 +270,23 @@ class TracklistController(object):
# shared. # shared.
return self.next_track(tl_track) 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): def next_track(self, tl_track):
""" """
The track that will be played if calling 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` :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
:rtype: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`mopidy.models.TlTrack` or :class:`None`
""" """
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 return None
if self.get_random() and not self._shuffled: if self.get_random() and not self._shuffled:
if self.get_repeat() or not tl_track: if self.get_repeat() or not tl_track:
logger.debug('Shuffling tracks') logger.debug('Shuffling tracks')
self._shuffled = self.get_tl_tracks() self._shuffled = self._tl_tracks[:]
random.shuffle(self._shuffled) random.shuffle(self._shuffled)
if self.get_random(): if self.get_random():
try: if self._shuffled:
return self._shuffled[0] return self._shuffled[0]
except IndexError: return None
return None
if tl_track is 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(): if self.get_repeat():
next_index %= len(self.get_tl_tracks()) next_index %= len(self._tl_tracks)
elif next_index >= len(self._tl_tracks):
try:
return self.get_tl_tracks()[next_index]
except IndexError:
return None 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): def previous_track(self, tl_track):
""" """
Returns the track that will be played if calling 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` :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
:rtype: :class:`mopidy.models.TlTrack` or :class:`None` :rtype: :class:`mopidy.models.TlTrack` or :class:`None`
""" """
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(): if self.get_repeat() or self.get_consume() or self.get_random():
return tl_track return tl_track
@ -296,30 +370,35 @@ class TracklistController(object):
if position in (None, 0): if position in (None, 0):
return None 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): 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 If ``uri`` is given instead of ``tracks``, the URI is looked up in the
library and the resulting tracks are added to the tracklist. library and the resulting tracks are added to the tracklist.
If ``uris`` is given instead of ``tracks``, the URIs are looked up in If ``uris`` is given instead of ``uri`` or ``tracks``, the URIs are
the library and the resulting tracks are added to the tracklist. looked up in the library and the resulting tracks are added to the
tracklist.
If ``at_position`` is given, the tracks placed at the given position in If ``at_position`` is given, the tracks are inserted at the given
the tracklist. If ``at_position`` is not given, the tracks are appended position in the tracklist. If ``at_position`` is not given, the tracks
to the end of the tracklist. are appended to the end of the tracklist.
Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event. Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event.
:param tracks: tracks to add :param tracks: tracks to add
:type tracks: list of :class:`mopidy.models.Track` :type tracks: list of :class:`mopidy.models.Track` or :class:`None`
:param at_position: position in tracklist to add track :param at_position: position in tracklist to add tracks
:type at_position: int or :class:`None` :type at_position: int or :class:`None`
:param uri: URI for tracks to add :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` :rtype: list of :class:`mopidy.models.TlTrack`
.. versionadded:: 1.0 .. versionadded:: 1.0
@ -328,10 +407,14 @@ class TracklistController(object):
.. deprecated:: 1.0 .. deprecated:: 1.0
The ``tracks`` and ``uri`` arguments. Use ``uris``. The ``tracks`` and ``uri`` arguments. Use ``uris``.
""" """
assert tracks is not None or uri is not None or uris is not None, \ if sum(o is not None for o in [tracks, uri, uris]) != 1:
'tracks, uri or uris must be provided' 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: if tracks:
deprecation.warn('core.tracklist.add:tracks_arg') deprecation.warn('core.tracklist.add:tracks_arg')
@ -349,8 +432,13 @@ class TracklistController(object):
tracks.extend(track_map[uri]) tracks.extend(track_map[uri])
tl_tracks = [] tl_tracks = []
max_length = self.core._config['core']['max_tracklist_length']
for track in tracks: 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) tl_track = TlTrack(self._next_tlid, track)
self._next_tlid += 1 self._next_tlid += 1
if at_position is not None: if at_position is not None:
@ -388,41 +476,35 @@ class TracklistController(object):
# Returns tracks with TLIDs 1, 2, 3, or 4 (tracklist ID) # Returns tracks with TLIDs 1, 2, 3, or 4 (tracklist ID)
filter({'tlid': [1, 2, 3, 4]}) 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' # Returns track with URIs 'xyz' or 'abc'
filter({'uri': ['xyz', 'abc']}) filter({'uri': ['xyz', 'abc']})
filter(uri=['xyz', 'abc'])
# Returns tracks with ID 1 and URI 'xyz' # Returns track with a matching TLIDs (1, 3 or 6) and a
filter({'id': [1], 'uri': ['xyz']}) # matching URI ('xyz' or 'abc')
filter(id=[1], uri=['xyz']) filter({'tlid': [1, 3, 6], 'uri': ['xyz', 'abc']})
# 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'])
:param criteria: on or more criteria to match by :param criteria: on or more criteria to match by
:type criteria: dict, of (string, list) pairs :type criteria: dict, of (string, list) pairs
:rtype: list of :class:`mopidy.models.TlTrack` :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 criteria = criteria or kwargs
tlids = criteria.pop('tlid', [])
validation.check_query(criteria, validation.TRACKLIST_FIELDS)
validation.check_instances(tlids, int)
matches = self._tl_tracks matches = self._tl_tracks
for (key, values) in criteria.items(): for (key, values) in criteria.items():
if (not isinstance(values, collections.Iterable) or matches = [
isinstance(values, compat.string_types)): ct for ct in matches if getattr(ct.track, key) in values]
# Fail hard if anyone is using the <0.17 calling style if tlids:
raise ValueError('Filter values must be iterable: %r' % values) matches = [ct for ct in matches if ct.tlid in tlids]
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]
return matches return matches
def move(self, start, end, to_position): def move(self, start, end, to_position):
@ -443,6 +525,7 @@ class TracklistController(object):
tl_tracks = self._tl_tracks tl_tracks = self._tl_tracks
# TODO: use validation helpers?
assert start < end, 'start must be smaller than end' assert start < end, 'start must be smaller than end'
assert start >= 0, 'start must be at least zero' assert start >= 0, 'start must be at least zero'
assert end <= len(tl_tracks), \ assert end <= len(tl_tracks), \
@ -469,8 +552,14 @@ class TracklistController(object):
:param criteria: on or more criteria to match by :param criteria: on or more criteria to match by
:type criteria: dict :type criteria: dict
:rtype: list of :class:`mopidy.models.TlTrack` that was removed :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: for tl_track in tl_tracks:
position = self._tl_tracks.index(tl_track) position = self._tl_tracks.index(tl_track)
del self._tl_tracks[position] del self._tl_tracks[position]
@ -491,6 +580,7 @@ class TracklistController(object):
""" """
tl_tracks = self._tl_tracks tl_tracks = self._tl_tracks
# TOOD: use validation helpers?
if start is not None and end is not None: if start is not None and end is not None:
assert start < end, 'start must be smaller than end' assert start < end, 'start must be smaller than end'
@ -519,6 +609,7 @@ class TracklistController(object):
:type end: int :type end: int
:rtype: :class:`mopidy.models.TlTrack` :rtype: :class:`mopidy.models.TlTrack`
""" """
# TODO: validate slice?
return self._tl_tracks[start:end] return self._tl_tracks[start:end]
def _mark_playing(self, tl_track): def _mark_playing(self, tl_track):
@ -535,13 +626,13 @@ class TracklistController(object):
def _mark_played(self, tl_track): def _mark_played(self, tl_track):
"""Internal method for :class:`mopidy.core.PlaybackController`.""" """Internal method for :class:`mopidy.core.PlaybackController`."""
if self.consume and tl_track is not None: if self.consume and tl_track is not None:
self.remove(tlid=[tl_track.tlid]) self.remove({'tlid': [tl_track.tlid]})
return True return True
return False return False
def _trigger_tracklist_changed(self): def _trigger_tracklist_changed(self):
if self.get_random(): if self.get_random():
self._shuffled = self.get_tl_tracks() self._shuffled = self._tl_tracks[:]
random.shuffle(self._shuffled) random.shuffle(self._shuffled)
else: else:
self._shuffled = [] self._shuffled = []

View File

@ -21,6 +21,13 @@ class BackendError(MopidyException):
pass pass
class CoreError(MopidyException):
def __init__(self, message, errno=None):
super(CoreError, self).__init__(message, errno)
self.errno = errno
class ExtensionError(MopidyException): class ExtensionError(MopidyException):
pass pass
@ -44,5 +51,16 @@ class ScannerError(MopidyException):
pass pass
class TracklistFull(CoreError):
def __init__(self, message, errno=None):
super(TracklistFull, self).__init__(message, errno)
self.errno = errno
class AudioException(MopidyException): class AudioException(MopidyException):
pass pass
class ValidationError(ValueError):
pass

View File

@ -11,6 +11,12 @@ from mopidy import config as config_lib, exceptions
logger = logging.getLogger(__name__) 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): class Extension(object):
"""Base class for Mopidy extensions""" """Base class for Mopidy extensions"""
@ -89,14 +95,7 @@ class Extension(object):
the ``frontend`` and ``backend`` registry keys. the ``frontend`` and ``backend`` registry keys.
This method can also be used for other setup tasks not involving the This method can also be used for other setup tasks not involving the
extension registry. For example, to register custom GStreamer extension registry.
elements::
def setup(self, registry):
from .mixer import SoundspotMixer
gobject.type_register(SoundspotMixer)
gst.element_register(
SoundspotMixer, 'soundspotmixer', gst.RANK_MARGINAL)
:param registry: the extension registry :param registry: the extension registry
:type registry: :class:`Registry` :type registry: :class:`Registry`
@ -155,55 +154,100 @@ def load_extensions():
for entry_point in pkg_resources.iter_entry_points('mopidy.ext'): for entry_point in pkg_resources.iter_entry_points('mopidy.ext'):
logger.debug('Loading entry point: %s', entry_point) logger.debug('Loading entry point: %s', entry_point)
extension_class = entry_point.load(require=False) extension_class = entry_point.load(require=False)
extension = extension_class()
extension.entry_point = entry_point try:
installed_extensions.append(extension) 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( logger.debug(
'Loaded extension: %s %s', extension.dist_name, extension.version) '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)) logger.debug('Discovered extensions: %s', ', '.join(names))
return installed_extensions return installed_extensions
def validate_extension(extension): def validate_extension_data(data):
"""Verify extension's dependencies and environment. """Verify extension's dependencies and environment.
:param extensions: an extension to check :param extensions: an extension to check
:returns: if extension should be run :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( logger.warning(
'Disabled extension %(ep)s: entry point name (%(ep)s) ' 'Disabled extension %(ep)s: entry point name (%(ep)s) '
'does not match extension name (%(ext)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 return False
try: try:
extension.entry_point.require() data.entry_point.require()
except pkg_resources.DistributionNotFound as ex: except pkg_resources.DistributionNotFound as ex:
logger.info( logger.info(
'Disabled extension %s: Dependency %s not found', 'Disabled extension %s: Dependency %s not found',
extension.ext_name, ex) data.extension.ext_name, ex)
return False return False
except pkg_resources.VersionConflict as ex: except pkg_resources.VersionConflict as ex:
if len(ex.args) == 2: if len(ex.args) == 2:
found, required = ex.args found, required = ex.args
logger.info( logger.info(
'Disabled extension %s: %s required, but found %s at %s', '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: else:
logger.info('Disabled extension %s: %s', extension.ext_name, ex) logger.info(
'Disabled extension %s: %s', data.extension.ext_name, ex)
return False return False
try: try:
extension.validate_environment() data.extension.validate_environment()
except exceptions.ExtensionError as ex: except exceptions.ExtensionError as ex:
logger.info( 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 False
return True return True

32
mopidy/file/__init__.py Normal file
View 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
View 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
View 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
View 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)

View File

@ -16,7 +16,7 @@ import tornado.websocket
from mopidy import exceptions, models, zeroconf from mopidy import exceptions, models, zeroconf
from mopidy.core import CoreListener from mopidy.core import CoreListener
from mopidy.http import handlers from mopidy.http import handlers
from mopidy.utils import encoding, formatting, network from mopidy.internal import encoding, formatting, network
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -1,16 +1,18 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
import functools
import logging import logging
import os import os
import socket import socket
import tornado.escape import tornado.escape
import tornado.ioloop
import tornado.web import tornado.web
import tornado.websocket import tornado.websocket
import mopidy import mopidy
from mopidy import core, models from mopidy import core, models
from mopidy.utils import encoding, jsonrpc from mopidy.internal import encoding, jsonrpc
logger = logging.getLogger(__name__) 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): class WebSocketHandler(tornado.websocket.WebSocketHandler):
# XXX This set is shared by all WebSocketHandler objects. This isn't # XXX This set is shared by all WebSocketHandler objects. This isn't
@ -74,17 +89,17 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
@classmethod @classmethod
def broadcast(cls, msg): 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: for client in cls.clients:
# We could check for client.ws_connection, but we don't really # One callback per client to keep time we hold up the loop short
# care why the broadcast failed, we just want the rest of them # NOTE: Pre 3.0 does not support *args or **kwargs...
# to succeed, so catch everything. loop.add_callback(functools.partial(_send_broadcast, client, msg))
try:
client.write_message(msg)
except Exception as e:
error_msg = encoding.locale_decode(e)
logger.debug('Broadcast of WebSocket message to %s failed: %s',
client.request.remote_ip, error_msg)
# TODO: should this do the same cleanup as the on_message code?
def initialize(self, core): def initialize(self, core):
self.jsonrpc = make_jsonrpc_wrapper(core) self.jsonrpc = make_jsonrpc_wrapper(core)

52
mopidy/httpclient.py Normal file
View 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)

View File

@ -30,6 +30,9 @@ _MESSAGES = {
'core.playback.set_mute': 'playback.set_mute() is deprecated', 'core.playback.set_mute': 'playback.set_mute() is deprecated',
'core.playback.get_volume': 'playback.get_volume() is deprecated', 'core.playback.get_volume': 'playback.get_volume() is deprecated',
'core.playback.set_volume': 'playback.set_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: # Deprecated features in core playlists:
'core.playlists.filter': 'playlists.filter() is deprecated', 'core.playlists.filter': 'playlists.filter() is deprecated',
@ -40,11 +43,32 @@ _MESSAGES = {
'tracklist.add() "tracks" argument is deprecated', 'tracklist.add() "tracks" argument is deprecated',
'core.tracklist.add:uri_arg': 'core.tracklist.add:uri_arg':
'tracklist.add() "uri" argument is deprecated', '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): def warn(msg_id, pending=False):
warnings.warn(_MESSAGES.get(msg_id, msg_id), DeprecationWarning) if pending:
category = PendingDeprecationWarning
else:
category = DeprecationWarning
warnings.warn(_MESSAGES.get(msg_id, msg_id), category)
@contextlib.contextmanager @contextlib.contextmanager

View File

@ -5,13 +5,13 @@ import os
import platform import platform
import sys import sys
import pkg_resources
import pygst import pygst
pygst.require('0.10') pygst.require('0.10')
import gst # noqa import gst # noqa
import pkg_resources from mopidy.internal import formatting
from mopidy.utils import formatting
def format_dependency_list(adapters=None): def format_dependency_list(adapters=None):

View File

@ -11,7 +11,7 @@ import gobject
import pykka import pykka
from mopidy.utils import encoding from mopidy.internal import encoding
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -8,25 +8,15 @@ import threading
import urllib import urllib
import urlparse import urlparse
import glib
from mopidy import compat, exceptions from mopidy import compat, exceptions
from mopidy.compat import queue from mopidy.compat import queue
from mopidy.utils import encoding from mopidy.internal import encoding, xdg
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
XDG_DIRS = { XDG_DIRS = xdg.get_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)
def get_or_create_dir(dir_path): def get_or_create_dir(dir_path):
@ -206,23 +196,23 @@ def find_mtimes(root, follow=False):
return mtimes, errors return mtimes, errors
def check_file_path_is_inside_base_dir(file_path, base_path): def is_path_inside_base_dir(path, base_path):
assert not file_path.endswith(os.sep), ( if path.endswith(os.sep):
'File path %s cannot end with a path separator' % file_path) raise ValueError('Path %s cannot end with a path separator'
% path)
# Expand symlinks # Expand symlinks
real_base_path = os.path.realpath(base_path) 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 if os.path.isfile(path):
# /tmp/foo.m3u as being inside /tmp/foo, simply because they have a # Use dir of file for prefix comparision, so we don't accept
# common prefix, /tmp/foo, which matches the base path, /tmp/foo. # /tmp/foo.m3u as being inside /tmp/foo, simply because they have a
real_dir_path = os.path.dirname(real_file_path) # 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 # Check if dir of file is the base path or a subdir
common_prefix = os.path.commonprefix([real_base_path, real_dir_path]) common_prefix = os.path.commonprefix([real_base_path, real_path])
assert common_prefix == real_base_path, ( return common_prefix == real_base_path
'File path %s must be in %s' % (real_file_path, real_base_path))
# FIXME replace with mock usage in tests. # FIXME replace with mock usage in tests.

View 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
View 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}

View File

@ -2,14 +2,18 @@ from __future__ import absolute_import, unicode_literals
import logging import logging
import gobject
import pykka import pykka
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def send_async(cls, event, **kwargs): 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)) gobject.idle_add(lambda: send(cls, event, **kwargs))

View File

@ -7,8 +7,8 @@ import time
from mopidy import commands, compat, exceptions from mopidy import commands, compat, exceptions
from mopidy.audio import scan, utils from mopidy.audio import scan, utils
from mopidy.internal import path
from mopidy.local import translator from mopidy.local import translator
from mopidy.utils import path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -136,12 +136,14 @@ class ScanCommand(commands.Command):
file_uri = path.path_to_uri(os.path.join(media_dir, relpath)) file_uri = path.path_to_uri(os.path.join(media_dir, relpath))
result = scanner.scan(file_uri) result = scanner.scan(file_uri)
tags, duration = result.tags, result.duration 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', logger.warning('Failed %s: Track shorter than %dms',
uri, MIN_DURATION_MS) uri, MIN_DURATION_MS)
else: else:
mtime = file_mtimes.get(os.path.join(media_dir, relpath)) mtime = file_mtimes.get(os.path.join(media_dir, relpath))
track = utils.convert_tags_to_track(tags).copy( track = utils.convert_tags_to_track(tags).replace(
uri=uri, length=duration, last_modified=mtime) uri=uri, length=duration, last_modified=mtime)
if library.add_supports_tags_and_duration: if library.add_supports_tags_and_duration:
library.add(track, tags=tags, duration=duration) library.add(track, tags=tags, duration=duration)

View File

@ -11,8 +11,8 @@ import tempfile
import mopidy import mopidy
from mopidy import compat, local, models from mopidy import compat, local, models
from mopidy.internal import encoding, timer
from mopidy.local import search, storage, translator from mopidy.local import search, storage, translator
from mopidy.utils import encoding, timer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -141,7 +141,10 @@ class JsonLibrary(local.Library):
return [] return []
def get_distinct(self, field, query=None): 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): def distinct(track):
return {a.name for a in track.artists} return {a.name for a in track.artists}
elif field == 'albumartist': elif field == 'albumartist':
@ -171,7 +174,7 @@ class JsonLibrary(local.Library):
search_result = search.search(self._tracks.values(), query, limit=None) search_result = search.search(self._tracks.values(), query, limit=None)
for track in search_result.tracks: for track in search_result.tracks:
distinct_result.update(distinct(track)) 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): def search(self, query=None, limit=100, offset=0, uris=None, exact=False):
tracks = self._tracks.values() tracks = self._tracks.values()

View File

@ -7,5 +7,5 @@ from mopidy.local import translator
class LocalPlaybackProvider(backend.PlaybackProvider): class LocalPlaybackProvider(backend.PlaybackProvider):
def translate_uri(self, uri): 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']) uri, self.backend.config['local']['media_dir'])

View File

@ -3,7 +3,7 @@ from __future__ import absolute_import, unicode_literals
import logging import logging
import os import os
from mopidy.utils import encoding, path from mopidy.internal import encoding, path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -5,25 +5,41 @@ import os
import urllib import urllib
from mopidy import compat from mopidy import compat
from mopidy.utils.path import path_to_uri, uri_to_path from mopidy.internal import path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def local_track_uri_to_file_uri(uri, media_dir): def local_uri_to_file_uri(uri, media_dir):
return path_to_uri(local_track_uri_to_path(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): def local_uri_to_path(uri, media_dir):
if not uri.startswith('local:track:'): """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.') 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) 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): 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): if isinstance(relpath, compat.text_type):
relpath = relpath.encode('utf-8') relpath = relpath.encode('utf-8')
return b'local:track:%s' % urllib.quote(relpath) return b'local:track:%s' % urllib.quote(relpath)

View File

@ -5,9 +5,9 @@ import logging
import pykka import pykka
from mopidy import backend from mopidy import backend
from mopidy.internal import encoding, path
from mopidy.m3u.library import M3ULibraryProvider from mopidy.m3u.library import M3ULibraryProvider
from mopidy.m3u.playlists import M3UPlaylistsProvider from mopidy.m3u.playlists import M3UPlaylistsProvider
from mopidy.utils import encoding, path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -51,10 +51,11 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider):
if os.path.exists(path): if os.path.exists(path):
os.remove(path) os.remove(path)
else: 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] del self._playlists[uri]
else: else:
logger.warn('Trying to delete unknown playlist %s', uri) logger.warning('Trying to delete unknown playlist %s', uri)
def lookup(self, uri): def lookup(self, uri):
return self._playlists.get(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')): for path in glob.glob(os.path.join(self._playlists_dir, b'*.m3u')):
relpath = os.path.basename(path) relpath = os.path.basename(path)
uri = translator.path_to_playlist_uri(relpath) 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) tracks = translator.parse_m3u(path)
playlists[uri] = Playlist(uri=uri, name=name, tracks=tracks) playlists[uri] = Playlist(uri=uri, name=name, tracks=tracks)
@ -76,6 +77,8 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider):
'Loaded %d M3U playlists from %s', 'Loaded %d M3U playlists from %s',
len(playlists), self._playlists_dir) len(playlists), self._playlists_dir)
# TODO Trigger playlists_loaded event?
def save(self, playlist): def save(self, playlist):
assert playlist.uri, 'Cannot save playlist without URI' assert playlist.uri, 'Cannot save playlist without URI'
assert playlist.uri in self._playlists, \ assert playlist.uri in self._playlists, \
@ -88,11 +91,6 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider):
self._playlists[playlist.uri] = playlist self._playlists[playlist.uri] = playlist
return 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()): def _sanitize_m3u_name(self, name, encoding=sys.getfilesystemencoding()):
name = self._invalid_filename_chars.sub('|', name.strip()) name = self._invalid_filename_chars.sub('|', name.strip())
# make sure we end up with a valid path segment # 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)) name, _ = os.path.splitext(os.path.basename(path).decode(encoding))
else: else:
raise ValueError('M3U playlist needs name or URI') raise ValueError('M3U playlist needs name or URI')
extended = any(track.name for track in playlist.tracks) translator.save_m3u(path, playlist.tracks, 'latin1')
with open(path, 'w') as file_handle:
if extended:
file_handle.write('#EXTM3U\n')
for track in playlist.tracks:
if extended and track.name:
self._write_m3u_extinf(file_handle, track)
file_handle.write(track.uri + '\n')
# assert playlist name matches file name/uri # assert playlist name matches file name/uri
return playlist.copy(uri=uri, name=name) return playlist.replace(uri=uri, name=name)

View File

@ -1,5 +1,6 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
import codecs
import logging import logging
import os import os
import re import re
@ -7,9 +8,8 @@ import urllib
import urlparse import urlparse
from mopidy import compat from mopidy import compat
from mopidy.internal import encoding, path
from mopidy.models import Track 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+),(.*)') 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): def playlist_uri_to_path(uri, playlists_dir):
if not uri.startswith('m3u:'): if not uri.startswith('m3u:'):
raise ValueError('Invalid URI %s' % uri) 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) 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: with open(file_path) as m3u:
contents = m3u.readlines() contents = m3u.readlines()
except IOError as error: 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 return tracks
if not contents: if not contents:
@ -98,13 +98,28 @@ def parse_m3u(file_path, media_dir=None):
continue continue
if urlparse.urlsplit(line).scheme: 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): elif os.path.normpath(line) == os.path.abspath(line):
path = path_to_uri(line) uri = path.path_to_uri(line)
tracks.append(track.copy(uri=path)) tracks.append(track.replace(uri=uri))
elif media_dir is not None: elif media_dir is not None:
path = path_to_uri(os.path.join(media_dir, line)) uri = path.path_to_uri(os.path.join(media_dir, line))
tracks.append(track.copy(uri=path)) tracks.append(track.replace(uri=uri))
track = Track() track = Track()
return tracks 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)

View File

@ -110,6 +110,10 @@ class Mixer(object):
logger.debug('Mixer event: mute_changed(mute=%s)', mute) logger.debug('Mixer event: mute_changed(mute=%s)', mute)
MixerListener.send('mute_changed', mute=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): class MixerListener(listener.Listener):

View File

@ -1,151 +1,16 @@
from __future__ import absolute_import, unicode_literals 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): class Ref(ValidatedImmutableObject):
"""
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):
""" """
Model to represent URI references with a human friendly name and type Model to represent URI references with a human friendly name and type
@ -161,14 +26,15 @@ class Ref(ImmutableObject):
""" """
#: The object URI. Read-only. #: The object URI. Read-only.
uri = None uri = fields.URI()
#: The object name. Read-only. #: The object name. Read-only.
name = None name = fields.String()
#: The object type, e.g. "artist", "album", "track", "playlist", #: The object type, e.g. "artist", "album", "track", "playlist",
#: "directory". Read-only. #: "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. #: Constant used for comparison with the :attr:`type` field.
ALBUM = 'album' ALBUM = 'album'
@ -216,7 +82,7 @@ class Ref(ImmutableObject):
return cls(**kwargs) return cls(**kwargs)
class Image(ImmutableObject): class Image(ValidatedImmutableObject):
""" """
:param string uri: URI of the image :param string uri: URI of the image
@ -225,16 +91,16 @@ class Image(ImmutableObject):
""" """
#: The image URI. Read-only. #: The image URI. Read-only.
uri = None uri = fields.URI()
#: Optional width of the image or :class:`None`. Read-only. #: 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. #: 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 :param uri: artist URI
@ -246,16 +112,16 @@ class Artist(ImmutableObject):
""" """
#: The artist URI. Read-only. #: The artist URI. Read-only.
uri = None uri = fields.URI()
#: The artist name. Read-only. #: The artist name. Read-only.
name = None name = fields.String()
#: The MusicBrainz ID of the artist. Read-only. #: 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 :param uri: album URI
@ -277,39 +143,34 @@ class Album(ImmutableObject):
""" """
#: The album URI. Read-only. #: The album URI. Read-only.
uri = None uri = fields.URI()
#: The album name. Read-only. #: The album name. Read-only.
name = None name = fields.String()
#: A set of album artists. Read-only. #: 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. #: 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. #: The number of discs in the album. Read-only.
num_discs = None num_discs = fields.Integer(min=0)
#: The album release date. Read-only. #: The album release date. Read-only.
date = None date = fields.Date()
#: The MusicBrainz ID of the album. Read-only. #: The MusicBrainz ID of the album. Read-only.
musicbrainz_id = None musicbrainz_id = fields.Identifier()
#: The album image URIs. Read-only. #: 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() # 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 # as it doesn't preserve order. I'm deferring this issue until we got
# actual usage of this field with more than one image. # 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(ValidatedImmutableObject):
class Track(ImmutableObject):
""" """
:param uri: track URI :param uri: track URI
@ -345,64 +206,55 @@ class Track(ImmutableObject):
""" """
#: The track URI. Read-only. #: The track URI. Read-only.
uri = None uri = fields.URI()
#: The track name. Read-only. #: The track name. Read-only.
name = None name = fields.String()
#: A set of track artists. Read-only. #: A set of track artists. Read-only.
artists = frozenset() artists = fields.Collection(type=Artist, container=frozenset)
#: The track :class:`Album`. Read-only. #: The track :class:`Album`. Read-only.
album = None album = fields.Field(type=Album)
#: A set of track composers. Read-only. #: A set of track composers. Read-only.
composers = frozenset() composers = fields.Collection(type=Artist, container=frozenset)
#: A set of track performers`. Read-only. #: A set of track performers`. Read-only.
performers = frozenset() performers = fields.Collection(type=Artist, container=frozenset)
#: The track genre. Read-only. #: The track genre. Read-only.
genre = None genre = fields.String()
#: The track number in the album. Read-only. #: 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. #: The disc number in the album. Read-only.
disc_no = None disc_no = fields.Integer(min=0)
#: The track release date. Read-only. #: The track release date. Read-only.
date = None date = fields.Date()
#: The track length in milliseconds. Read-only. #: The track length in milliseconds. Read-only.
length = None length = fields.Integer(min=0)
#: The track's bitrate in kbit/s. Read-only. #: The track's bitrate in kbit/s. Read-only.
bitrate = None bitrate = fields.Integer(min=0)
#: The track comment. Read-only. #: The track comment. Read-only.
comment = None comment = fields.String()
#: The MusicBrainz ID of the track. Read-only. #: 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 #: Integer representing when the track was last modified. Exact meaning
#: depends on source of track. For local files this is the modification #: 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 #: time in milliseconds since Unix epoch. For other backends it could be an
#: equivalent timestamp or simply a version counter. #: equivalent timestamp or simply a version counter.
last_modified = None last_modified = fields.Integer(min=0)
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)
class TlTrack(ImmutableObject): class TlTrack(ValidatedImmutableObject):
""" """
A tracklist track. Wraps a regular track and it's tracklist ID. A tracklist track. Wraps a regular track and it's tracklist ID.
@ -425,10 +277,10 @@ class TlTrack(ImmutableObject):
""" """
#: The tracklist ID. Read-only. #: The tracklist ID. Read-only.
tlid = None tlid = fields.Integer(min=0)
#: The track. Read-only. #: The track. Read-only.
track = None track = fields.Field(type=Track)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
if len(args) == 2 and len(kwargs) == 0: if len(args) == 2 and len(kwargs) == 0:
@ -441,7 +293,7 @@ class TlTrack(ImmutableObject):
return iter([self.tlid, self.track]) return iter([self.tlid, self.track])
class Playlist(ImmutableObject): class Playlist(ValidatedImmutableObject):
""" """
:param uri: playlist URI :param uri: playlist URI
@ -456,23 +308,19 @@ class Playlist(ImmutableObject):
""" """
#: The playlist URI. Read-only. #: The playlist URI. Read-only.
uri = None uri = fields.URI()
#: The playlist name. Read-only. #: The playlist name. Read-only.
name = None name = fields.String()
#: The playlist's tracks. Read-only. #: The playlist's tracks. Read-only.
tracks = tuple() tracks = fields.Collection(type=Track, container=tuple)
#: The playlist modification time in milliseconds since Unix epoch. #: The playlist modification time in milliseconds since Unix epoch.
#: Read-only. #: Read-only.
#: #:
#: Integer, or :class:`None` if unknown. #: Integer, or :class:`None` if unknown.
last_modified = None last_modified = fields.Integer(min=0)
def __init__(self, *args, **kwargs):
self.__dict__['tracks'] = tuple(kwargs.pop('tracks', None) or [])
super(Playlist, self).__init__(*args, **kwargs)
# TODO: def insert(self, pos, track): ... ? # TODO: def insert(self, pos, track): ... ?
@ -482,7 +330,7 @@ class Playlist(ImmutableObject):
return len(self.tracks) return len(self.tracks)
class SearchResult(ImmutableObject): class SearchResult(ValidatedImmutableObject):
""" """
:param uri: search result URI :param uri: search result URI
@ -496,19 +344,13 @@ class SearchResult(ImmutableObject):
""" """
# The search result URI. Read-only. # The search result URI. Read-only.
uri = None uri = fields.URI()
# The tracks matching the search query. Read-only. # 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. # 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. # The albums matching the search query. Read-only.
albums = tuple() albums = fields.Collection(type=Album, container=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)

154
mopidy/models/fields.py Normal file
View 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
View 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)

View 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

View File

@ -6,8 +6,8 @@ import pykka
from mopidy import exceptions, zeroconf from mopidy import exceptions, zeroconf
from mopidy.core import CoreListener from mopidy.core import CoreListener
from mopidy.internal import encoding, network, process
from mopidy.mpd import session, uri_mapper from mopidy.mpd import session, uri_mapper
from mopidy.utils import encoding, network, process
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -167,7 +167,8 @@ class MpdDispatcher(object):
# TODO: check that blacklist items are valid commands? # TODO: check that blacklist items are valid commands?
blacklist = self.config['mpd'].get('command_blacklist', []) blacklist = self.config['mpd'].get('command_blacklist', [])
if tokens and tokens[0] in 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]) raise exceptions.MpdDisabled(command=tokens[0])
try: try:
return protocol.commands.call(tokens, context=self.context) return protocol.commands.call(tokens, context=self.context)

View File

@ -22,8 +22,8 @@ ENCODING = 'UTF-8'
#: The MPD protocol uses ``\n`` as line terminator. #: The MPD protocol uses ``\n`` as line terminator.
LINE_TERMINATOR = '\n' LINE_TERMINATOR = '\n'
#: The MPD protocol version is 0.17.0. #: The MPD protocol version is 0.19.0.
VERSION = '0.17.0' VERSION = '0.19.0'
def load_protocol_modules(): def load_protocol_modules():
@ -33,7 +33,8 @@ def load_protocol_modules():
""" """
from . import ( # noqa from . import ( # noqa
audio_output, channels, command_list, connection, current_playlist, 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 def INT(value): # noqa: N802

View File

@ -8,7 +8,7 @@ def disableoutput(context, outputid):
""" """
*musicpd.org, audio output section:* *musicpd.org, audio output section:*
``disableoutput`` ``disableoutput {ID}``
Turns an output off. Turns an output off.
""" """
@ -25,7 +25,7 @@ def enableoutput(context, outputid):
""" """
*musicpd.org, audio output section:* *musicpd.org, audio output section:*
``enableoutput`` ``enableoutput {ID}``
Turns an output on. Turns an output on.
""" """

View File

@ -1,7 +1,9 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
import urlparse
from mopidy.internal import deprecation
from mopidy.mpd import exceptions, protocol, translator from mopidy.mpd import exceptions, protocol, translator
from mopidy.utils import deprecation
@protocol.commands.add('add') @protocol.commands.add('add')
@ -21,8 +23,11 @@ def add(context, uri):
if not uri.strip('/'): if not uri.strip('/'):
return return
if context.core.tracklist.add(uris=[uri]).get(): # If we have an URI just try and add it directly without bothering with
return # jumping through browse...
if urlparse.urlparse(uri).scheme != '':
if context.core.tracklist.add(uris=[uri]).get():
return
try: try:
uris = [] uris = []
@ -59,17 +64,21 @@ def addid(context, uri, songpos=None):
""" """
if not uri: if not uri:
raise exceptions.MpdNoExistError('No such song') 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') raise exceptions.MpdArgError('Bad song index')
tl_tracks = context.core.tracklist.add( tl_tracks = context.core.tracklist.add(
uris=[uri], at_position=songpos).get() uris=[uri], at_position=songpos).get()
if not tl_tracks: if not tl_tracks:
raise exceptions.MpdNoExistError('No such song') raise exceptions.MpdNoExistError('No such song')
return ('Id', tl_tracks[0].tlid) return ('Id', tl_tracks[0].tlid)
@protocol.commands.add('delete', position=protocol.RANGE) @protocol.commands.add('delete', songrange=protocol.RANGE)
def delete(context, position): def delete(context, songrange):
""" """
*musicpd.org, current playlist section:* *musicpd.org, current playlist section:*
@ -77,15 +86,15 @@ def delete(context, position):
Deletes a song from the playlist. Deletes a song from the playlist.
""" """
start = position.start start = songrange.start
end = position.stop end = songrange.stop
if end is None: 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() tl_tracks = context.core.tracklist.slice(start, end).get()
if not tl_tracks: if not tl_tracks:
raise exceptions.MpdArgError('Bad song index', command='delete') raise exceptions.MpdArgError('Bad song index', command='delete')
for (tlid, _) in tl_tracks: for (tlid, _) in tl_tracks:
context.core.tracklist.remove(tlid=[tlid]) context.core.tracklist.remove({'tlid': [tlid]})
@protocol.commands.add('deleteid', tlid=protocol.UINT) @protocol.commands.add('deleteid', tlid=protocol.UINT)
@ -97,7 +106,7 @@ def deleteid(context, tlid):
Deletes the song ``SONGID`` from the playlist 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: if not tl_tracks:
raise exceptions.MpdNoExistError('No such song') raise exceptions.MpdNoExistError('No such song')
@ -114,8 +123,8 @@ def clear(context):
context.core.tracklist.clear() context.core.tracklist.clear()
@protocol.commands.add('move', position=protocol.RANGE, to=protocol.UINT) @protocol.commands.add('move', songrange=protocol.RANGE, to=protocol.UINT)
def move_range(context, position, to): def move_range(context, songrange, to):
""" """
*musicpd.org, current playlist section:* *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 Moves the song at ``FROM`` or range of songs at ``START:END`` to
``TO`` in the playlist. ``TO`` in the playlist.
""" """
start = position.start start = songrange.start
end = position.stop end = songrange.stop
if end is None: if end is None:
end = context.core.tracklist.length.get() end = context.core.tracklist.get_length().get()
context.core.tracklist.move(start, end, to) 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 the playlist. If ``TO`` is negative, it is relative to the current
song in the playlist (if there is one). 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: if not tl_tracks:
raise exceptions.MpdNoExistError('No such song') raise exceptions.MpdNoExistError('No such song')
position = context.core.tracklist.index(tl_tracks[0]).get() position = context.core.tracklist.index(tl_tracks[0]).get()
@ -174,13 +183,9 @@ def playlistfind(context, tag, needle):
``playlistfind {TAG} {NEEDLE}`` ``playlistfind {TAG} {NEEDLE}``
Finds songs in the current playlist with strict matching. Finds songs in the current playlist with strict matching.
*GMPC:*
- does not add quotes around the tag.
""" """
if tag == 'filename': 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: if not tl_tracks:
return None return None
position = context.core.tracklist.index(tl_tracks[0]).get() 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. and specifies a single song to display info for.
""" """
if tlid is not None: 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: if not tl_tracks:
raise exceptions.MpdNoExistError('No such song') raise exceptions.MpdNoExistError('No such song')
position = context.core.tracklist.index(tl_tracks[0]).get() position = context.core.tracklist.index(tl_tracks[0]).get()
return translator.track_to_mpd_format(tl_tracks[0], position=position) return translator.track_to_mpd_format(tl_tracks[0], position=position)
else: else:
return translator.tracks_to_mpd_format( return translator.tracks_to_mpd_format(
context.core.tracklist.tl_tracks.get()) context.core.tracklist.get_tl_tracks().get())
@protocol.commands.add('playlistinfo') @protocol.commands.add('playlistinfo')
@ -231,7 +236,7 @@ def playlistinfo(context, parameter=None):
tracklist_slice = protocol.RANGE(parameter) tracklist_slice = protocol.RANGE(parameter)
start, end = tracklist_slice.start, tracklist_slice.stop 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): if start and start > len(tl_tracks):
raise exceptions.MpdArgError('Bad song index') raise exceptions.MpdArgError('Bad song index')
if end and end > len(tl_tracks): if end and end > len(tl_tracks):
@ -251,7 +256,6 @@ def playlistsearch(context, tag, needle):
*GMPC:* *GMPC:*
- does not add quotes around the tag
- uses ``filename`` and ``any`` as tags - uses ``filename`` and ``any`` as tags
""" """
raise exceptions.MpdNotImplemented # TODO raise exceptions.MpdNotImplemented # TODO
@ -274,10 +278,10 @@ def plchanges(context, version):
- Calls ``plchanges "-1"`` two times per second to get the entire playlist. - Calls ``plchanges "-1"`` two times per second to get the entire playlist.
""" """
# XXX Naive implementation that returns all tracks as changed # XXX Naive implementation that returns all tracks as changed
tracklist_version = context.core.tracklist.version.get() tracklist_version = context.core.tracklist.get_version().get()
if version < tracklist_version: if version < tracklist_version:
return translator.tracks_to_mpd_format( return translator.tracks_to_mpd_format(
context.core.tracklist.tl_tracks.get()) context.core.tracklist.get_tl_tracks().get())
elif version == tracklist_version: elif version == tracklist_version:
# A version match could indicate this is just a metadata update, so # 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. # 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: if stream_title is None:
return 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() position = context.core.tracklist.index(tl_track).get()
return translator.track_to_mpd_format( return translator.track_to_mpd_format(
tl_track, position=position, stream_title=stream_title) tl_track, position=position, stream_title=stream_title)
@ -306,17 +310,65 @@ def plchangesposid(context, version):
``playlistlength`` returned by status command. ``playlistlength`` returned by status command.
""" """
# XXX Naive implementation that returns all tracks as changed # XXX Naive implementation that returns all tracks as changed
if int(version) != context.core.tracklist.version.get(): if int(version) != context.core.tracklist.get_version().get():
result = [] result = []
for (position, (tlid, _)) in enumerate( for (position, (tlid, _)) in enumerate(
context.core.tracklist.tl_tracks.get()): context.core.tracklist.get_tl_tracks().get()):
result.append(('cpos', position)) result.append(('cpos', position))
result.append(('Id', tlid)) result.append(('Id', tlid))
return result return result
@protocol.commands.add('shuffle', position=protocol.RANGE) @protocol.commands.add(
def shuffle(context, position=None): '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:* *musicpd.org, current playlist section:*
@ -325,10 +377,10 @@ def shuffle(context, position=None):
Shuffles the current playlist. ``START:END`` is optional and Shuffles the current playlist. ``START:END`` is optional and
specifies a range of songs. specifies a range of songs.
""" """
if position is None: if songrange is None:
start, end = None, None start, end = None, None
else: else:
start, end = position.start, position.stop start, end = songrange.start, songrange.stop
context.core.tracklist.shuffle(start, end) context.core.tracklist.shuffle(start, end)
@ -341,7 +393,7 @@ def swap(context, songpos1, songpos2):
Swaps the positions of ``SONG1`` and ``SONG2``. Swaps the positions of ``SONG1`` and ``SONG2``.
""" """
tracks = context.core.tracklist.tracks.get() tracks = context.core.tracklist.get_tracks().get()
song1 = tracks[songpos1] song1 = tracks[songpos1]
song2 = tracks[songpos2] song2 = tracks[songpos2]
del tracks[songpos1] del tracks[songpos1]
@ -365,8 +417,8 @@ def swapid(context, tlid1, tlid2):
Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids). Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids).
""" """
tl_tracks1 = context.core.tracklist.filter(tlid=[tlid1]).get() tl_tracks1 = context.core.tracklist.filter({'tlid': [tlid1]}).get()
tl_tracks2 = context.core.tracklist.filter(tlid=[tlid2]).get() tl_tracks2 = context.core.tracklist.filter({'tlid': [tlid2]}).get()
if not tl_tracks1 or not tl_tracks2: if not tl_tracks1 or not tl_tracks2:
raise exceptions.MpdNoExistError('No such song') raise exceptions.MpdNoExistError('No such song')
position1 = context.core.tracklist.index(tl_tracks1[0]).get() position1 = context.core.tracklist.index(tl_tracks1[0]).get()
@ -374,39 +426,7 @@ def swapid(context, tlid1, tlid2):
swap(context, position1, position2) swap(context, position1, position2)
# TODO: add at least reflection tests before adding NotImplemented version @protocol.commands.add('addtagid', tlid=protocol.UINT)
# @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)
def addtagid(context, tlid, tag, value): def addtagid(context, tlid, tag, value):
""" """
*musicpd.org, current playlist section:* *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 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 tags received from the server, and the data is gone when the song gets
removed from the queue. 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): def cleartagid(context, tlid, tag):
""" """
*musicpd.org, current playlist section:* *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 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 tag values will be removed. Editing song tags is only possible for
remote songs. 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